Cen #7

Merged
chenlin merged 4 commits from Cen into main 2026-09-07 09:15:38 +08:00
136 changed files with 17353 additions and 1588 deletions
+3 -1
View File
@@ -16,9 +16,10 @@ __pycache__/
*.py[cod] *.py[cod]
*.egg-info/ *.egg-info/
# Local build and cache directories # Local build, cache, and planning directories
build/ build/
.cache/ .cache/
/plans/
# Editors and operating systems # Editors and operating systems
.vscode/ .vscode/
@@ -31,3 +32,4 @@ MUJOCO_LOG.TXT
training_server/rl/logs/ training_server/rl/logs/
training_server/rl/wandb/ training_server/rl/wandb/
training_server/rl/outputs/ training_server/rl/outputs/
training_server/tuning-data/
+3
View File
@@ -0,0 +1,3 @@
Please always speak chinese.
python 虚拟环境路径在/home/cen/Embodied_Workspace/Mujoco_Projects/mujoco/.venv/bin/activate
系统是Ubuntu 24.04 LTS
+5 -5
View File
@@ -10,12 +10,12 @@
- ROS `package://`、常见 URDF 兼容转换及 DAE 降级处理 - ROS `package://`、常见 URDF 兼容转换及 DAE 降级处理
- Three.js 模型、碰撞体、坐标系、关节轴、质心和惯量可视化 - Three.js 模型、碰撞体、坐标系、关节轴、质心和惯量可视化
- 播放、暂停、单步、重置、变速、关节拖动与外力交互 - 播放、暂停、单步、重置、变速、关节拖动与外力交互
- 内置平地、坡道、楼梯、随机障碍物及 9 类系统参数化地形(粗糙/波浪、金字塔阶梯、深坑、沟壑等) - 统一地图资产库:工程地图、认证资产、内置地图及 9 类系统参数化地形共用点击/拖放、场景树、轻量预览、实例变换、放弃和一次编译事务
- 工程地图包:静态 MJCF/OBJ/STL/高度场碰撞层、GLB 视觉层和机器人出生点 - 工程地图包:静态 MJCF/OBJ/STL/高度场碰撞层、GLB 视觉层和机器人出生点;同源实例拥有独立位姿,物理/视觉/出生点同步变换
- V3 地图创作层:认证资产库支持点击添加、拖到画布落位和首个资产自动创建场景,并可通过表单与视口操纵器继续编辑、事务式应用及导出地图 ZIP - V3 地图创作层:认证资产可在程序地形上自动重力落位,共用右侧属性和视口操纵器,支持源内容编辑、空白取消选择及导出地图 ZIP
- 浏览器内 Python 控制器(Pyodide) - 浏览器内 Python 控制器(Pyodide)
- ONNX 强化学习策略推理(ONNX Runtime Web) - ONNX 强化学习策略推理(ONNX Runtime Web)
- 内置 Go2 PPO 任务的本机 mjlab 训练桥接服务 - 内置 Go2 PPO 任务的本机 mjlab 训练桥接服务,以及 DeepSeek 驱动的奖励函数自调参、固定评估和 TensorBoard 风格独立工作台
- 响应式工作区、源码编辑、性能监控和中文诊断 - 响应式工作区、源码编辑、性能监控和中文诊断
## 快速开始 ## 快速开始
@@ -82,7 +82,7 @@ npm run lint:python
## 数据与安全边界 ## 数据与安全边界
模型、资源、Python 控制器和 ONNX 策略默认只在当前浏览器会话中处理,不上传到服务器。训练桥接服务只监听本机回环地址,并仅执行服务端允许列表中的任务。 模型、资源、Python 控制器和 ONNX 策略默认只在当前浏览器会话中处理,不上传到服务器。训练桥接服务只监听本机回环地址,并仅执行服务端允许列表中的任务。启用自调参时,DeepSeek 只接收脱敏后的奖励参数、曲线摘要和评估数值;API key 仅存在训练服务环境中,不进入浏览器、URL、SQLite 或训练日志。
## 上游与许可证 ## 上游与许可证
+116
View File
@@ -0,0 +1,116 @@
# Code Context
## Files Retrieved
1. `wasm/web_platform/src/simulation/SimulationSession.ts`(lines 1-107)- 仿真步进、控制器生命周期、模型名称解析和 `mj_step` 前控制写入的核心集成点。
2. `wasm/web_platform/src/simulation/PhysicsAdapter.ts`(lines 12-110)- UI 与 session 的稳定门面;模型切换、资源释放和 Go2-W URDF 增强发生于此。
3. `wasm/web_platform/src/app/App.tsx`(lines 37-78, 100-105)- 工程导入、控制器状态、错误反馈、右侧栏 wiring。
4. `wasm/web_platform/src/app/components/SidebarPanel.tsx`(lines 19-35)- 当前“控制”tab 仅嵌入 Python 控制器,是 ONNX 面板的最小 UI 插槽。
5. `wasm/web_platform/src/controller/PythonControllerRuntime.ts`(lines 30-120)- 可复用的控制器生命周期/状态设计;同时揭示现有控制回调必须同步。
6. `wasm/web_platform/src/controller/types.ts`(lines 1-42)- 当前状态被写死为 `language:'python'`,且 step API 可读关节、sensor、body quaternion。
7. `wasm/web_platform/src/project/importer.ts`(lines 1-48, 184-223)- 所有二进制文件已原样进入 manifest,ONNX 无需进入模型 entry 发现逻辑。
8. `wasm/web_platform/src/project/types.ts`(lines 1-41)- ProjectFile/大小上限;单文件允许 128 MiB、工程 512 MiB。
9. `wasm/package.json`(scripts/dependencies)- 已有 Vitest/Playwright,尚无 `onnxruntime-web`。
10. `wasm/web_platform/vite.config.ts`(lines 1-27)- Pyodide 本地资产复制是 ORT wasm 离线资产处理的直接范例。
11. `wasm/src/go2_w_balance.py`(lines 1-约480)- 仓库已有 Go2-W 手写 200 Hz 平衡/轮速控制,可作模型命名、IMU 和安全回退参考,但不是 RL 策略。
12. `/home/cen/Embodied_Workspace/unitree_rl_mjlab/deploy/robots/go2/config/policy/velocity/v0/params/deploy.yaml`(lines 1-56)- 部署时序、动作变换及精确 observation 顺序。
13. `/home/cen/Embodied_Workspace/unitree_rl_mjlab/deploy/robots/go2/src/State_RLBase.cpp`(lines 6-31)- 部署约定为 `params/deploy.yaml` + `exported/policy.onnx`,输出经 action manager 处理后按 joint map 下发。
14. `/home/cen/Embodied_Workspace/unitree_rl_mjlab/src/tasks/velocity/config/go2/env_cfgs.py`(lines 1-约160)- flat 任务移除 height scan,Go2 base 为 `base_link`。
## Key Code
### 已有正确插入顺序
`SimulationSession.singleStep/advance` 均执行:控制器 → 外力 → `mj_step`(`SimulationSession.ts:46-57`)。ONNX 动作必须沿用该位置,而不能在 React frame snapshot 后直接写 ctrl,否则会引入一物理步延迟和不稳定的渲染帧时序。
### 参考部署契约
Go2 deploy YAML 定义:
- `step_dt: 0.02`,即策略 50 Hz;仿真步可保持 0.002 s,动作 hold 10 步。
- observation 串接顺序和维数:`base_ang_vel(3), projected_gravity(3), velocity_commands(3), gait_phase(2), joint_pos_rel(12), joint_vel_rel(12), last_action(12)`,总计 **47 float32**。
- action 为 12 维位置目标:`processed = raw * 0.25 + default_joint_pos`;默认位姿见 YAML lines 3-5/18-20。
- joint 顺序不能取 MuJoCo ID 偶然顺序;部署 `joint_ids_map=[3,4,5,0,1,2,9,10,11,6,7,8]` 表示 RL 顺序与硬件/模型顺序存在显式重排。
- ONNX 与参数是配套件:`params/deploy.yaml` + `exported/policy.onnx`(`State_RLBase.cpp:12-16`)。仅导入 `.onnx` 无法可靠解释 observation/action。
### 最小文件级实现方案(建议,不修改)
1. **新增独立策略层**:`src/policy/types.ts`, `OnnxPolicyRuntime.ts`, `Go2VelocityTask.ts`。
- `OnnxPolicyRuntime` 只负责 ORT session、输入/输出名称与 shape 校验、generation cancellation、dispose。
- `Go2VelocityTask` 固化首个 task 的 47→12 契约、命令/phase/lastAction、名称到 joint/actuator 的显式映射、PD 目标或 position actuator 输出。
- 不应把 ONNX 逻辑塞入 `PythonControllerRuntime`;其 Pyodide proxy 与同步函数假设完全不同。
2. **`SimulationSession.ts`**:将单一 `pythonController` 最小泛化为互斥 controller slot,或新增 `onnxPolicy` 并在同一个 `runController()` 中互斥调度;load/remove/reset/dispose 复用 generation 防模型切换竞态。添加只读 observation helpers(按 joint/body/sensor 名称解析),避免 UI 从 snapshot 拼向量。禁用/异常时必须 `ctrl.fill(0)`、暂停并保留错误。
3. **异步风险处理**:`onnxruntime-web` 的 `session.run()` 返回 Promise,不能直接 await 于当前同步 `advance()` while loop。最小侵入方案是“最新完成动作 hold”:到 50 Hz 时复制 observation 发起一次推理(仅允许一个 in-flight),后续物理步继续持有上次动作,完成后原子替换;首帧保持安全默认位姿而非零力矩。状态须显示 inference latency/overrun。若要求训练等价的严格每 20 ms 动作,则需把物理 loop 改为异步/worker,已非最小侵入。
4. **`PhysicsAdapter.ts`**:接口新增 `loadOnnxPolicy(policyBytes, config)`, `setPolicyEnabled`, `setVelocityCommand`, `removePolicy`;MainThread adapter 只转发,模型 `releaseCurrent()` 自动 dispose。不要把 policy 写入 MuJoCo MEMFS,ORT 可直接吃 `Uint8Array`。
5. **项目导入**:`importer.ts` 已保留任意扩展名,ZIP/目录中的 `.onnx/.yaml` 会存在 `manifest.files`;无需修改 `discoverEntries`(它只应发现模型)。在 App 中按配套目录约定寻找 `exported/policy.onnx` 及 `params/deploy.yaml`,首版更稳妥的是新增浏览器可直接解析的 JSON policy manifest;不要为 YAML 引入重量解析器后猜测任意训练配置。独立单文件导入应沿用 Python 的 upsert 流程,但 accept 改 `.onnx` 并施加专用上限。
6. **依赖/构建**:`wasm/package.json` 加 `onnxruntime-web`;`vite.config.ts` 仿照 Pyodide 将 ORT wasm 文件复制到固定相对目录并设置 `ort.env.wasm.wasmPaths`,否则离线 `base:'./'` 构建容易 404。首版使用 wasm CPU 单线程,避免 WebGPU/COOP-COEP 扩大范围。
7. **UI**:新增 `PolicyPanel.tsx`,插在 `SidebarPanel.tsx:32` 的控制 tab,与 Python 控制器并列;展示模型/config、47×12 shape、50 Hz、启停、速度 x/y/yaw、推理耗时/错误。`App.tsx:40,55,72-77,104` 按现有 controller 状态模式增加 policy 状态及 callbacks;加载另一控制器时明确移除/禁用前一个,不能二者同时写 ctrl。
8. **首个 Go2-W 任务边界**:参考仓库提供的是 **Go2 12-DOF** velocity policy,不是 Go2-W policy;Go2-W 多四个轮关节。首版可把 12 个腿关节按该 policy 控制、轮 actuator 固定阻尼/零速,仅称“Go2-W 腿式站立/速度实验”,不能宣称与训练分布一致。真正轮式平衡/速度需要 Go2-W 专属训练导出(observation/action 很可能含轮速和 16 维动作)。
## Architecture
浏览器文件 → `ProjectManifest.files`(二进制 ONNX 已保留)→ `App` 选择配套 policy/config → `PhysicsAdapter` → `SimulationSession` 拥有 policy runtime。每个 MuJoCo 子步前 task 从 `MjData` 组 observation;每 0.02 s 异步提交 ONNX,完成输出经 scale/offset、显式 joint mapping 和限幅后成为 held action;物理 loop 每步应用 held action。snapshot 仅向 UI报告状态,不作为 observation 数据源。
## 观测/动作风险与严重度
- **blocker**:参考资产无 Go2-W ONNX,且仓库参考目录下 Go2 velocity 的 `exported/policy.onnx` 实际不存在;不能验证 input/output 名或数值一致性。
- **blocker**:Go2 12-DOF policy 与 Go2-W 16 actuator 拓扑不匹配。若按 `model.njnt/nactuator` 顺序拼接会 shape 错误或静默错控。
- **high**:`projected_gravity` 必须是重力单位向量由世界系旋到 body/IMU 局部系。MuJoCo `xquat` 为 wxyz;符号、quat inverse、IMU 安装姿态任一错误都会使策略立刻摔倒。加速度计并不等价于无噪声 projected gravity。
- **high**:`base_ang_vel` 需 body/local frame;直接使用 free joint `qvel[3:6]` 或 gyro sensor前必须确认 MuJoCo sensor frame与训练定义一致。
- **high**:`joint_pos_rel = q-default`,joint_vel 和 action 都必须是 YAML 的 RL 顺序;四足 FL/FR/RL/RR 次序在当前手写 Go2-W controller 与 deploy map 中并不天然一致。
- **high**:输出是 normalized action,不是 torque;必须 scale+offset 后交给 position actuator/PD。当前 `setActuator` 只写 ctrl,URDF 自动生成的是 motor(`PhysicsAdapter.ts:68`),因此若直接写位置值会被当 N·m。需要 task 内 PD torque,或为该 task 构造 position actuator。
- **medium**:gait phase 是 0.6 s 周期的 sin/cos,但应确认零点、sin/cos 顺序以及站立零命令时是否冻结;YAML 没编码完整函数语义。
- **medium**:异步 ORT latency 若超过 20 ms 会跳过策略 tick;必须统计 dropped/late inference,且避免并发堆积。
- **medium**:reset 应同时清 lastAction、phase、in-flight generation 和 held action;否则旧 Promise 可污染新 episode。
- **medium**:导入不可信 ONNX 可能造成大内存/计算消耗;现有 128 MiB 文件上限不足以约束 tensor shape/运行时内存。
## 测试建议
1. `src/policy/Go2VelocityTask.test.ts`:固定 qpos/qvel/quaternion/command,逐元素断言 47 维顺序、dtype、projected gravity、phase、joint reorder;固定 raw action 断言 `*0.25+offset` 和 actuator 映射。
2. `src/policy/OnnxPolicyRuntime.test.ts`:mock ORT,覆盖 input/output shape/name 错误、NaN/Inf、single-flight、超时/overrun、generation cancellation、dispose;用极小 identity ONNX fixture 做一次真实 wasm smoke test。
3. 新增 `SimulationSession.test.ts`(当前该核心类无测试):mock MainModule 验证 controller 在 `mj_step` 前执行、50 Hz decimation、held action、reset/disable/error 清 ctrl、Python/ONNX 互斥、模型切换后旧推理无效。
4. `PhysicsAdapter` contract test:load/remove/dispose 转发及失败后 workspace/runtime 都释放。
5. `importer.test.ts`:ZIP/目录保留 `.onnx` 与 config、路径规范化、重复/超限拒绝;entry discovery 不把 ONNX 当模型。
6. `PolicyPanel.test.tsx` + `SidebarPanel` 测试:导入、启停、命令范围、shape/错误展示、另一控制器启用时互斥。
7. Playwright:导入 Go2-W 工程+fixture policy,启用后推进若干秒,断言页面不崩、policy 状态与命令变化;数值验收应另做离线 golden trajectory(相同初态前 N 次 observation/action 与 Python/ORT reference 对齐),容差逐元素约 `1e-5`,并检查 base height/倾角安全阈值。
8. 常规命令:`npm run typecheck:platform`, `npm run lint:platform`, `npm run test:platform`, `npm run build:platform`, 最后 `npm run test:e2e:platform`。
## Start Here
先打开 `wasm/web_platform/src/simulation/SimulationSession.ts:44-93`:这是唯一能保证 observation 采样、控制输出和 `mj_step` 顺序正确,并统一 reset/error/dispose 语义的位置。
## Residual Risks
- 未获得 Go2-W 专属 ONNX 与导出 metadata,无法完成真实 shape/name/golden 验证。
- unitree_rl_mjlab 的 YAML 说明字段顺序与缩放,但 gait phase、frame convention 等函数语义仍需用实际导出/运行器确认。
- 主线程异步 inference 的 held-action 方案是最小侵入折衷,不提供硬实时或训练环境严格等价。
```acceptance-report
{
"criteriaSatisfied": [
{
"id": "criterion-1",
"status": "satisfied",
"evidence": "已给出 SimulationSession、PhysicsAdapter、App/Sidebar、导入与测试结构的具体文件/行范围方案,并按 blocker/high/medium 列出观测动作风险。"
}
],
"changedFiles": [],
"testsAddedOrUpdated": [],
"commandsRun": [
{
"command": "find/grep/read/nl(只读仓库与 unitree_rl_mjlab 参考配置)",
"result": "passed",
"summary": "确认现有控制步序、项目导入行为、测试布局及 Go2 deploy 的 47维观测/12维动作约定。"
}
],
"validationOutput": [
"参考 deploy.yaml: step_dt=0.02,观测 3+3+3+2+12+12+12=47,动作 12,scale=0.25。",
"参考部署约定为 params/deploy.yaml + exported/policy.onnx;当前 Go2 目录未发现实际 ONNX。",
"当前仓库 package dependencies 未包含 onnxruntime-web。"
],
"residualRisks": [
"缺少 Go2-W 专属 ONNX/metadata,参考仅为 Go2 12-DOF。",
"异步浏览器 ORT 与同步物理 loop 存在时序折衷。",
"projected gravity、角速度 frame、joint mapping 必须做 golden 对齐。"
],
"noStagedFiles": true,
"diffSummary": "只读分析;未修改仓库文件,仅写入指定 context.md 报告。",
"reviewFindings": [
"blocker: /home/cen/Embodied_Workspace/unitree_rl_mjlab/deploy/robots/go2 - 未发现 Go2 velocity policy.onnx,无法验证模型 I/O。",
"blocker: deploy.yaml:1-56 - 参考策略为 12-DOF Go2,与 Go2-W 16 actuator 不匹配。",
"high: wasm/web_platform/src/simulation/SimulationSession.ts:49-57 - 同步物理 while loop 无法直接 await ORT Promise。",
"high: wasm/web_platform/src/simulation/PhysicsAdapter.ts:68-70 - URDF 自动生成 motor,normalized position action 不可直接写 ctrl。"
],
"manualNotes": "报告已写入权威路径;除 context.md 外未改动项目。"
}
```
+9 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mujoco-web-platform", "name": "mujoco-web-platform",
"version": "0.7.3", "version": "0.8.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mujoco-web-platform", "name": "mujoco-web-platform",
"version": "0.7.3", "version": "0.8.3",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
@@ -18,6 +18,7 @@
"pyodide": "^0.29.4", "pyodide": "^0.29.4",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"uplot": "^1.6.32",
"zustand": "^5.0.15" "zustand": "^5.0.15"
}, },
"devDependencies": { "devDependencies": {
@@ -5091,6 +5092,12 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/uplot": {
"version": "1.6.32",
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
"license": "MIT"
},
"node_modules/uri-js": { "node_modules/uri-js": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mujoco-web-platform", "name": "mujoco-web-platform",
"version": "0.7.3", "version": "0.8.3",
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台", "description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -61,6 +61,7 @@
"pyodide": "^0.29.4", "pyodide": "^0.29.4",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"uplot": "^1.6.32",
"zustand": "^5.0.15" "zustand": "^5.0.15"
}, },
"engines": { "engines": {
+1
View File
@@ -1 +1,2 @@
-r training_server/requirements.txt
ruff==0.16.5 ruff==0.16.5
+39 -5
View File
@@ -1,6 +1,6 @@
# 本地强化学习训练服务 # 本地强化学习训练服务
该服务把 Web 平台发出的受限训练请求转换为本机训练子进程,并提供状态轮询、停止任务和 `policy.onnx` 下载接口。服务只绑定 `127.0.0.1`,不执行前端传入的任意命令。 该服务把 Web 平台发出的受限训练请求转换为本机训练子进程,并提供状态轮询、停止任务和 `policy.onnx` 下载接口。它还提供 `Unitree-Go2-Flat` 奖励函数自调参:DeepSeek Agent 根据训练曲线与固定评估指标提出受限参数 patch,系统支持全自动或逐轮审批、successive-halving、TensorBoard scalar 查询、最佳 preset 与 ONNX 导出。服务只绑定 `127.0.0.1`,不执行前端传入的任意命令或 Agent 生成的代码。
仓库已在 [`rl/`](rl/) 内置 `Unitree-Go2-Flat` 所需的 PPO 训练代码、Go2 模型资产和 ONNX 导出逻辑,不再要求另外克隆 `unitree_rl_mjlab`。`mjlab`、PyTorch 等大型运行依赖仍需安装在本机训练环境中。 仓库已在 [`rl/`](rl/) 内置 `Unitree-Go2-Flat` 所需的 PPO 训练代码、Go2 模型资产和 ONNX 导出逻辑,不再要求另外克隆 `unitree_rl_mjlab`。`mjlab`、PyTorch 等大型运行依赖仍需安装在本机训练环境中。
@@ -11,6 +11,7 @@
```bash ```bash
source .venv/bin/activate source .venv/bin/activate
python -m pip install -r training_server/rl/requirements.txt python -m pip install -r training_server/rl/requirements.txt
python -m pip install -r training_server/requirements.txt
``` ```
## 启动 ## 启动
@@ -29,6 +30,17 @@ MUJOCO_TRAINING_TOKEN='至少十六个字符的随机令牌' npm run training-se
--trainer-python /path/to/training-env/bin/python --trainer-python /path/to/training-env/bin/python
``` ```
启用云端自调参 Agent 时,在**服务端环境变量**中配置 DeepSeek;不要把 key 填到浏览器、URL 或命令行参数:
```bash
export DEEPSEEK_API_KEY='你的 DeepSeek API key'
export MUJOCO_TUNING_AGENT_BASE_URL='https://api.deepseek.com' # 可省略
export MUJOCO_TUNING_AGENT_MODEL='deepseek-v4-flash' # 可省略
npm run training-server -- --trainer-python "$PWD/.venv/bin/python"
```
普通训练不要求 DeepSeek key。未配置时健康接口会把 tuning 标记为不可用;只有创建 session 时明确勾选 fallback,才允许 Agent 失败后使用 Optuna 候选,不会静默降级。
默认训练工程是仓库内的 `training_server/rl`。如需使用包含其他已注册任务的外部训练工程,仍可通过 `--trainer-root /path/to/trainer` 或 `UNITREE_RL_MJLAB_ROOT` 覆盖。默认端口是 `8765`。如果前端不是从 `localhost` 或 `127.0.0.1` 提供,可显式添加来源: 默认训练工程是仓库内的 `training_server/rl`。如需使用包含其他已注册任务的外部训练工程,仍可通过 `--trainer-root /path/to/trainer` 或 `UNITREE_RL_MJLAB_ROOT` 覆盖。默认端口是 `8765`。如果前端不是从 `localhost` 或 `127.0.0.1` 提供,可显式添加来源:
```bash ```bash
@@ -37,7 +49,17 @@ python training_server/server.py \
--allow-origin http://192.168.1.10:5173 --allow-origin http://192.168.1.10:5173
``` ```
服务一次只运行一个训练任务,最多保留 20 个任务的内存状态,每个任务最多保留 200 行最近日志。停止服务或在界面点击“停止训练”会同步终止整个训练进程组。训练请求的 W&B 模式默认为 `offline`,保留本地指标但不登录;也可以在界面选择完全禁用或在线模式。所有 API 请求都必须携带启动时生成的 Bearer Token。 普通训练与自调参共享同一个计算资源锁,任何时刻只允许一个训练/评估子进程占用 GPU。普通任务最多保留 20 个内存状态和每个任务 200 行最近日志;调参 session、trial、proposal、审计和 scalar 写入 SQLite/WAL,默认保存在 `training_server/rl/logs/auto_tuning/`。服务重启后等待审批/暂停状态可恢复,正在训练或评估的 trial 标记为 interrupted,只能从已完整保存的 checkpoint 显式恢复。停止服务或点击“停止”会终止整个进程组。
普通训练的 W&B 模式默认为 `offline`;调参 trial 强制使用本地 TensorBoard writer,DeepSeek 只接收最多 12 个 trial 的脱敏数值摘要和降采样曲线,不接收源代码、机器人资产、checkpoint、服务 token 或本地路径。所有 HTTP API 请求都必须携带启动时生成的 Bearer Token。
## 自调参流程
在主工作台连接训练服务后,点击“打开自调参 Agent 工作台”。默认预算为 12 个唯一配置:所有配置先训练 300 iterations,前 4 名续训到 900,前 2 名续训到 2000;默认使用 GPU 0 和 4096 个并行环境。首次使用建议先降低为 256–512 environments 做 smoke test。
固定评估使用站立、前进/侧移、转向和组合命令以及 3 个固定 seed。最终分数不直接使用可被权重放大的总 reward,而由速度跟踪 35%、动作平滑 20%、姿态稳定 15%、减少跌倒 15%、足端滑移 10%、能耗 5% 的权重无关指标组成。跌倒率高于基线 2% 或速度误差恶化超过 5% 的 trial 不晋级。逐轮审批模式会自动运行基线,之后每条 Agent 建议都等待批准、修改后批准或拒绝反馈;运行中的 session 也可在逐轮审批和全自动之间切换,切到全自动时会批准当前待处理建议。
最佳结果保存为不可变 preset,可在普通训练面板的“奖励配置”中选择,也可导出 JSON;不会覆盖仓库里的 Python 默认奖励配置。
## 接口 ## 接口
@@ -45,14 +67,26 @@ python training_server/server.py \
- `POST /api/training/jobs`:发起训练; - `POST /api/training/jobs`:发起训练;
- `GET /api/training/jobs/{id}`:状态、迭代进度和最近日志; - `GET /api/training/jobs/{id}`:状态、迭代进度和最近日志;
- `DELETE /api/training/jobs/{id}`:停止训练; - `DELETE /api/training/jobs/{id}`:停止训练;
- `GET /api/training/jobs/{id}/artifacts/policy.onnx`:下载本次生成的策略。 - `GET /api/training/jobs/{id}/artifacts/policy.onnx`:下载本次生成的策略;
- `GET /api/tuning/capabilities`、`POST /api/tuning/agent/test`:检查/测试 Agent;
- `GET|POST /api/tuning/sessions`、`GET|DELETE /api/tuning/sessions/{id}`:列出、创建、查询、停止 session;
- `POST /api/tuning/sessions/{id}/pause|resume`:暂停后续调度或恢复;
- `POST /api/tuning/sessions/{id}/mode`:运行时切换 `automatic`/`approval` 模式;
- `PUT /api/tuning/sessions/{id}/constraints`:以 revision CAS 保存参数固定值/工程上下限,服务端在 Agent、fallback 与人工修改三条路径统一强制;
- `POST /api/tuning/sessions/{id}/step`:发放且只消费一个 Trial 调度令牌,完成训练与固定评估后重新暂停;
- `POST /api/tuning/sessions/{id}/rollback`:把同 Session 内已完成且通过安全门槛的 Trial 设为非破坏性后续基准,可同时验证其 checkpoint;
- `POST /api/tuning/sessions/{id}/proposals/{proposalId}/approve|reject`:审批、修改或拒绝建议;
- `GET /api/tuning/sessions/{id}/trials/{trialId}/metrics?afterStep=N`:查询降采样或增量 scalar;
- `GET /api/tuning/sessions/{id}/artifacts/best/policy.onnx`:下载最佳策略;
- `GET /api/tuning/presets`:列出可供普通训练复用的最佳奖励 preset。
任务保存在服务内存中,服务重启后历史任务状态会丢失;训练日志、checkpoint 和 ONNX 产物保留在 `training_server/rl/logs/rsl_rl/`,使用外部训练工程时则保留在对应工程中。 普通任务状态在服务重启后丢失,但日志、checkpoint 和 ONNX 保留在 `training_server/rl/logs/rsl_rl/`;调参状态、调度令牌、参数护栏、回滚基准及产物持久化在 `logs/auto_tuning/`。候选配置数可在 1–100 间设置(包含基线配置,仍受连续无提升早停约束)。API 只接收 32 位资源 ID,不接收客户端文件路径;奖励 patch 受到名称、符号、上下界、Session 护栏、每轮最多 4 项及 `0.5×–2×` 变化率校验。
## 测试 ## 测试
```bash ```bash
python3 -m pip install -r requirements-dev.txt source .venv/bin/activate
python -m pip install -r requirements-dev.txt
npm run lint:python npm run lint:python
npm run test:training-server npm run test:training-server
``` ```
+5
View File
@@ -0,0 +1,5 @@
# 本地自调参服务(Python 3.12)
pydantic-ai-slim[openai]==2.37.0
httpx2[socks]==2.12.0
optuna==4.9.0
tensorboard==2.21.0
+222
View File
@@ -0,0 +1,222 @@
"""Deterministic, headless evaluation for Unitree Go2 velocity policies."""
from __future__ import annotations
import hashlib
import json
import os
import sys
from dataclasses import asdict, dataclass, field
from pathlib import Path
from statistics import fmean, pstdev
from typing import Literal
TRAINER_ROOT = Path(__file__).resolve().parents[1]
SERVICE_ROOT = TRAINER_ROOT.parent
for source_root in (TRAINER_ROOT, SERVICE_ROOT):
if str(source_root) not in sys.path:
sys.path.insert(0, str(source_root))
import torch
import tyro
import warp as wp
if not hasattr(wp, "context"):
from warp._src import context as warp_context
wp.context = warp_context # type: ignore[attr-defined]
from mjlab.envs import ManagerBasedRlEnv
from mjlab.rl import MjlabOnPolicyRunner, RslRlVecEnvWrapper
from mjlab.tasks.registry import list_tasks, load_env_cfg, load_rl_cfg, load_runner_cls
from mjlab.tasks.velocity.mdp import UniformVelocityCommandCfg
from mjlab.utils.torch import configure_torch_backends
from torch.utils.tensorboard import SummaryWriter
from tuning.schema import apply_reward_configuration, validate_configuration
SCENARIOS = (
(0.0, 0.0, 0.0),
(0.5, 0.0, 0.0),
(1.0, 0.0, 0.0),
(1.5, 0.0, 0.0),
(0.0, 0.5, 0.0),
(0.0, -0.5, 0.0),
(0.0, 0.0, 0.5),
(0.0, 0.0, -0.5),
(0.8, 0.25, 0.35),
)
METRIC_NAMES = (
"linear_velocity_rmse",
"angular_velocity_rmse",
"mean_action_acc",
"orientation_error",
"slip_velocity",
"mechanical_power",
)
@dataclass(frozen=True)
class EvaluateConfig:
checkpoint: str
output: str
reward_config: str | None = None
num_envs: int = 256
steps_per_seed: int = 1000
seeds: tuple[int, ...] = field(default_factory=lambda: (101, 202, 303))
device: str | None = None
gpu_ids: list[int] | Literal["all"] | None = field(default_factory=lambda: [0])
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _fixed_commands(command: torch.Tensor) -> torch.Tensor:
values = torch.as_tensor(SCENARIOS, device=command.device, dtype=command.dtype)
indexes = torch.arange(command.shape[0], device=command.device) % values.shape[0]
command[:] = values[indexes]
return command
def _evaluate_seed(task_id: str, cfg: EvaluateConfig, seed: int) -> dict[str, float]:
torch.manual_seed(seed)
env_cfg = load_env_cfg(task_id, play=False)
agent_cfg = load_rl_cfg(task_id)
env_cfg.seed = seed
env_cfg.scene.num_envs = cfg.num_envs
env_cfg.curriculum = {}
env_cfg.observations["actor"].enable_corruption = False
env_cfg.events.pop("push_robot", None)
twist_cfg = env_cfg.commands["twist"]
assert isinstance(twist_cfg, UniformVelocityCommandCfg)
twist_cfg.heading_command = False
twist_cfg.ranges.heading = None
twist_cfg.rel_heading_envs = 0.0
twist_cfg.rel_standing_envs = 0.0
twist_cfg.resampling_time_range = (1.0e9, 1.0e9)
if cfg.reward_config:
reward_path = Path(cfg.reward_config).expanduser().resolve(strict=True)
with reward_path.open(encoding="utf-8") as stream:
apply_reward_configuration(env_cfg, validate_configuration(json.load(stream)))
env = ManagerBasedRlEnv(cfg=env_cfg, device=cfg.device or "cuda:0")
wrapped = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions)
try:
runner_cls = load_runner_cls(task_id) or MjlabOnPolicyRunner
runner = runner_cls(wrapped, asdict(agent_cfg), log_dir=None, device=wrapped.device)
runner.load(
str(Path(cfg.checkpoint).expanduser().resolve(strict=True)),
load_cfg={"actor": True},
strict=True,
map_location=str(wrapped.device),
)
policy = runner.get_inference_policy(device=str(wrapped.device))
twist = wrapped.unwrapped.command_manager.get_term("twist")
_fixed_commands(twist.command)
obs = wrapped.get_observations()
sums = {name: 0.0 for name in METRIC_NAMES}
samples = 0
terminations = 0
completions = 0
with torch.inference_mode():
for _ in range(cfg.steps_per_seed):
_fixed_commands(twist.command)
obs = wrapped.get_observations()
actions = policy(obs)
obs, _rewards, _dones, _extras = wrapped.step(actions)
manager = wrapped.unwrapped.metrics_manager
for index, name in enumerate(manager.active_terms):
if name in sums:
sums[name] += float(torch.sum(manager._step_values[:, index]).item())
samples += wrapped.num_envs
terminated = wrapped.unwrapped.reset_terminated
timed_out = wrapped.unwrapped.reset_time_outs
terminations += int(torch.count_nonzero(terminated).item())
completions += int(torch.count_nonzero(terminated | timed_out).item())
result = {name: sums[name] / max(samples, 1) for name in METRIC_NAMES}
result["fall_rate"] = terminations / max(completions, wrapped.num_envs)
return result
finally:
wrapped.close()
def run_evaluation(task_id: str, cfg: EvaluateConfig) -> dict:
configure_torch_backends()
selected = cfg.gpu_ids
if selected is None:
os.environ["CUDA_VISIBLE_DEVICES"] = ""
device = "cpu"
else:
if selected == "all":
selected = [0]
os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(map(str, selected))
device = cfg.device or "cuda:0"
os.environ["MUJOCO_GL"] = "egl"
cfg = EvaluateConfig(**{**asdict(cfg), "device": device})
checkpoint = Path(cfg.checkpoint).expanduser().resolve(strict=True)
output = Path(cfg.output).expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
per_seed = [_evaluate_seed(task_id, cfg, seed) for seed in cfg.seeds]
metrics = {
key: fmean(seed_metrics[key] for seed_metrics in per_seed)
for key in (*METRIC_NAMES, "fall_rate")
}
deviations = {
key: pstdev(seed_metrics[key] for seed_metrics in per_seed)
for key in (*METRIC_NAMES, "fall_rate")
}
result = {
"protocolVersion": 1,
"taskId": task_id,
"checkpoint": checkpoint.name,
"checkpointSha256": _sha256(checkpoint),
"seeds": list(cfg.seeds),
"numEnvs": cfg.num_envs,
"stepsPerSeed": cfg.steps_per_seed,
"scenarios": [list(value) for value in SCENARIOS],
"metrics": metrics,
"metricStd": deviations,
"seedMetrics": [
{"seed": seed, "metrics": values}
for seed, values in zip(cfg.seeds, per_seed, strict=True)
],
}
output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
writer = SummaryWriter(log_dir=str(output.parent / "evaluation-events"))
try:
for name, value in metrics.items():
writer.add_scalar(f"Evaluation/{name}", value, 0)
finally:
writer.close()
print("MUJOCO_EVALUATION " + json.dumps({"output": str(output), "metrics": metrics}))
return result
def main() -> None:
import mjlab.tasks # noqa: F401
import src.tasks # noqa: F401
chosen_task, remaining = tyro.cli(
tyro.extras.literal_type_from_choices(list_tasks()),
add_help=False,
return_unknown_args=True,
config=mjlab.TYRO_FLAGS,
)
args = tyro.cli(
EvaluateConfig,
args=remaining,
prog=sys.argv[0] + f" {chosen_task}",
config=mjlab.TYRO_FLAGS,
)
run_evaluation(chosen_task, args)
if __name__ == "__main__":
main()
+60 -9
View File
@@ -1,5 +1,6 @@
"""Script to train RL agent with RSL-RL.""" """Script to train RL agent with RSL-RL."""
import json
import logging import logging
import os import os
import sys import sys
@@ -10,8 +11,10 @@ from typing import Literal, cast
# 训练器作为仓库内置子集直接从 scripts/ 启动,不要求额外执行 pip install -e。 # 训练器作为仓库内置子集直接从 scripts/ 启动,不要求额外执行 pip install -e。
TRAINER_ROOT = Path(__file__).resolve().parents[1] TRAINER_ROOT = Path(__file__).resolve().parents[1]
if str(TRAINER_ROOT) not in sys.path: SERVICE_ROOT = TRAINER_ROOT.parent
sys.path.insert(0, str(TRAINER_ROOT)) for source_root in (TRAINER_ROOT, SERVICE_ROOT):
if str(source_root) not in sys.path:
sys.path.insert(0, str(source_root))
import tyro import tyro
import warp as wp import warp as wp
@@ -32,6 +35,8 @@ from mjlab.utils.os import dump_yaml, get_checkpoint_path
from mjlab.utils.torch import configure_torch_backends from mjlab.utils.torch import configure_torch_backends
from mjlab.utils.wrappers import VideoRecorder from mjlab.utils.wrappers import VideoRecorder
from tuning.schema import apply_reward_configuration, validate_configuration
@dataclass(frozen=True) @dataclass(frozen=True)
class TrainConfig: class TrainConfig:
@@ -44,6 +49,10 @@ class TrainConfig:
enable_nan_guard: bool = False enable_nan_guard: bool = False
torchrunx_log_dir: str | None = None torchrunx_log_dir: str | None = None
gpu_ids: list[int] | Literal["all"] | None = field(default_factory=lambda: [0]) gpu_ids: list[int] | Literal["all"] | None = field(default_factory=lambda: [0])
output_dir: str | None = None
resume_checkpoint: str | None = None
reward_config: str | None = None
reward_config_json: str | None = None
@staticmethod @staticmethod
def from_task(task_id: str) -> "TrainConfig": def from_task(task_id: str) -> "TrainConfig":
@@ -52,7 +61,27 @@ class TrainConfig:
return TrainConfig(env=env_cfg, agent=agent_cfg) return TrainConfig(env=env_cfg, agent=agent_cfg)
def _load_reward_config(path: str | None, inline: str | None) -> dict | None:
if path is not None and inline is not None:
raise ValueError("Use only one of reward_config and reward_config_json")
if inline is not None:
if len(inline.encode("utf-8")) > 64 * 1024:
raise ValueError("Reward configuration is larger than 64 KiB")
return validate_configuration(json.loads(inline))
if path is None:
return None
source = Path(path).expanduser().resolve(strict=True)
if source.stat().st_size > 64 * 1024:
raise ValueError("Reward configuration is larger than 64 KiB")
with source.open(encoding="utf-8") as stream:
return validate_configuration(json.load(stream))
def run_train(task_id: str, cfg: TrainConfig, log_dir: Path) -> None: def run_train(task_id: str, cfg: TrainConfig, log_dir: Path) -> None:
reward_config = _load_reward_config(cfg.reward_config, cfg.reward_config_json)
if reward_config is not None:
apply_reward_configuration(cfg.env, reward_config)
cuda_visible = os.environ.get("CUDA_VISIBLE_DEVICES", "") cuda_visible = os.environ.get("CUDA_VISIBLE_DEVICES", "")
if cuda_visible == "": if cuda_visible == "":
device = "cpu" device = "cpu"
@@ -109,7 +138,10 @@ def run_train(task_id: str, cfg: TrainConfig, log_dir: Path) -> None:
log_root_path = log_dir.parent # Go up from specific run dir to experiment dir. log_root_path = log_dir.parent # Go up from specific run dir to experiment dir.
resume_path: Path | None = None resume_path: Path | None = None
if cfg.agent.resume: explicit_resume = cfg.resume_checkpoint is not None
if explicit_resume:
resume_path = Path(cfg.resume_checkpoint).expanduser().resolve(strict=True)
elif cfg.agent.resume:
# Load checkpoint from local filesystem. # Load checkpoint from local filesystem.
resume_path = get_checkpoint_path( resume_path = get_checkpoint_path(
log_root_path, cfg.agent.load_run, cfg.agent.load_checkpoint log_root_path, cfg.agent.load_run, cfg.agent.load_checkpoint
@@ -141,17 +173,33 @@ def run_train(task_id: str, cfg: TrainConfig, log_dir: Path) -> None:
runner.add_git_repo_to_log(__file__) runner.add_git_repo_to_log(__file__)
if resume_path is not None: if resume_path is not None:
print(f"[INFO]: Loading model checkpoint from: {resume_path}") print(f"[INFO]: Loading model checkpoint from: {resume_path}")
runner.load(str(resume_path)) runner.load(str(resume_path), map_location=device)
if explicit_resume:
# RSL-RL stores the last completed zero-based iteration and otherwise
# repeats it after load. Explicit tuning promotion uses an absolute target.
runner.current_learning_iteration += 1
# Only write config files from rank 0 to avoid race conditions. # Only write config files from rank 0 to avoid race conditions.
if rank == 0: if rank == 0:
dump_yaml(log_dir / "params" / "env.yaml", env_cfg) dump_yaml(log_dir / "params" / "env.yaml", env_cfg)
dump_yaml(log_dir / "params" / "agent.yaml", agent_cfg) dump_yaml(log_dir / "params" / "agent.yaml", agent_cfg)
if reward_config is not None:
runner.learn( reward_snapshot = log_dir / "params" / "reward_config.json"
num_learning_iterations=cfg.agent.max_iterations, init_at_random_ep_len=True reward_snapshot.parent.mkdir(parents=True, exist_ok=True)
reward_snapshot.write_text(
json.dumps(reward_config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
) )
iterations = cfg.agent.max_iterations
if explicit_resume:
iterations = max(0, cfg.agent.max_iterations - runner.current_learning_iteration)
print(
f"[INFO] Learning target: current={runner.current_learning_iteration}, "
f"additional={iterations}, target={cfg.agent.max_iterations}",
flush=True,
)
runner.learn(num_learning_iterations=iterations, init_at_random_ep_len=True)
env.close() env.close()
@@ -159,12 +207,15 @@ def launch_training(task_id: str, args: TrainConfig | None = None):
args = args or TrainConfig.from_task(task_id) args = args or TrainConfig.from_task(task_id)
# Create log directory once before launching workers. # Create log directory once before launching workers.
log_root_path = Path("logs") / "rsl_rl" / args.agent.experiment_name if args.output_dir:
log_root_path.resolve() log_dir = Path(args.output_dir).expanduser().resolve()
else:
log_root_path = (Path("logs") / "rsl_rl" / args.agent.experiment_name).resolve()
log_dir_name = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") log_dir_name = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
if args.agent.run_name: if args.agent.run_name:
log_dir_name += f"_{args.agent.run_name}" log_dir_name += f"_{args.agent.run_name}"
log_dir = log_root_path / log_dir_name log_dir = log_root_path / log_dir_name
log_dir.mkdir(parents=True, exist_ok=True)
# Select GPUs based on CUDA_VISIBLE_DEVICES and user specification. # Select GPUs based on CUDA_VISIBLE_DEVICES and user specification.
selected_gpus, num_gpus = select_gpus(args.gpu_ids) selected_gpus, num_gpus = select_gpus(args.gpu_ids)
@@ -106,6 +106,7 @@ def unitree_go2_rough_env_cfg(
cfg.rewards["body_ang_vel"].params["asset_cfg"].body_names = ("base_link",) cfg.rewards["body_ang_vel"].params["asset_cfg"].body_names = ("base_link",)
cfg.rewards["foot_clearance"].params["asset_cfg"].site_names = site_names cfg.rewards["foot_clearance"].params["asset_cfg"].site_names = site_names
cfg.rewards["foot_slip"].params["asset_cfg"].site_names = site_names cfg.rewards["foot_slip"].params["asset_cfg"].site_names = site_names
cfg.metrics["slip_velocity"].params["asset_cfg"].site_names = site_names
cfg.terminations["illegal_contact"] = TerminationTermCfg( cfg.terminations["illegal_contact"] = TerminationTermCfg(
func=mdp.illegal_contact, func=mdp.illegal_contact,
@@ -1,6 +1,7 @@
from mjlab.envs.mdp import * # noqa: F401, F403 from mjlab.envs.mdp import * # noqa: F401, F403
from .curriculums import * # noqa: F403 from .curriculums import * # noqa: F403
from .metrics import * # noqa: F403
from .observations import * # noqa: F403 from .observations import * # noqa: F403
from .rewards import * # noqa: F403 from .rewards import * # noqa: F403
from .terminations import * # noqa: F403 from .terminations import * # noqa: F403
@@ -0,0 +1,88 @@
"""Reward-weight-independent quality metrics for velocity tasks."""
from __future__ import annotations
from typing import TYPE_CHECKING
import torch
from mjlab.entity import Entity
from mjlab.managers.scene_entity_config import SceneEntityCfg
from mjlab.sensor import ContactSensor
if TYPE_CHECKING:
from mjlab.envs import ManagerBasedRlEnv
_DEFAULT_ASSET_CFG = SceneEntityCfg("robot")
def linear_velocity_rmse(
env: ManagerBasedRlEnv,
command_name: str,
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
) -> torch.Tensor:
"""Per-step commanded-vs-actual base linear velocity RMSE in body frame."""
asset: Entity = env.scene[asset_cfg.name]
command = env.command_manager.get_command(command_name)
assert command is not None
actual = asset.data.root_link_lin_vel_b
error = torch.cat((command[:, :2] - actual[:, :2], -actual[:, 2:3]), dim=1)
return torch.sqrt(torch.mean(torch.square(error), dim=1))
def angular_velocity_rmse(
env: ManagerBasedRlEnv,
command_name: str,
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
) -> torch.Tensor:
"""Per-step commanded-vs-actual base angular velocity RMSE in body frame."""
asset: Entity = env.scene[asset_cfg.name]
command = env.command_manager.get_command(command_name)
assert command is not None
actual = asset.data.root_link_ang_vel_b
desired = torch.zeros_like(actual)
desired[:, 2] = command[:, 2]
return torch.sqrt(torch.mean(torch.square(desired - actual), dim=1))
def orientation_error(
env: ManagerBasedRlEnv,
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
) -> torch.Tensor:
"""Magnitude of projected gravity in the base x/y plane; zero is upright."""
asset: Entity = env.scene[asset_cfg.name]
return torch.linalg.vector_norm(asset.data.projected_gravity_b[:, :2], dim=1)
def fall_indicator(env: ManagerBasedRlEnv) -> torch.Tensor:
"""One on non-timeout terminal steps, otherwise zero."""
return env.termination_manager.terminated.float()
def slip_velocity(
env: ManagerBasedRlEnv,
sensor_name: str,
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
) -> torch.Tensor:
"""Mean x/y velocity of feet currently touching the ground."""
asset: Entity = env.scene[asset_cfg.name]
sensor: ContactSensor = env.scene[sensor_name]
assert sensor.data.found is not None
contact = (sensor.data.found > 0).float()
speed = torch.linalg.vector_norm(
asset.data.site_lin_vel_w[:, asset_cfg.site_ids, :2], dim=-1
)
count = torch.clamp(torch.sum(contact, dim=1), min=1.0)
return torch.sum(speed * contact, dim=1) / count
def mechanical_power(
env: ManagerBasedRlEnv,
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
) -> torch.Tensor:
"""Positive actuator mechanical power in watts (regeneration is ignored)."""
asset: Entity = env.scene[asset_cfg.name]
torque = asset.data.actuator_force[:, asset_cfg.actuator_ids]
velocity = asset.data.joint_vel[:, asset_cfg.joint_ids]
count = min(torque.shape[1], velocity.shape[1])
return torch.sum(torch.clamp(torque[:, :count] * velocity[:, :count], min=0.0), dim=1)
@@ -140,8 +140,27 @@ def make_velocity_env_cfg() -> ManagerBasedRlEnvCfg:
## ##
metrics = { metrics = {
"mean_action_acc": MetricsTermCfg( "linear_velocity_rmse": MetricsTermCfg(
func=mdp.mean_action_acc, func=mdp.linear_velocity_rmse,
params={"command_name": "twist"},
),
"angular_velocity_rmse": MetricsTermCfg(
func=mdp.angular_velocity_rmse,
params={"command_name": "twist"},
),
"mean_action_acc": MetricsTermCfg(func=mdp.mean_action_acc),
"orientation_error": MetricsTermCfg(func=mdp.orientation_error),
"fall_indicator": MetricsTermCfg(func=mdp.fall_indicator),
"slip_velocity": MetricsTermCfg(
func=mdp.slip_velocity,
params={
"sensor_name": "feet_ground_contact",
"asset_cfg": SceneEntityCfg("robot", site_names=()), # Set per-robot.
},
),
"mechanical_power": MetricsTermCfg(
func=mdp.mechanical_power,
params={"asset_cfg": SceneEntityCfg("robot", joint_names=(".*",))},
), ),
} }
@@ -298,6 +317,11 @@ def make_velocity_env_cfg() -> ManagerBasedRlEnvCfg:
params={"sensor_name": "robot/root_angmom"}, params={"sensor_name": "robot/root_angmom"},
), ),
"is_terminated": RewardTermCfg(func=mdp.is_terminated, weight=-200.0), "is_terminated": RewardTermCfg(func=mdp.is_terminated, weight=-200.0),
"electrical_power": RewardTermCfg(
func=mdp.electrical_power_cost,
weight=0.0,
params={"asset_cfg": SceneEntityCfg("robot", joint_names=(".*",))},
),
"joint_acc_l2": RewardTermCfg(func=mdp.joint_acc_l2, weight=-2.5e-7), "joint_acc_l2": RewardTermCfg(func=mdp.joint_acc_l2, weight=-2.5e-7),
"joint_pos_limits": RewardTermCfg(func=mdp.joint_pos_limits, weight=-10.0), "joint_pos_limits": RewardTermCfg(func=mdp.joint_pos_limits, weight=-10.0),
"action_rate_l2": RewardTermCfg(func=mdp.action_rate_l2, weight=-0.05), "action_rate_l2": RewardTermCfg(func=mdp.action_rate_l2, weight=-0.05),
+198 -21
View File
@@ -23,9 +23,14 @@ from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import unquote, urlsplit from urllib.parse import parse_qs, unquote, urlsplit
VERSION = "0.3.0" from tuning.manager import TuningError, TuningManager
from tuning.process import GpuLease, ResourceBusyError
from tuning.schema import RewardConfigError
from tuning.scoring import EvaluationError
VERSION = "0.4.0"
# 浏览器当前 ONNX 运行时只实现 Go2 的 47→12 部署契约;其他任务须由服务启动参数显式放行。 # 浏览器当前 ONNX 运行时只实现 Go2 的 47→12 部署契约;其他任务须由服务启动参数显式放行。
DEFAULT_TASKS = ("Unitree-Go2-Flat",) DEFAULT_TASKS = ("Unitree-Go2-Flat",)
ACTIVE_STATES = {"queued", "running"} ACTIVE_STATES = {"queued", "running"}
@@ -65,6 +70,7 @@ class TrainingConfig:
device: str device: str
gpu_ids: list[int] gpu_ids: list[int]
wandb_mode: str wandb_mode: str
reward_config: dict[str, Any] | None = None
@dataclass @dataclass
@@ -110,6 +116,7 @@ class TrainingManager:
python: str, python: str,
tasks: tuple[str, ...], tasks: tuple[str, ...],
check_environment: bool = True, check_environment: bool = True,
lease: GpuLease | None = None,
): ):
self.trainer_root = trainer_root.expanduser().resolve() self.trainer_root = trainer_root.expanduser().resolve()
self.python = str(Path(python).expanduser()) if os.sep in python else python self.python = str(Path(python).expanduser()) if os.sep in python else python
@@ -118,6 +125,8 @@ class TrainingManager:
self.lock = threading.RLock() self.lock = threading.RLock()
self.check_environment = check_environment self.check_environment = check_environment
self._environment_error: str | None | bool = False self._environment_error: str | None | bool = False
self.lease = lease or GpuLease()
self.preset_resolver: Any = None
def readiness_error(self) -> str | None: def readiness_error(self) -> str | None:
if not self.trainer_root.is_dir(): if not self.trainer_root.is_dir():
@@ -204,6 +213,17 @@ class TrainingManager:
wandb_mode = payload.get("wandbMode", "offline") wandb_mode = payload.get("wandbMode", "offline")
if wandb_mode not in ("offline", "disabled", "online"): if wandb_mode not in ("offline", "disabled", "online"):
raise ApiError(HTTPStatus.BAD_REQUEST, "wandbMode 必须是 offline、disabled 或 online") raise ApiError(HTTPStatus.BAD_REQUEST, "wandbMode 必须是 offline、disabled 或 online")
preset_id = payload.get("rewardPresetId")
reward_config = None
if preset_id is not None:
if not isinstance(preset_id, str) or not re.fullmatch(r"[0-9a-f]{32}", preset_id):
raise ApiError(HTTPStatus.BAD_REQUEST, "rewardPresetId 格式无效")
if self.preset_resolver is None:
raise ApiError(HTTPStatus.BAD_REQUEST, "奖励 preset 服务未就绪")
try:
reward_config = self.preset_resolver(preset_id)
except KeyError as error:
raise ApiError(HTTPStatus.BAD_REQUEST, "奖励 preset 不存在") from error
return TrainingConfig( return TrainingConfig(
task_id=task_id, task_id=task_id,
num_envs=integer("numEnvs", 1, 16384), num_envs=integer("numEnvs", 1, 16384),
@@ -213,6 +233,7 @@ class TrainingManager:
device=device, device=device,
gpu_ids=raw_gpu_ids, gpu_ids=raw_gpu_ids,
wandb_mode=wandb_mode, wandb_mode=wandb_mode,
reward_config=reward_config,
) )
def start(self, payload: Any) -> dict[str, Any]: def start(self, payload: Any) -> dict[str, Any]:
@@ -232,10 +253,20 @@ class TrainingManager:
raise ApiError(HTTPStatus.CONFLICT, "训练任务历史已满,请稍后重试") raise ApiError(HTTPStatus.CONFLICT, "训练任务历史已满,请稍后重试")
del self.jobs[completed] del self.jobs[completed]
job = TrainingJob(id=uuid.uuid4().hex, config=config) job = TrainingJob(id=uuid.uuid4().hex, config=config)
owner = f"training:{job.id}"
try:
self.lease.acquire(owner)
except ResourceBusyError as error:
raise ApiError(HTTPStatus.CONFLICT, str(error)) from error
self.jobs[job.id] = job self.jobs[job.id] = job
try:
threading.Thread( threading.Thread(
target=self._run, args=(job,), name=f"training-{job.id[:8]}", daemon=True target=self._run, args=(job,), name=f"training-{job.id[:8]}", daemon=True
).start() ).start()
except Exception:
self.jobs.pop(job.id, None)
self.lease.release(owner)
raise
return job.public() return job.public()
def get(self, job_id: str) -> dict[str, Any]: def get(self, job_id: str) -> dict[str, Any]:
@@ -305,6 +336,13 @@ class TrainingManager:
f"--agent.seed={config.seed}", f"--agent.seed={config.seed}",
f"--agent.run-name={config.run_name}", f"--agent.run-name={config.run_name}",
] ]
if config.reward_config is not None:
command.extend(
(
"--reward-config-json",
json.dumps(config.reward_config, ensure_ascii=False, separators=(",", ":")),
)
)
if config.device == "cpu": if config.device == "cpu":
command.extend(("--gpu-ids", "None")) command.extend(("--gpu-ids", "None"))
else: else:
@@ -408,13 +446,16 @@ class TrainingManager:
job.state = "cancelled" if job.cancel_requested else "failed" job.state = "cancelled" if job.cancel_requested else "failed"
job.message = f"启动训练失败:{error}" job.message = f"启动训练失败:{error}"
job.logs.append(job.message) job.logs.append(job.message)
finally:
self.lease.release(f"training:{job.id}")
class TrainingRequestHandler(BaseHTTPRequestHandler): class TrainingRequestHandler(BaseHTTPRequestHandler):
manager: TrainingManager manager: TrainingManager
tuning_manager: TuningManager
allowed_origins: tuple[str, ...] = () allowed_origins: tuple[str, ...] = ()
access_token = "" access_token = ""
server_version = "MuJoCoLocalTraining/0.3" server_version = "MuJoCoLocalTraining/0.4"
def log_message(self, format: str, *args: Any) -> None: def log_message(self, format: str, *args: Any) -> None:
sys.stderr.write(f"[{self.log_date_time_string()}] {format % args}\n") sys.stderr.write(f"[{self.log_date_time_string()}] {format % args}\n")
@@ -455,6 +496,12 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
def _error(self, error: Exception) -> None: def _error(self, error: Exception) -> None:
if isinstance(error, ApiError): if isinstance(error, ApiError):
self._json(error.status, {"error": str(error)}) self._json(error.status, {"error": str(error)})
elif isinstance(error, KeyError):
self._json(HTTPStatus.NOT_FOUND, {"error": "调参 session、trial 或 proposal 不存在"})
elif isinstance(error, ResourceBusyError):
self._json(HTTPStatus.CONFLICT, {"error": str(error)})
elif isinstance(error, (TuningError, RewardConfigError, EvaluationError)):
self._json(HTTPStatus.BAD_REQUEST, {"error": str(error)})
else: else:
self._json( self._json(
HTTPStatus.INTERNAL_SERVER_ERROR, {"error": f"本地训练服务内部错误:{error}"} HTTPStatus.INTERNAL_SERVER_ERROR, {"error": f"本地训练服务内部错误:{error}"}
@@ -490,12 +537,24 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
match = re.fullmatch(r"/api/training/jobs/([0-9a-f]{32})(/artifacts/policy\.onnx)?", path) match = re.fullmatch(r"/api/training/jobs/([0-9a-f]{32})(/artifacts/policy\.onnx)?", path)
return (unquote(match.group(1)), bool(match.group(2))) if match else (None, False) return (unquote(match.group(1)), bool(match.group(2))) if match else (None, False)
def _send_file(self, file_path: Path, filename: str) -> None:
size = file_path.stat().st_size
self.send_response(HTTPStatus.OK)
self._cors()
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
self.send_header("Content-Length", str(size))
self.send_header("Cache-Control", "no-store")
self.end_headers()
with file_path.open("rb") as source:
shutil.copyfileobj(source, self.wfile)
def do_OPTIONS(self) -> None: def do_OPTIONS(self) -> None:
try: try:
self._ensure_origin() self._ensure_origin()
self.send_response(HTTPStatus.NO_CONTENT) self.send_response(HTTPStatus.NO_CONTENT)
self._cors() self._cors()
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type") self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
self.send_header("Access-Control-Max-Age", "600") self.send_header("Access-Control-Max-Age", "600")
self.end_headers() self.end_headers()
@@ -505,25 +564,61 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: def do_GET(self) -> None:
try: try:
self._ensure_request() self._ensure_request()
path = urlsplit(self.path).path parsed = urlsplit(self.path)
path = parsed.path
if path == "/api/training/health": if path == "/api/training/health":
self._json(HTTPStatus.OK, self.manager.health()) health = self.manager.health()
health["tuning"] = self.tuning_manager.capability()
health["resourceOwner"] = self.manager.lease.public()
self._json(HTTPStatus.OK, health)
return
if path == "/api/tuning/capabilities":
self._json(HTTPStatus.OK, self.tuning_manager.capability())
return
if path == "/api/tuning/sessions":
self._json(HTTPStatus.OK, {"sessions": self.tuning_manager.list()})
return
if path == "/api/tuning/presets":
self._json(HTTPStatus.OK, {"presets": self.tuning_manager.storage.list_presets()})
return
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})", path)
if match:
self._json(HTTPStatus.OK, self.tuning_manager.detail(match.group(1)))
return
match = re.fullmatch(
r"/api/tuning/sessions/([0-9a-f]{32})/trials/([0-9a-f]{32})/metrics", path
)
if match:
query = parse_qs(parsed.query)
tags = [tag for value in query.get("tags", []) for tag in value.split(",") if tag]
try:
max_points = int(query.get("maxPoints", ["1000"])[0])
after_raw = query.get("afterStep", [None])[0]
after_step = int(after_raw) if after_raw is not None else None
except ValueError as error:
raise TuningError("maxPoints/afterStep 必须是整数") from error
if not 10 <= max_points <= 5000:
raise TuningError("maxPoints 必须在 10–5000 之间")
if after_step is not None and after_step < -1:
raise TuningError("afterStep 不能小于 -1")
self._json(
HTTPStatus.OK,
self.tuning_manager.metrics(
match.group(1), match.group(2), tags or None, max_points, after_step
),
)
return
match = re.fullmatch(
r"/api/tuning/sessions/([0-9a-f]{32})/artifacts/best/policy\.onnx", path
)
if match:
self._send_file(self.tuning_manager.best_artifact(match.group(1)), "policy.onnx")
return return
job_id, artifact = self._route(path) job_id, artifact = self._route(path)
if not job_id: if not job_id:
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在") raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
if artifact: if artifact:
file_path = self.manager.artifact(job_id) self._send_file(self.manager.artifact(job_id), "policy.onnx")
size = file_path.stat().st_size
self.send_response(HTTPStatus.OK)
self._cors()
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Disposition", 'attachment; filename="policy.onnx"')
self.send_header("Content-Length", str(size))
self.send_header("Cache-Control", "no-store")
self.end_headers()
with file_path.open("rb") as source:
shutil.copyfileobj(source, self.wfile)
else: else:
self._json(HTTPStatus.OK, self.manager.get(job_id)) self._json(HTTPStatus.OK, self.manager.get(job_id))
except Exception as error: except Exception as error:
@@ -532,16 +627,83 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
def do_POST(self) -> None: def do_POST(self) -> None:
try: try:
self._ensure_request() self._ensure_request()
if urlsplit(self.path).path != "/api/training/jobs": path = urlsplit(self.path).path
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在") if path == "/api/training/jobs":
self._json(HTTPStatus.ACCEPTED, self.manager.start(self._payload())) self._json(HTTPStatus.ACCEPTED, self.manager.start(self._payload()))
return
if path == "/api/tuning/agent/test":
self._json(HTTPStatus.OK, self.tuning_manager.test_agent())
return
if path == "/api/tuning/sessions":
self._json(HTTPStatus.ACCEPTED, self.tuning_manager.create(self._payload()))
return
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})/(pause|resume)", path)
if match:
action = (
self.tuning_manager.pause
if match.group(2) == "pause"
else self.tuning_manager.resume
)
self._json(HTTPStatus.ACCEPTED, action(match.group(1)))
return
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})/(step|rollback)", path)
if match:
action = (
self.tuning_manager.step
if match.group(2) == "step"
else self.tuning_manager.rollback
)
self._json(HTTPStatus.ACCEPTED, action(match.group(1), self._payload()))
return
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})/mode", path)
if match:
self._json(
HTTPStatus.ACCEPTED,
self.tuning_manager.set_mode(match.group(1), self._payload()),
)
return
match = re.fullmatch(
r"/api/tuning/sessions/([0-9a-f]{32})/proposals/([0-9a-f]{32})/(approve|reject)",
path,
)
if match:
action = (
self.tuning_manager.approve
if match.group(3) == "approve"
else self.tuning_manager.reject
)
self._json(
HTTPStatus.ACCEPTED, action(match.group(1), match.group(2), self._payload())
)
return
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
except Exception as error:
self._error(error)
def do_PUT(self) -> None:
try:
self._ensure_request()
path = urlsplit(self.path).path
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})/constraints", path)
if match:
self._json(
HTTPStatus.ACCEPTED,
self.tuning_manager.set_constraints(match.group(1), self._payload()),
)
return
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
except Exception as error: except Exception as error:
self._error(error) self._error(error)
def do_DELETE(self) -> None: def do_DELETE(self) -> None:
try: try:
self._ensure_request() self._ensure_request()
job_id, artifact = self._route(urlsplit(self.path).path) path = urlsplit(self.path).path
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})", path)
if match:
self._json(HTTPStatus.ACCEPTED, self.tuning_manager.cancel(match.group(1)))
return
job_id, artifact = self._route(path)
if not job_id or artifact: if not job_id or artifact:
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在") raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
self._json(HTTPStatus.ACCEPTED, self.manager.cancel(job_id)) self._json(HTTPStatus.ACCEPTED, self.manager.cancel(job_id))
@@ -574,6 +736,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument( parser.add_argument(
"--trainer-python", default=sys.executable, help="已安装 mjlab/torch 的 Python 解释器" "--trainer-python", default=sys.executable, help="已安装 mjlab/torch 的 Python 解释器"
) )
parser.add_argument(
"--tuning-data-root",
type=Path,
default=None,
help="调参 SQLite 与 trial 产物目录;默认位于训练工程 logs/auto_tuning",
)
parser.add_argument( parser.add_argument(
"--task", action="append", dest="tasks", help="允许前端启动的任务 ID;可重复" "--task", action="append", dest="tasks", help="允许前端启动的任务 ID;可重复"
) )
@@ -593,10 +761,18 @@ def main() -> None:
token = args.token or secrets.token_urlsafe(24) token = args.token or secrets.token_urlsafe(24)
if len(token) < 16: if len(token) < 16:
raise SystemExit("训练服务访问令牌至少需要 16 个字符") raise SystemExit("训练服务访问令牌至少需要 16 个字符")
lease = GpuLease()
manager = TrainingManager( manager = TrainingManager(
args.trainer_root, args.trainer_python, tuple(args.tasks or DEFAULT_TASKS) args.trainer_root,
args.trainer_python,
tuple(args.tasks or DEFAULT_TASKS),
lease=lease,
) )
tuning_root = args.tuning_data_root or (Path(args.trainer_root) / "logs" / "auto_tuning")
tuning_manager = TuningManager(args.trainer_root, args.trainer_python, tuning_root, lease)
manager.preset_resolver = tuning_manager.preset_config
TrainingRequestHandler.manager = manager TrainingRequestHandler.manager = manager
TrainingRequestHandler.tuning_manager = tuning_manager
TrainingRequestHandler.allowed_origins = tuple(args.allow_origin) TrainingRequestHandler.allowed_origins = tuple(args.allow_origin)
TrainingRequestHandler.access_token = token TrainingRequestHandler.access_token = token
server = ThreadingHTTPServer((args.host, args.port), TrainingRequestHandler) server = ThreadingHTTPServer((args.host, args.port), TrainingRequestHandler)
@@ -612,6 +788,7 @@ def main() -> None:
except KeyboardInterrupt: except KeyboardInterrupt:
print("\n正在停止本地训练服务…") print("\n正在停止本地训练服务…")
finally: finally:
tuning_manager.shutdown()
manager.shutdown() manager.shutdown()
server.server_close() server.server_close()
+11 -7
View File
@@ -1,3 +1,4 @@
import json
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -64,13 +65,7 @@ out.write_bytes(b'onnx')
self.assertTrue((trainer_root / "scripts" / "train.py").is_file()) self.assertTrue((trainer_root / "scripts" / "train.py").is_file())
self.assertTrue( self.assertTrue(
( (
trainer_root trainer_root / "src" / "assets" / "robots" / "unitree_go2" / "xmls" / "go2.xml"
/ "src"
/ "assets"
/ "robots"
/ "unitree_go2"
/ "xmls"
/ "go2.xml"
).is_file() ).is_file()
) )
@@ -92,6 +87,15 @@ out.write_bytes(b'onnx')
) )
self.assertEqual(command[-2:], ["--gpu-ids", "[0,2]"]) self.assertEqual(command[-2:], ["--gpu-ids", "[0,2]"])
def test_resolves_reward_preset_to_inline_validated_trainer_argument(self):
preset_id = "f" * 32
reward_config = {"weights": {"pose": 1.2}, "params": {}}
self.manager.preset_resolver = lambda value: reward_config if value == preset_id else None
config = self.manager.parse_config(self.payload(rewardPresetId=preset_id))
command = self.manager.command_for(config)
index = command.index("--reward-config-json")
self.assertEqual(json.loads(command[index + 1]), reward_config)
def test_requires_local_host_origin_and_bearer_token(self): def test_requires_local_host_origin_and_bearer_token(self):
handler = object.__new__(TrainingRequestHandler) handler = object.__new__(TrainingRequestHandler)
handler.access_token = "secret-token-1234" handler.access_token = "secret-token-1234"
+239
View File
@@ -0,0 +1,239 @@
import math
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from tuning.advisor import AdvisorConfig, DeepSeekAdvisor # noqa: E402
from tuning.schema import ( # noqa: E402
BASE_REWARD_CONFIGURATION,
RewardConfigError,
merge_proposal,
validate_configuration,
validate_constraints,
validate_proposal,
)
from tuning.scoring import ( # noqa: E402
DEFAULT_OBJECTIVE_WEIGHTS,
EvaluationError,
score_evaluation,
)
from tuning.storage import StorageConflict, TuningStorage # noqa: E402
class RewardSchemaTest(unittest.TestCase):
def test_baseline_is_complete_and_energy_is_disabled(self):
config = validate_configuration(BASE_REWARD_CONFIGURATION)
self.assertEqual(len(config["weights"]), 16)
self.assertEqual(config["weights"]["electrical_power"], 0.0)
def test_sparse_proposal_constraints(self):
patch = validate_proposal(
{
"weights": {"track_linear_velocity": 1.2, "foot_slip": -0.3},
"params": {"foot_gait.period": 0.65},
},
BASE_REWARD_CONFIGURATION,
)
merged = merge_proposal(BASE_REWARD_CONFIGURATION, patch)
self.assertEqual(merged["weights"]["track_linear_velocity"], 1.2)
with self.assertRaises(RewardConfigError):
validate_proposal({"weights": {"track_linear_velocity": 0}}, BASE_REWARD_CONFIGURATION)
with self.assertRaises(RewardConfigError):
validate_proposal({"weights": {"foot_slip": 0.2}}, BASE_REWARD_CONFIGURATION)
with self.assertRaises(RewardConfigError):
validate_proposal(
{"weights": {"track_linear_velocity": 3.0}}, BASE_REWARD_CONFIGURATION
)
with self.assertRaises(RewardConfigError):
validate_proposal({"params": {"foot_gait.period": math.nan}}, BASE_REWARD_CONFIGURATION)
with self.assertRaises(RewardConfigError):
validate_proposal(
{
"weights": {
"pose": 1.1,
"foot_gait": 0.6,
"foot_slip": -0.3,
"soft_landing": -0.002,
},
"params": {"foot_gait.period": 0.65},
},
BASE_REWARD_CONFIGURATION,
)
def test_cross_parameter_order(self):
with self.assertRaises(RewardConfigError):
validate_proposal(
{"params": {"pose.walking_threshold": 0.5, "pose.running_threshold": 0.4}},
BASE_REWARD_CONFIGURATION,
)
def test_session_constraints_reject_unknown_out_of_range_and_fixed_changes(self):
constraints = validate_constraints(
{
"weights.track_linear_velocity": {"kind": "fixed", "value": 1.0},
"params.foot_gait.period": {"kind": "range", "min": 0.5, "max": 0.7},
}
)
validate_proposal(
{"params": {"foot_gait.period": 0.65}},
BASE_REWARD_CONFIGURATION,
constraints,
)
with self.assertRaisesRegex(RewardConfigError, "已固定"):
validate_proposal(
{"weights": {"track_linear_velocity": 1.1}},
BASE_REWARD_CONFIGURATION,
constraints,
)
with self.assertRaisesRegex(RewardConfigError, "工程锁定范围"):
validate_proposal(
{"params": {"foot_gait.period": 0.75}},
BASE_REWARD_CONFIGURATION,
constraints,
)
with self.assertRaisesRegex(RewardConfigError, "未知参数约束"):
validate_constraints({"weights.not_allowed": {"kind": "fixed", "value": 1.0}})
class ScoringTest(unittest.TestCase):
baseline = {
"linear_velocity_rmse": 0.3,
"angular_velocity_rmse": 0.2,
"mean_action_acc": 0.1,
"orientation_error": 0.2,
"fall_rate": 0.1,
"slip_velocity": 0.2,
"mechanical_power": 100.0,
}
def test_improvement_and_safety_gate(self):
better = {key: value * 0.8 for key, value in self.baseline.items()}
scored = score_evaluation(self.baseline, better, DEFAULT_OBJECTIVE_WEIGHTS)
self.assertTrue(scored["eligible"])
self.assertGreater(scored["score"], 0)
unsafe = dict(better, fall_rate=0.2)
scored = score_evaluation(self.baseline, unsafe)
self.assertFalse(scored["eligible"])
self.assertEqual(scored["score"], -1.0)
def test_rejects_missing_and_nonfinite_metrics(self):
with self.assertRaises(EvaluationError):
score_evaluation(self.baseline, {"fall_rate": 0.1})
bad = dict(self.baseline, mechanical_power=math.inf)
with self.assertRaises(EvaluationError):
score_evaluation(self.baseline, bad)
class AdvisorTest(unittest.TestCase):
class Output:
weights = {"pose": 1.1}
params = {}
rationale = "improve posture"
expected_impact = {"posture": "better"}
confidence = 0.7
class Result:
output = None
@staticmethod
def usage():
return type("Usage", (), {"requests": 1, "input_tokens": 10, "output_tokens": 5})()
class Agent:
def __init__(self, output):
self.output = output
def run_sync(self, _prompt):
result = AdvisorTest.Result()
result.output = self.output
return result
def test_structured_result_is_revalidated_locally(self):
advisor = DeepSeekAdvisor(AdvisorConfig("fake"))
advisor._cached_agent = self.Agent(self.Output())
proposal = advisor.propose({"trials": []}, BASE_REWARD_CONFIGURATION)
self.assertEqual(proposal["patch"]["weights"]["pose"], 1.1)
self.assertEqual(proposal["usage"]["input_tokens"], 10)
def test_invalid_model_patch_is_rejected(self):
output = self.Output()
output.weights = {"track_linear_velocity": -1.0}
advisor = DeepSeekAdvisor(AdvisorConfig("fake"))
advisor._cached_agent = self.Agent(output)
with self.assertRaises(RewardConfigError):
advisor.propose({"trials": []}, BASE_REWARD_CONFIGURATION)
class StorageTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.storage = TuningStorage(Path(self.temporary.name) / "state.sqlite3")
def tearDown(self):
self.temporary.cleanup()
def test_persists_session_trial_proposal_and_metrics(self):
session = self.storage.create_session(
"approval", {"taskId": "Unitree-Go2-Flat"}, DEFAULT_OBJECTIVE_WEIGHTS, False
)
trial = self.storage.create_trial(
session["id"], 0, 0, 10, BASE_REWARD_CONFIGURATION, None, "trial-000-rung-0"
)
proposal = self.storage.create_proposal(
session["id"],
trial["id"],
{"weights": {"pose": 1.1}, "params": {}},
"test",
{},
0.8,
)
self.assertTrue(self.storage.decide_proposal(proposal["id"], "approved", None))
self.assertFalse(self.storage.decide_proposal(proposal["id"], "approved", None))
points = [
("Train/reward", step, float(step), 50.0 if step == 50 else float(step % 7))
for step in range(100)
]
self.storage.insert_metrics(trial["id"], points)
sampled = self.storage.metrics(trial["id"], max_points=10)[0]["points"]
self.assertEqual(len(sampled), 10)
self.assertEqual(sampled[0]["step"], 0)
self.assertEqual(sampled[-1]["step"], 99)
self.assertIn(50.0, [point["value"] for point in sampled])
control = self.storage.replace_constraints(
session["id"],
0,
{"weights.pose": {"kind": "range", "min": 0.5, "max": 1.5}},
)
self.assertEqual(control["constraintsRevision"], 1)
with self.assertRaises(StorageConflict):
self.storage.replace_constraints(session["id"], 0, {})
self.storage.grant_dispatch_token(session["id"])
with self.assertRaises(StorageConflict):
self.storage.grant_dispatch_token(session["id"])
self.assertTrue(self.storage.use_dispatch_token(session["id"]))
self.assertFalse(self.storage.use_dispatch_token(session["id"]))
incremental = self.storage.metrics(trial["id"], max_points=100, after_step=90)[0]
self.assertEqual(incremental["points"][0]["step"], 91)
reopened = TuningStorage(self.storage.path)
self.assertEqual(reopened.get_session(session["id"])["mode"], "approval")
self.assertEqual(reopened.get_control(session["id"])["constraintsRevision"], 1)
def test_recovery_marks_inflight_records(self):
session = self.storage.create_session(
"automatic", {"taskId": "Unitree-Go2-Flat"}, DEFAULT_OBJECTIVE_WEIGHTS, True
)
trial = self.storage.create_trial(
session["id"], 0, 0, 10, BASE_REWARD_CONFIGURATION, None, "trial-000-rung-0"
)
self.storage.update_session(session["id"], state="running")
self.storage.update_trial(trial["id"], state="training")
self.storage.recover_interrupted()
self.assertEqual(self.storage.get_session(session["id"])["state"], "interrupted")
self.assertEqual(self.storage.get_trial(trial["id"])["state"], "interrupted")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,350 @@
import sys
import tempfile
import time
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from tuning.manager import TuningManager # noqa: E402
from tuning.process import GpuLease # noqa: E402
from tuning.schema import BASE_REWARD_CONFIGURATION # noqa: E402
from tuning.scoring import score_evaluation # noqa: E402
from tuning.storage import now_iso # noqa: E402
BASE_METRICS = {
"linear_velocity_rmse": 0.3,
"angular_velocity_rmse": 0.2,
"mean_action_acc": 0.1,
"orientation_error": 0.2,
"fall_rate": 0.1,
"slip_velocity": 0.2,
"mechanical_power": 100.0,
}
class FakeAdvisor:
def capability(self):
return {
"configured": True,
"apiKeyConfigured": True,
"frameworkInstalled": True,
"model": "fake",
"baseUrl": "https://example.invalid",
}
def propose(self, _context, previous):
value = min(2.4, previous["weights"]["pose"] * 1.05)
return {
"patch": {"weights": {"pose": value}, "params": {}},
"rationale": "fake",
"expectedImpact": {},
"confidence": 0.8,
"promptHash": "abc",
"usage": {},
"model": "fake",
}
def test_connection(self):
return {"ok": True, "model": "fake", "outputType": "fake"}
class FakeTuningManager(TuningManager):
def _execute_trial(self, session, trial, resume_checkpoint=None):
del resume_checkpoint
factor = max(0.5, 1.0 - 0.03 * trial["number"] - 0.01 * trial["rung"])
metrics = {key: value * factor for key, value in BASE_METRICS.items()}
trials = self.storage.list_trials(session["id"])
baseline = next(
(item for item in trials if item["number"] == 0 and item["rung"] == 0), None
)
if baseline and baseline["evaluation"]:
scored = score_evaluation(
baseline["evaluation"]["metrics"], metrics, session["objectiveWeights"]
)
else:
scored = {"score": 0.0, "eligible": True, "components": {}}
root = self._session_root(session["id"])
run = root / trial["runDir"]
run.mkdir(parents=True, exist_ok=True)
(run / "model_1.pt").write_bytes(b"checkpoint")
(run / "policy.onnx").write_bytes(b"onnx")
evaluation = {"metrics": metrics, "score": scored}
self.storage.update_trial(
trial["id"],
state="completed",
started_at=now_iso(),
ended_at=now_iso(),
message="fake complete",
checkpoint_path=str((run / "model_1.pt").relative_to(root)),
policy_path=str((run / "policy.onnx").relative_to(root)),
evaluation=evaluation,
score=scored["score"],
eligible=scored["eligible"],
)
return self.storage.get_trial(trial["id"])
class TuningManagerTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
(self.root / "trainer" / "scripts").mkdir(parents=True)
(self.root / "trainer" / "scripts" / "evaluate.py").write_text("", encoding="utf-8")
self.manager = FakeTuningManager(
self.root / "trainer",
sys.executable,
self.root / "data",
GpuLease(),
advisor=FakeAdvisor(),
)
def tearDown(self):
self.manager.shutdown()
self.temporary.cleanup()
@staticmethod
def payload(mode="automatic"):
return {
"taskId": "Unitree-Go2-Flat",
"mode": mode,
"runName": "test",
"numEnvs": 16,
"gpuIds": [0],
"trialCount": 4,
"initialIterations": 1,
"middleIterations": 2,
"finalIterations": 3,
"evalNumEnvs": 8,
"evalSteps": 10,
}
def wait_terminal(self, session_id, timeout=5):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
session = self.manager.detail(session_id)
if session["state"] in {"succeeded", "failed", "cancelled"}:
return session
time.sleep(0.01)
self.fail("session did not finish")
def test_automatic_session_runs_rungs_and_persists_best_artifact(self):
session = self.manager.create(self.payload())
completed = self.wait_terminal(session["id"])
self.assertEqual(completed["state"], "succeeded", completed["message"])
self.assertGreaterEqual(len(completed["trials"]), 7)
self.assertTrue(self.manager.best_artifact(session["id"]).is_file())
self.assertEqual(len(self.manager.storage.list_presets()), 1)
def test_approval_session_waits_and_accepts_modified_patch(self):
session = self.manager.create(self.payload("approval"))
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
if detail["state"] == "awaiting_approval":
break
time.sleep(0.01)
else:
self.fail("session did not wait for approval")
proposal = detail["proposals"][-1]
patch = {"weights": {"pose": 1.1}, "params": {}}
with self.assertRaisesRegex(Exception, "feedback"):
self.manager.approve(session["id"], proposal["id"], {"feedback": {}})
approved = self.manager.approve(
session["id"], proposal["id"], {"feedback": "ok", "patch": patch}
)
self.assertEqual(approved["proposals"][-1]["state"], "approved")
self.manager.cancel(session["id"])
self.assertEqual(self.wait_terminal(session["id"])["state"], "cancelled")
def test_runtime_mode_switch_auto_approves_pending_proposal(self):
session = self.manager.create(self.payload("approval"))
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
if detail["state"] == "awaiting_approval":
break
time.sleep(0.01)
else:
self.fail("session did not wait for approval")
changed = self.manager.set_mode(session["id"], {"mode": "automatic"})
self.assertEqual(changed["mode"], "automatic")
self.assertEqual(changed["proposals"][-1]["state"], "approved")
completed = self.wait_terminal(session["id"])
self.assertEqual(completed["state"], "succeeded", completed["message"])
def test_trial_count_is_user_configurable(self):
payload = self.payload()
payload["trialCount"] = 1
_, config, _, _ = self.manager.parse_create(payload)
self.assertEqual(config["trialCount"], 1)
payload["trialCount"] = 100
_, config, _, _ = self.manager.parse_create(payload)
self.assertEqual(config["trialCount"], 100)
payload["trialCount"] = 101
with self.assertRaisesRegex(Exception, "trialCount"):
self.manager.parse_create(payload)
def test_resume_discards_only_interrupted_trial_and_continues(self):
mode, config, objective, fallback = self.manager.parse_create(self.payload())
session = self.manager.storage.create_session(mode, config, objective, fallback)
baseline = self.manager.storage.create_trial(
session["id"],
0,
0,
1,
BASE_REWARD_CONFIGURATION,
None,
"trial-000-rung-0",
)
self.manager._execute_trial(self.manager.storage.get_session(session["id"]), baseline)
interrupted = self.manager.storage.create_trial(
session["id"],
1,
0,
1,
baseline["rewardConfig"],
None,
"trial-001-rung-0",
)
self.manager.storage.update_trial(interrupted["id"], state="interrupted")
self.manager.storage.reset_step_gate(session["id"])
self.manager.storage.update_session(session["id"], state="interrupted")
self.manager.resume(session["id"])
self.assertEqual(self.manager.storage.get_control(session["id"])["runPolicy"], "continuous")
completed = self.wait_terminal(session["id"])
self.assertEqual(completed["state"], "succeeded", completed["message"])
self.assertNotIn(interrupted["id"], [trial["id"] for trial in completed["trials"]])
def test_create_validation_and_agent_capability(self):
self.assertTrue(self.manager.capability()["configured"])
with self.assertRaisesRegex(Exception, "只支持"):
self.manager.parse_create({"taskId": "Other"})
self.assertEqual(self.manager.test_agent()["model"], "fake")
def test_pause_closes_persistent_dispatch_gate_at_trial_boundary(self):
mode, config, objective, fallback = self.manager.parse_create(self.payload())
session = self.manager.storage.create_session(mode, config, objective, fallback)
self.manager.storage.update_session(session["id"], state="running")
self.manager.storage.grant_dispatch_token(session["id"])
paused = self.manager.pause(session["id"])
self.assertEqual(paused["state"], "paused")
self.assertEqual(paused["control"]["runPolicy"], "step")
self.assertEqual(paused["control"]["dispatchTokens"], 0)
def test_step_token_executes_exactly_one_trial_then_pauses(self):
session = self.manager.create(self.payload("approval"))
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
if detail["state"] == "awaiting_approval":
break
time.sleep(0.01)
else:
self.fail("session did not wait for approval")
baseline_count = len([trial for trial in detail["trials"] if trial["state"] == "completed"])
stepped = self.manager.step(session["id"], {"count": 1})
self.assertEqual(stepped["control"]["runPolicy"], "step")
self.assertEqual(stepped["control"]["dispatchTokens"], 1)
proposal = stepped["proposals"][-1]
self.manager.approve(session["id"], proposal["id"], {})
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
completed_count = len(
[trial for trial in detail["trials"] if trial["state"] == "completed"]
)
if detail["state"] == "paused" and completed_count == baseline_count + 1:
break
time.sleep(0.01)
else:
self.fail("single-step trial did not pause at the next boundary")
time.sleep(0.05)
self.assertEqual(
len(
[
trial
for trial in self.manager.detail(session["id"])["trials"]
if trial["state"] == "completed"
]
),
baseline_count + 1,
)
self.manager.cancel(session["id"])
self.assertEqual(self.wait_terminal(session["id"])["state"], "cancelled")
def test_constraints_are_revisioned_and_enforced_during_approval(self):
session = self.manager.create(self.payload("approval"))
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
if detail["state"] == "awaiting_approval":
break
time.sleep(0.01)
else:
self.fail("session did not wait for approval")
proposal = detail["proposals"][-1]
constrained = self.manager.set_constraints(
session["id"],
{
"revision": 0,
"constraints": {"weights.track_linear_velocity": {"kind": "fixed", "value": 1.0}},
},
)
self.assertEqual(constrained["control"]["constraintsRevision"], 1)
with self.assertRaisesRegex(Exception, "已固定"):
self.manager.approve(
session["id"],
proposal["id"],
{"patch": {"weights": {"track_linear_velocity": 1.1}, "params": {}}},
)
with self.assertRaisesRegex(Exception, "revision"):
self.manager.set_constraints(session["id"], {"revision": 0, "constraints": {}})
self.manager.approve(session["id"], proposal["id"], {})
self.manager.cancel(session["id"])
self.assertEqual(self.wait_terminal(session["id"])["state"], "cancelled")
def test_rollback_uses_safe_completed_trial_as_next_proposal_base(self):
session = self.manager.create(self.payload("approval"))
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
if detail["state"] == "awaiting_approval":
break
time.sleep(0.01)
else:
self.fail("session did not wait for approval")
baseline = detail["trials"][0]
old_proposal = detail["proposals"][-1]
rolled_back = self.manager.rollback(
session["id"], {"trialId": baseline["id"], "checkpoint": True}
)
self.assertEqual(rolled_back["state"], "paused")
self.assertEqual(rolled_back["control"]["activeBaseTrialId"], baseline["id"])
self.assertEqual(
next(item for item in rolled_back["proposals"] if item["id"] == old_proposal["id"])[
"state"
],
"rejected",
)
self.manager.step(session["id"], {"count": 1})
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
detail = self.manager.detail(session["id"])
if (
detail["state"] == "awaiting_approval"
and detail["proposals"][-1]["id"] != old_proposal["id"]
):
break
time.sleep(0.01)
else:
self.fail("rollback base did not produce a replacement proposal")
self.assertEqual(detail["proposals"][-1]["baseTrialId"], baseline["id"])
self.manager.cancel(session["id"])
self.assertEqual(self.wait_terminal(session["id"])["state"], "cancelled")
if __name__ == "__main__":
unittest.main()
+26
View File
@@ -0,0 +1,26 @@
"""Reward auto-tuning support for the local training service."""
from .schema import (
BASE_REWARD_CONFIGURATION,
PARAMETER_SPECS,
WEIGHT_SPECS,
RewardConfigError,
apply_reward_configuration,
merge_proposal,
validate_configuration,
validate_proposal,
)
from .scoring import DEFAULT_OBJECTIVE_WEIGHTS, score_evaluation
__all__ = [
"BASE_REWARD_CONFIGURATION",
"DEFAULT_OBJECTIVE_WEIGHTS",
"PARAMETER_SPECS",
"WEIGHT_SPECS",
"RewardConfigError",
"apply_reward_configuration",
"merge_proposal",
"score_evaluation",
"validate_configuration",
"validate_proposal",
]
+134
View File
@@ -0,0 +1,134 @@
"""PydanticAI adapter for the DeepSeek reward-tuning advisor."""
from __future__ import annotations
import hashlib
import json
import os
from dataclasses import dataclass
from typing import Any
from .schema import validate_proposal
SYSTEM_PROMPT = """你是 Unitree Go2 强化学习奖励调参专家。
只根据提供的数值配置、训练曲线摘要和固定评估结果提出下一轮稀疏修改。
必须优先保持速度跟踪与跌倒安全门槛;每轮最多修改四个白名单标量,不得改变符号、函数、传感器或结构。
不要建议 Python 代码、命令、文件路径或白名单外参数。输出必须符合 RewardProposal schema。
"""
class AdvisorUnavailable(RuntimeError):
pass
@dataclass(frozen=True)
class AdvisorConfig:
api_key: str | None
base_url: str = "https://api.deepseek.com"
model: str = "deepseek-v4-flash"
@classmethod
def from_environment(cls) -> AdvisorConfig:
return cls(
api_key=os.environ.get("DEEPSEEK_API_KEY"),
base_url=os.environ.get("MUJOCO_TUNING_AGENT_BASE_URL", "https://api.deepseek.com"),
model=os.environ.get("MUJOCO_TUNING_AGENT_MODEL", "deepseek-v4-flash"),
)
class DeepSeekAdvisor:
def __init__(self, config: AdvisorConfig | None = None):
self.config = config or AdvisorConfig.from_environment()
self._cached_agent = None
def capability(self) -> dict[str, Any]:
try:
import pydantic_ai # noqa: F401
except ImportError:
installed = False
else:
installed = True
return {
"configured": bool(self.config.api_key) and installed,
"apiKeyConfigured": bool(self.config.api_key),
"frameworkInstalled": installed,
"model": self.config.model,
"baseUrl": self.config.base_url,
}
def _agent(self):
if self._cached_agent is not None:
return self._cached_agent
if not self.config.api_key:
raise AdvisorUnavailable("未配置 DEEPSEEK_API_KEY")
try:
import httpx2
from pydantic import BaseModel, Field
from pydantic_ai import Agent, PromptedOutput
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider
except ImportError as error:
raise AdvisorUnavailable(
"缺少 PydanticAI,请安装 training_server/requirements.txt"
) from error
class RewardProposalOutput(BaseModel):
weights: dict[str, float] = Field(default_factory=dict)
params: dict[str, float] = Field(default_factory=dict)
rationale: str = Field(min_length=1, max_length=2000)
expected_impact: dict[str, str] = Field(default_factory=dict)
confidence: float = Field(ge=0.0, le=1.0)
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("ALL_PROXY")
if proxy and proxy.startswith("socks://"):
proxy = "socks5://" + proxy.removeprefix("socks://")
http_client = httpx2.AsyncClient(proxy=proxy, trust_env=False, timeout=60.0)
provider = OpenAIProvider(
base_url=self.config.base_url, api_key=self.config.api_key, http_client=http_client
)
model = OpenAIChatModel(self.config.model, provider=provider) # type: ignore[arg-type]
self._cached_agent = Agent(
model,
output_type=PromptedOutput(RewardProposalOutput),
system_prompt=SYSTEM_PROMPT,
retries=2,
model_settings={"temperature": 0.2},
)
return self._cached_agent
def propose(self, context: dict[str, Any], previous: dict) -> dict[str, Any]:
prompt = json.dumps(context, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
result = self._agent().run_sync(prompt)
output = result.output
patch = validate_proposal(
{"weights": dict(output.weights), "params": dict(output.params)}, previous
)
try:
usage = result.usage()
usage_value = {
key: getattr(usage, key)
for key in ("requests", "input_tokens", "output_tokens", "total_tokens")
if getattr(usage, key, None) is not None
}
except (AttributeError, TypeError):
usage_value = {}
return {
"patch": patch,
"rationale": output.rationale,
"expectedImpact": dict(output.expected_impact),
"confidence": float(output.confidence),
"promptHash": hashlib.sha256(prompt.encode()).hexdigest(),
"usage": usage_value,
"model": self.config.model,
}
def test_connection(self) -> dict[str, Any]:
base = {
"weights": {"track_linear_velocity": 1.0},
"params": {},
"instruction": "仅返回一个合法示例:把 track_linear_velocity 改为 1.1。",
}
# A minimal full previous config is supplied by callers for actual proposals;
# connectivity probing only verifies the provider and structured response path.
result = self._agent().run_sync(json.dumps(base, ensure_ascii=False))
return {"ok": True, "model": self.config.model, "outputType": type(result.output).__name__}
+999
View File
@@ -0,0 +1,999 @@
"""Persistent reward tuning session orchestrator."""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import threading
from contextlib import suppress
from copy import deepcopy
from pathlib import Path
from typing import Any
from .advisor import AdvisorUnavailable, DeepSeekAdvisor
from .process import GpuLease, ResourceBusyError, terminate_process
from .schema import (
BASE_REWARD_CONFIGURATION,
merge_proposal,
validate_configuration_constraints,
validate_constraints,
validate_proposal,
)
from .scoring import DEFAULT_OBJECTIVE_WEIGHTS, score_evaluation, validate_objective_weights
from .storage import StorageConflict, TuningStorage, now_iso
from .study import OptunaStudies
from .tensorboard import ingest_scalars
ACTIVE_SESSION_STATES = {
"queued",
"running",
"evaluating",
"awaiting_approval",
"paused",
"interrupted",
}
RUNNING_STATES = {"queued", "running", "evaluating"}
RUN_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
class TuningError(RuntimeError):
pass
class TuningManager:
def __init__(
self,
trainer_root: Path,
python: str,
data_root: Path,
lease: GpuLease,
advisor: Any | None = None,
):
self.trainer_root = trainer_root.expanduser().resolve()
self.python = python
self.data_root = data_root.expanduser().resolve()
self.data_root.mkdir(parents=True, exist_ok=True)
self.storage = TuningStorage(self.data_root / "tuning.sqlite3")
self.storage.recover_interrupted()
self.studies = OptunaStudies(self.data_root)
self.lease = lease
self.advisor = advisor or DeepSeekAdvisor()
self.lock = threading.RLock()
self.condition = threading.Condition(self.lock)
self.workers: dict[str, threading.Thread] = {}
self.processes: dict[str, subprocess.Popen[str]] = {}
self.cancel_events: dict[str, threading.Event] = {}
def capability(self) -> dict[str, Any]:
capability = self.advisor.capability()
capability.update({"ready": (self.trainer_root / "scripts" / "evaluate.py").is_file()})
return capability
@staticmethod
def _integer(payload: dict, name: str, default: int, minimum: int, maximum: int) -> int:
value = payload.get(name, default)
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
raise TuningError(f"{name} 必须在 {minimum}–{maximum} 之间")
return value
def parse_create(self, payload: Any) -> tuple[str, dict, dict, bool]:
if not isinstance(payload, dict):
raise TuningError("请求体必须是 JSON 对象")
mode = payload.get("mode", "automatic")
if mode not in ("automatic", "approval"):
raise TuningError("mode 必须是 automatic 或 approval")
if payload.get("taskId", "Unitree-Go2-Flat") != "Unitree-Go2-Flat":
raise TuningError("第一版只支持 Unitree-Go2-Flat")
run_name = payload.get("runName", "auto-tune")
if not isinstance(run_name, str) or not RUN_NAME.fullmatch(run_name):
raise TuningError("runName 格式无效")
gpu_ids = payload.get("gpuIds", [0])
if (
not isinstance(gpu_ids, list)
or not gpu_ids
or any(
isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 255
for value in gpu_ids
)
):
raise TuningError("gpuIds 必须是非空非负整数数组")
trial_count = self._integer(payload, "trialCount", 12, 1, 100)
rung0 = self._integer(payload, "initialIterations", 300, 1, 1000000)
rung1 = self._integer(payload, "middleIterations", 900, rung0, 1000000)
rung2 = self._integer(payload, "finalIterations", 2000, rung1, 1000000)
config = {
"taskId": "Unitree-Go2-Flat",
"numEnvs": self._integer(payload, "numEnvs", 4096, 1, 16384),
"seed": self._integer(payload, "seed", 42, 0, 2147483647),
"runName": run_name,
"gpuIds": gpu_ids,
"trialCount": trial_count,
"rungs": [rung0, rung1, rung2],
"promote": [trial_count, min(4, trial_count), min(2, trial_count)],
"evalNumEnvs": self._integer(payload, "evalNumEnvs", 256, 1, 4096),
"evalSteps": self._integer(payload, "evalSteps", 1000, 10, 100000),
"earlyStopPatience": self._integer(payload, "earlyStopPatience", 4, 1, 20),
}
objective = validate_objective_weights(
payload.get("objectiveWeights", DEFAULT_OBJECTIVE_WEIGHTS)
)
fallback = payload.get("fallbackEnabled", False)
if not isinstance(fallback, bool):
raise TuningError("fallbackEnabled 必须是布尔值")
return mode, config, objective, fallback
def create(self, payload: Any) -> dict:
mode, config, objective, fallback = self.parse_create(payload)
capability = self.capability()
if not capability["ready"]:
raise TuningError("评估入口未就绪")
if not capability["configured"] and not fallback:
raise TuningError("DeepSeek Agent 未配置;设置 DEEPSEEK_API_KEY 或显式启用 fallback")
with self.lock:
active = [
session
for session in self.storage.list_sessions()
if session["state"] in ACTIVE_SESSION_STATES
]
if active:
raise ResourceBusyError("已有调参 session 未结束")
session = self.storage.create_session(mode, config, objective, fallback)
self._start_worker(session["id"], resume=False)
return self.detail(session["id"])
def _start_worker(self, session_id: str, resume: bool) -> None:
cancel = threading.Event()
self.cancel_events[session_id] = cancel
worker = threading.Thread(
target=self._run_session,
args=(session_id, resume, cancel),
name=f"tuning-{session_id[:8]}",
daemon=True,
)
self.workers[session_id] = worker
worker.start()
def detail(self, session_id: str) -> dict:
session = self.storage.get_session(session_id)
session["trials"] = self.storage.list_trials(session_id)
session["proposals"] = self.storage.list_proposals(session_id)
session["audit"] = self.storage.audit_events(session_id)
control = self.storage.get_control(session_id)
current = next(
(trial for trial in session["trials"] if trial["id"] == session["currentTrialId"]),
None,
)
control["effectiveAfterCurrent"] = bool(
current and current["state"] in {"training", "evaluating"}
)
session["control"] = control
return session
def list(self) -> list[dict]:
return self.storage.list_sessions()
def _session_root(self, session_id: str) -> Path:
root = (self.data_root / "sessions" / session_id).resolve()
if not root.is_relative_to(self.data_root):
raise TuningError("非法 session 路径")
root.mkdir(parents=True, exist_ok=True)
return root
def _run_command(
self,
session_id: str,
command: list[str],
cwd: Path,
environment: dict[str, str],
log_path: Path,
) -> int:
owner = f"tuning:{session_id}"
self.lease.acquire(owner)
try:
process = subprocess.Popen(
command,
cwd=cwd,
env=environment,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
start_new_session=True,
)
with self.lock:
self.processes[session_id] = process
assert process.stdout is not None
with log_path.open("a", encoding="utf-8") as log:
for line in process.stdout:
log.write(line)
log.flush()
if self.cancel_events[session_id].is_set():
terminate_process(process)
break
return process.wait()
finally:
with self.lock:
self.processes.pop(session_id, None)
self.lease.release(owner)
@staticmethod
def _latest_checkpoint(run_dir: Path) -> Path | None:
values = []
for path in run_dir.glob("model_*.pt"):
match = re.fullmatch(r"model_(\d+)\.pt", path.name)
if match:
values.append((int(match.group(1)), path))
return max(values, default=(0, None), key=lambda value: value[0])[1]
def _execute_trial(
self, session: dict, trial: dict, resume_checkpoint: Path | None = None
) -> dict:
session_id, trial_id = session["id"], trial["id"]
root = self._session_root(session_id)
run_dir = (root / trial["runDir"]).resolve()
if not run_dir.is_relative_to(root):
raise TuningError("trial 目录越界")
run_dir.mkdir(parents=True, exist_ok=True)
reward_path = run_dir / "reward_config.json"
reward_path.write_text(
json.dumps(trial["rewardConfig"], ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
config = session["config"]
command = [
self.python,
"-u",
"scripts/train.py",
config["taskId"],
f"--env.scene.num-envs={config['numEnvs']}",
f"--agent.max-iterations={trial['targetIterations']}",
f"--agent.seed={config['seed']}",
f"--agent.run-name={config['runName']}-t{trial['number']}-r{trial['rung']}",
"--agent.logger=tensorboard",
"--agent.upload-model=False",
"--gpu-ids",
json.dumps(config["gpuIds"], separators=(",", ":")),
"--output-dir",
str(run_dir),
"--reward-config",
str(reward_path),
]
if resume_checkpoint is not None:
command.extend(("--resume-checkpoint", str(resume_checkpoint)))
environment = os.environ.copy()
environment["WANDB_MODE"] = "disabled"
environment["WANDB_SILENT"] = "true"
self.storage.update_trial(
trial_id, state="training", started_at=now_iso(), message="正在训练"
)
self.storage.update_session(
session_id,
state="running",
message=f"正在训练 trial {trial['number']} / rung {trial['rung']}",
)
return_code = self._run_command(
session_id, command, self.trainer_root, environment, run_dir / "train.log"
)
ingest_scalars(self.storage, trial_id, run_dir)
if self.cancel_events[session_id].is_set():
raise TuningError("session 已取消")
checkpoint = self._latest_checkpoint(run_dir)
policy = run_dir / "policy.onnx"
if return_code != 0 or checkpoint is None or not policy.is_file():
raise TuningError(f"训练失败(返回码 {return_code})或缺少 checkpoint/policy.onnx")
# 暂停只关闭后续 Trial 调度门;已经开始的 Trial 必须连同固定评估一起
# 完成,避免把一次单步令牌错误地消耗在半个 Trial 上。
eval_output = run_dir / "evaluation.json"
eval_command = [
self.python,
"-u",
"scripts/evaluate.py",
config["taskId"],
"--checkpoint",
str(checkpoint),
"--output",
str(eval_output),
"--reward-config",
str(reward_path),
f"--num-envs={config['evalNumEnvs']}",
f"--steps-per-seed={config['evalSteps']}",
"--gpu-ids",
json.dumps(config["gpuIds"], separators=(",", ":")),
]
self.storage.update_trial(trial_id, state="evaluating", message="正在固定协议评估")
self.storage.update_session(
session_id, state="evaluating", message=f"正在评估 trial {trial['number']}"
)
return_code = self._run_command(
session_id, eval_command, self.trainer_root, environment, run_dir / "evaluate.log"
)
ingest_scalars(self.storage, trial_id, run_dir / "evaluation-events")
if return_code != 0 or not eval_output.is_file():
raise TuningError(f"评估失败(返回码 {return_code})")
evaluation = json.loads(eval_output.read_text(encoding="utf-8"))
baseline_trial = self.storage.list_trials(session_id)[0]
if baseline_trial["evaluation"] is None:
scored = {
"eligible": True,
"score": 0.0,
"components": {},
"metrics": evaluation["metrics"],
}
else:
scored = score_evaluation(
baseline_trial["evaluation"]["metrics"],
evaluation["metrics"],
session["objectiveWeights"],
)
evaluation["score"] = scored
rel_checkpoint = str(checkpoint.relative_to(root))
rel_policy = str(policy.relative_to(root))
self.storage.update_trial(
trial_id,
state="completed",
ended_at=now_iso(),
message="训练与评估完成",
checkpoint_path=rel_checkpoint,
policy_path=rel_policy,
evaluation=evaluation,
score=scored["score"],
eligible=scored["eligible"],
)
best = self._best_highest_rung(session_id)
if best is not None:
self.storage.update_session(session_id, best_trial_id=best["id"])
try:
optuna_number = self.studies.record(
session_id,
trial["rewardConfig"],
scored["score"],
scored["eligible"],
trial["rung"],
)
self.storage.audit(
session_id, "optuna_trial_recorded", {"trialId": trial_id, "number": optuna_number}
)
except Exception as error:
self.storage.audit(
session_id, "optuna_record_failed", {"trialId": trial_id, "error": str(error)[:500]}
)
return self.storage.get_trial(trial_id)
def _best(self, session_id: str, rung: int | None = None) -> dict | None:
trials = [
trial
for trial in self.storage.list_trials(session_id)
if trial["state"] == "completed" and trial["eligible"]
]
if rung is not None:
trials = [trial for trial in trials if trial["rung"] == rung]
return max(
trials,
key=lambda trial: trial["score"] if trial["score"] is not None else -999,
default=None,
)
def _best_highest_rung(self, session_id: str) -> dict | None:
for rung in (2, 1, 0):
best = self._best(session_id, rung=rung)
if best is not None:
return best
return None
def _proposal_context(self, session: dict) -> dict:
trials = self.storage.list_trials(session["id"])[-12:]
rejected_feedback = [
proposal["feedback"]
for proposal in self.storage.list_proposals(session["id"])
if proposal["state"] == "rejected" and proposal["feedback"]
][-4:]
return {
"task": session["config"]["taskId"],
"objectiveWeights": session["objectiveWeights"],
"allowlist": "服务端将验证固定 schema;最多四项修改",
"parameterConstraints": self.storage.get_control(session["id"])["constraints"],
"rejectedFeedback": rejected_feedback,
"trials": [
{
"number": t["number"],
"rung": t["rung"],
"score": t["score"],
"eligible": t["eligible"],
"rewardConfig": t["rewardConfig"],
"evaluation": t["evaluation"] and t["evaluation"].get("metrics"),
}
for t in trials
],
}
def _fallback_patch(self, previous: dict, index: int) -> dict:
names = ("track_linear_velocity", "action_rate_l2", "body_orientation_l2", "foot_slip")
name = names[index % len(names)]
old = previous["weights"][name]
factor = 1.1 if index % 2 == 0 else 0.9
return validate_proposal({"weights": {name: old * factor}}, previous)
def _request_proposal(
self, session: dict, previous: dict, base_trial_id: str, index: int
) -> dict:
try:
result = self.advisor.propose(self._proposal_context(session), previous)
source = "agent"
except Exception as error:
if not session["fallbackEnabled"]:
raise AdvisorUnavailable(str(error)) from error
result = {
"patch": self._fallback_patch(previous, index),
"rationale": f"Agent 不可用,显式 fallback:{error}",
"expectedImpact": {},
"confidence": 0.2,
"promptHash": None,
"usage": {},
"model": "optuna-fallback",
}
source = "fallback"
constraints = self.storage.get_control(session["id"])["constraints"]
result["patch"] = validate_proposal(result["patch"], previous, constraints)
proposal = self.storage.create_proposal(
session["id"],
base_trial_id,
result["patch"],
result["rationale"],
result.get("expectedImpact", {}),
result["confidence"],
source,
)
self.storage.audit(
session["id"],
"proposal_created",
{
"proposalId": proposal["id"],
"source": source,
"promptHash": result.get("promptHash"),
"usage": result.get("usage", {}),
"model": result.get("model"),
},
)
return proposal
def _wait_for_approval(
self, session_id: str, proposal_id: str, cancel: threading.Event
) -> dict:
with self.condition:
while not cancel.is_set():
proposal = self.storage.get_proposal(proposal_id)
if proposal["state"] != "pending":
return proposal
self.condition.wait(timeout=1.0)
raise TuningError("session 已取消")
def _run_session(self, session_id: str, resume: bool, cancel: threading.Event) -> None:
try:
session = self.storage.get_session(session_id)
trials = self.storage.list_trials(session_id)
if resume:
root = self._session_root(session_id)
for interrupted in [trial for trial in trials if trial["state"] == "interrupted"]:
run_dir = (root / interrupted["runDir"]).resolve()
if run_dir.is_relative_to(root):
shutil.rmtree(run_dir, ignore_errors=True)
self.storage.delete_trial(interrupted["id"])
self.storage.audit(
session_id,
"session_resumed",
{
"discardedInterruptedTrials": [
t["id"] for t in trials if t["state"] == "interrupted"
]
},
)
trials = self.storage.list_trials(session_id)
if not trials:
baseline_dir = "trial-000-rung-0"
trial = self.storage.create_trial(
session_id,
0,
0,
session["config"]["rungs"][0],
deepcopy(BASE_REWARD_CONFIGURATION),
None,
baseline_dir,
)
self._execute_trial(session, trial)
session = self.storage.get_session(session_id)
completed_rung0 = [
t
for t in self.storage.list_trials(session_id)
if t["rung"] == 0 and t["state"] == "completed"
]
next_number = len({t["number"] for t in completed_rung0})
best_score = max(
(t["score"] if t["score"] is not None else float("-inf") for t in completed_rung0),
default=0.0,
)
no_improve = 0
while (
next_number < session["config"]["trialCount"]
and no_improve < session["config"]["earlyStopPatience"]
):
if cancel.is_set():
raise TuningError("session 已取消")
self._wait_for_dispatch(session_id, cancel)
control = self.storage.get_control(session_id)
base = (
self.storage.get_trial(control["activeBaseTrialId"])
if control["activeBaseTrialId"]
else self._best(session_id, rung=0) or completed_rung0[0]
)
proposal = self._request_proposal(
session, base["rewardConfig"], base["id"], next_number
)
# 模式允许在 session 运行期间切换,因此每次决策都读取最新持久化值。
current_mode = self.storage.get_session(session_id)["mode"]
if current_mode == "approval":
self.storage.update_session(
session_id, state="awaiting_approval", message="等待批准 Agent 建议"
)
proposal = self._wait_for_approval(session_id, proposal["id"], cancel)
if proposal["state"] == "rejected":
self.storage.audit(
session_id,
"proposal_rejected",
{"proposalId": proposal["id"], "feedback": proposal["feedback"]},
)
continue
else:
self.storage.decide_proposal(proposal["id"], "approved", "自动模式")
proposal = self.storage.get_proposal(proposal["id"])
self._claim_dispatch(session_id, cancel)
constraints = self.storage.get_control(session_id)["constraints"]
reward_config = merge_proposal(base["rewardConfig"], proposal["patch"], constraints)
trial = self.storage.create_trial(
session_id,
next_number,
0,
session["config"]["rungs"][0],
reward_config,
proposal["id"],
f"trial-{next_number:03d}-rung-0",
)
result = self._execute_trial(session, trial)
if control["activeBaseTrialId"]:
self.storage.set_active_base(session_id, None)
self._pause_after_step(session_id)
result_score = result["score"]
if (
result["eligible"]
and result_score is not None
and result_score > best_score + 0.01
):
best_score = result_score
no_improve = 0
else:
no_improve += 1
self.storage.update_session(session_id, consecutive_no_improve=no_improve)
next_number += 1
# Promote top configurations; each new rung resumes its own previous checkpoint.
for rung in (1, 2):
previous = [
t
for t in self.storage.list_trials(session_id)
if t["rung"] == rung - 1 and t["state"] == "completed" and t["eligible"]
]
previous.sort(
key=lambda trial: (
trial["score"] if trial["score"] is not None else float("-inf")
),
reverse=True,
)
promoted_numbers = {
trial["number"]
for trial in self.storage.list_trials(session_id)
if trial["rung"] == rung and trial["state"] == "completed"
}
for parent in previous[: session["config"]["promote"][rung]]:
if parent["number"] in promoted_numbers:
continue
if cancel.is_set():
raise TuningError("session 已取消")
self._claim_dispatch(session_id, cancel)
root = self._session_root(session_id)
checkpoint = root / parent["checkpointPath"]
trial = self.storage.create_trial(
session_id,
parent["number"],
rung,
session["config"]["rungs"][rung],
parent["rewardConfig"],
parent["proposalId"],
f"trial-{parent['number']:03d}-rung-{rung}",
)
self._execute_trial(session, trial, checkpoint)
self._pause_after_step(session_id)
best = self._best_highest_rung(session_id)
if best is None:
raise TuningError("没有通过安全门槛的 trial")
preset_name = f"{session['config']['runName']}-{session_id[:8]}"
self.storage.save_preset(preset_name, session_id, best["id"], best["rewardConfig"])
self.storage.update_session(
session_id,
state="succeeded",
best_trial_id=best["id"],
current_trial_id=None,
message="调参完成",
)
self.storage.audit(
session_id, "session_completed", {"bestTrialId": best["id"], "preset": preset_name}
)
except Exception as error:
state = self.storage.get_session(session_id)["state"]
if cancel.is_set() or state == "cancelled":
self.storage.update_session(
session_id, state="cancelled", message="调参已取消", current_trial_id=None
)
else:
self.storage.update_session(
session_id, state="failed", message=str(error), current_trial_id=None
)
self.storage.audit(session_id, "session_failed", {"error": str(error)[:1000]})
finally:
with self.lock:
self.workers.pop(session_id, None)
self.processes.pop(session_id, None)
def _wait_for_dispatch(self, session_id: str, cancel: threading.Event) -> None:
"""Wait at a scheduler boundary without consuming a one-shot token."""
with self.condition:
while not cancel.is_set():
session = self.storage.get_session(session_id)
control = self.storage.get_control(session_id)
if session["state"] == "paused":
self.condition.wait(timeout=1.0)
continue
if control["runPolicy"] == "continuous" or control["dispatchTokens"] > 0:
return
self.storage.update_session(
session_id,
state="paused",
message="单步 Trial 已完成;等待下一个调度令牌",
)
self.storage.audit(session_id, "step_gate_waiting", {})
self.condition.wait(timeout=1.0)
raise TuningError("session 已取消")
def _claim_dispatch(self, session_id: str, cancel: threading.Event) -> None:
while not cancel.is_set():
self._wait_for_dispatch(session_id, cancel)
if self.storage.use_dispatch_token(session_id):
return
raise TuningError("session 已取消")
def _pause_after_step(self, session_id: str) -> None:
control = self.storage.get_control(session_id)
if control["runPolicy"] == "step" and control["dispatchTokens"] == 0:
self.storage.update_session(
session_id,
state="paused",
message="单步 Trial 已完成;后续调度已暂停",
)
self.storage.audit(session_id, "step_trial_completed", {})
def approve(self, session_id: str, proposal_id: str, payload: Any) -> dict:
proposal = self.storage.get_proposal(proposal_id)
if proposal["sessionId"] != session_id:
raise TuningError("proposal 不属于该 session")
patch = proposal["patch"]
feedback = None
base = self.storage.get_trial(proposal["baseTrialId"])
constraints = self.storage.get_control(session_id)["constraints"]
if isinstance(payload, dict):
feedback = payload.get("feedback")
if "patch" in payload:
patch = payload["patch"]
if feedback is not None and (not isinstance(feedback, str) or len(feedback) > 2000):
raise TuningError("feedback 无效")
patch = validate_proposal(patch, base["rewardConfig"], constraints)
if not self.storage.decide_proposal(proposal_id, "approved", feedback, patch):
raise TuningError("proposal 已处理")
self.storage.audit(
session_id,
"proposal_approved",
{"proposalId": proposal_id, "modified": patch != proposal["patch"]},
)
with self.condition:
self.condition.notify_all()
return self.detail(session_id)
def reject(self, session_id: str, proposal_id: str, payload: Any) -> dict:
feedback = payload.get("feedback", "") if isinstance(payload, dict) else ""
if not isinstance(feedback, str) or len(feedback) > 2000:
raise TuningError("feedback 无效")
proposal = self.storage.get_proposal(proposal_id)
if proposal["sessionId"] != session_id:
raise TuningError("proposal 不属于该 session")
if not self.storage.decide_proposal(proposal_id, "rejected", feedback):
raise TuningError("proposal 已处理")
with self.condition:
self.condition.notify_all()
return self.detail(session_id)
def set_mode(self, session_id: str, payload: Any) -> dict:
if not isinstance(payload, dict) or payload.get("mode") not in ("automatic", "approval"):
raise TuningError("mode 必须是 automatic 或 approval")
mode = payload["mode"]
with self.condition:
session = self.storage.get_session(session_id)
if session["state"] not in RUNNING_STATES | {"awaiting_approval", "paused"}:
raise TuningError("当前状态不能切换运行模式")
previous = session["mode"]
if previous == mode:
return self.detail(session_id)
self.storage.update_session(session_id, mode=mode)
approved_ids = []
if mode == "automatic":
constraints = self.storage.get_control(session_id)["constraints"]
for proposal in self.storage.list_proposals(session_id):
if proposal["state"] != "pending":
continue
base = self.storage.get_trial(proposal["baseTrialId"])
try:
validate_proposal(proposal["patch"], base["rewardConfig"], constraints)
except Exception as error:
self.storage.decide_proposal(
proposal["id"], "rejected", f"参数护栏已变化:{error}"
)
continue
if self.storage.decide_proposal(
proposal["id"], "approved", "运行时切换为全自动模式"
):
approved_ids.append(proposal["id"])
if session["state"] == "awaiting_approval":
self.storage.update_session(
session_id, state="running", message="已切换为全自动模式,继续调参"
)
self.storage.audit(
session_id,
"session_mode_changed",
{"from": previous, "to": mode, "autoApprovedProposalIds": approved_ids},
)
self.condition.notify_all()
return self.detail(session_id)
def set_constraints(self, session_id: str, payload: Any) -> dict:
if not isinstance(payload, dict) or set(payload) != {"revision", "constraints"}:
raise TuningError("参数护栏请求必须包含 revision 与 constraints")
revision = payload["revision"]
if isinstance(revision, bool) or not isinstance(revision, int) or revision < 0:
raise TuningError("constraints revision 必须是非负整数")
constraints = validate_constraints(payload["constraints"])
session = self.storage.get_session(session_id)
if session["state"] not in ACTIVE_SESSION_STATES:
raise TuningError("终态 session 不能修改参数护栏")
control = self.storage.get_control(session_id)
base = None
if control["activeBaseTrialId"]:
base = self.storage.get_trial(control["activeBaseTrialId"])
elif session["currentTrialId"]:
base = self.storage.get_trial(session["currentTrialId"])
else:
base = self._best_highest_rung(session_id)
if base is not None:
validate_configuration_constraints(base["rewardConfig"], constraints)
try:
updated = self.storage.replace_constraints(session_id, revision, constraints)
except StorageConflict as error:
raise ResourceBusyError(str(error)) from error
rejected = []
for proposal in self.storage.list_proposals(session_id):
if proposal["state"] != "pending":
continue
proposal_base = self.storage.get_trial(proposal["baseTrialId"])
try:
validate_proposal(proposal["patch"], proposal_base["rewardConfig"], constraints)
except Exception as error:
message = f"参数护栏 revision {updated['constraintsRevision']}:{error}"
if self.storage.decide_proposal(proposal["id"], "rejected", message):
rejected.append(proposal["id"])
self.storage.audit(
session_id,
"constraints_updated",
{
"revision": updated["constraintsRevision"],
"paths": sorted(constraints),
"rejectedProposalIds": rejected,
},
)
if rejected:
with self.condition:
self.condition.notify_all()
return self.detail(session_id)
def step(self, session_id: str, payload: Any) -> dict:
if not isinstance(payload, dict) or payload.get("count", 1) != 1:
raise TuningError("单步调度一次只能发放 1 个 Trial 令牌")
session = self.storage.get_session(session_id)
if session["state"] not in {"paused", "awaiting_approval"}:
raise TuningError("请先暂停或等待 Proposal 审批,再执行单步 Trial")
control = self.storage.get_control(session_id)
if control["dispatchTokens"] > 0:
raise TuningError("已有未消费的单步 Trial 令牌")
try:
self.storage.grant_dispatch_token(session_id)
except StorageConflict as error:
raise ResourceBusyError(str(error)) from error
if session["state"] == "paused":
has_pending = any(
proposal["state"] == "pending"
for proposal in self.storage.list_proposals(session_id)
)
self.storage.update_session(
session_id,
state="awaiting_approval" if has_pending else "running",
message="单步令牌已就绪;请审批 Proposal"
if has_pending
else "已授权执行一个 Trial",
)
self.storage.audit(session_id, "step_token_granted", {"count": 1})
with self.condition:
self.condition.notify_all()
return self.detail(session_id)
def rollback(self, session_id: str, payload: Any) -> dict:
if not isinstance(payload, dict):
raise TuningError("rollback 请求体必须是对象")
session = self.storage.get_session(session_id)
if session["state"] not in {"paused", "awaiting_approval"}:
raise TuningError("回滚只能在安全暂停或等待审批时执行")
target_id = payload.get("trialId")
if payload.get("target") == "best":
target_id = session["bestTrialId"] or (self._best_highest_rung(session_id) or {}).get(
"id"
)
if not isinstance(target_id, str):
raise TuningError("rollback 必须指定 trialId 或 target=best")
target = self.storage.get_trial(target_id)
if target["sessionId"] != session_id:
raise TuningError("rollback Trial 不属于该 session")
if target["state"] != "completed" or not target["eligible"]:
raise TuningError("只能回滚到已完成且通过安全门槛的 Trial")
checkpoint = payload.get("checkpoint", False)
if not isinstance(checkpoint, bool):
raise TuningError("checkpoint 必须是布尔值")
if checkpoint:
path_value = target.get("checkpointPath")
if not path_value:
raise TuningError("目标 Trial 没有可用 checkpoint")
root = self._session_root(session_id)
path = (root / path_value).resolve()
if not path.is_relative_to(root) or not path.is_file():
raise TuningError("目标 checkpoint 不存在或路径非法")
superseded = []
for proposal in self.storage.list_proposals(session_id):
if proposal["state"] == "pending" and self.storage.decide_proposal(
proposal["id"], "rejected", f"由回滚到 Trial {target_id[:8]} 取代"
):
superseded.append(proposal["id"])
self.storage.set_active_base(session_id, target_id)
self.storage.reset_step_gate(session_id)
self.storage.update_session(
session_id,
state="paused",
message=f"已回滚到 Trial {target['number']} / Rung {target['rung']};等待单步或继续",
)
self.storage.audit(
session_id,
"rollback_selected",
{
"trialId": target_id,
"checkpoint": checkpoint,
"supersededProposalIds": superseded,
},
)
with self.condition:
self.condition.notify_all()
return self.detail(session_id)
def pause(self, session_id: str) -> dict:
session = self.storage.get_session(session_id)
if session["state"] not in RUNNING_STATES | {"awaiting_approval"}:
raise TuningError("当前状态不能暂停")
# 以持久化调度门记录暂停意图,而不是只依赖 session.state。当前 Trial
# 的训练/评估会继续完成,下一次 _claim_dispatch 必须等待显式继续或单步。
self.storage.reset_step_gate(session_id)
self.storage.update_session(
session_id, state="paused", message="已暂停后续调度;在途 Trial(如有)将完整结束"
)
return self.detail(session_id)
def resume(self, session_id: str) -> dict:
session = self.storage.get_session(session_id)
if session["state"] == "paused":
self.storage.set_run_policy(session_id, "continuous")
has_pending = any(
proposal["state"] == "pending"
for proposal in self.storage.list_proposals(session_id)
)
self.storage.update_session(
session_id,
state="awaiting_approval" if has_pending else "running",
message="请审批待处理 Proposal" if has_pending else "连续调参已恢复",
)
with self.condition:
self.condition.notify_all()
elif session["state"] == "interrupted":
self.storage.set_run_policy(session_id, "continuous")
self.storage.update_session(session_id, state="queued", message="从最近完整结果恢复")
self._start_worker(session_id, resume=True)
else:
raise TuningError("当前状态不能恢复")
return self.detail(session_id)
def cancel(self, session_id: str) -> dict:
session = self.storage.get_session(session_id)
if session["state"] not in ACTIVE_SESSION_STATES:
return self.detail(session_id)
self.storage.update_session(session_id, state="cancelled", message="正在取消")
event = self.cancel_events.get(session_id)
if event:
event.set()
process = self.processes.get(session_id)
if process:
terminate_process(process)
with self.condition:
self.condition.notify_all()
return self.detail(session_id)
def metrics(
self,
session_id: str,
trial_id: str,
tags: list[str] | None,
max_points: int,
after_step: int | None = None,
) -> dict:
trial = self.storage.get_trial(trial_id)
if trial["sessionId"] != session_id:
raise TuningError("trial 不属于该 session")
series = self.storage.metrics(trial_id, tags, max_points, after_step)
next_step = max(
(point["step"] for item in series for point in item["points"]),
default=after_step,
)
return {"trialId": trial_id, "series": series, "nextStep": next_step}
def best_artifact(self, session_id: str) -> Path:
session = self.storage.get_session(session_id)
if not session["bestTrialId"]:
raise TuningError("尚无最佳策略")
trial = self.storage.get_trial(session["bestTrialId"])
if not trial["policyPath"]:
raise TuningError("最佳策略文件不存在")
root = self._session_root(session_id)
path = (root / trial["policyPath"]).resolve()
if not path.is_relative_to(root) or not path.is_file():
raise TuningError("最佳策略文件不存在")
return path
def preset_config(self, preset_id: str) -> dict:
return self.storage.get_preset(preset_id)["rewardConfig"]
def test_agent(self) -> dict:
try:
return self.advisor.test_connection()
except Exception as error:
raise TuningError(f"Agent 连接测试失败:{error}") from error
def shutdown(self) -> None:
for session_id in list(self.workers):
with suppress(KeyError, TuningError):
self.cancel(session_id)
for worker in list(self.workers.values()):
worker.join(timeout=7)
+46
View File
@@ -0,0 +1,46 @@
"""Shared GPU lease and process-group lifecycle helpers."""
from __future__ import annotations
import os
import signal
import subprocess
import threading
from contextlib import suppress
class ResourceBusyError(RuntimeError):
pass
class GpuLease:
def __init__(self):
self.lock = threading.RLock()
self.owner: str | None = None
def acquire(self, owner: str) -> None:
with self.lock:
if self.owner is not None and self.owner != owner:
raise ResourceBusyError(f"计算资源正由 {self.owner} 使用")
self.owner = owner
def release(self, owner: str) -> None:
with self.lock:
if self.owner == owner:
self.owner = None
def public(self) -> str | None:
with self.lock:
return self.owner
def terminate_process(process: subprocess.Popen[str], grace_seconds: float = 5.0) -> None:
if process.poll() is not None:
return
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=grace_seconds)
except subprocess.TimeoutExpired:
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGKILL)
+281
View File
@@ -0,0 +1,281 @@
"""Pure-Python reward tuning schema shared by the service and trainer."""
from __future__ import annotations
import math
from collections.abc import Mapping
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
MAX_PROPOSAL_CHANGES = 4
MIN_CHANGE_RATIO = 0.5
MAX_CHANGE_RATIO = 2.0
class RewardConfigError(ValueError):
"""A reward configuration or proposal violated the allowlist."""
@dataclass(frozen=True)
class NumericSpec:
minimum: float
maximum: float
default: float
allow_zero: bool = True
WEIGHT_SPECS: dict[str, NumericSpec] = {
"track_linear_velocity": NumericSpec(0.5, 3.0, 1.0, False),
"track_angular_velocity": NumericSpec(0.25, 2.0, 1.0, False),
"body_orientation_l2": NumericSpec(-3.0, -0.1, -1.0, False),
"pose": NumericSpec(0.0, 2.5, 1.0),
"body_ang_vel": NumericSpec(-0.2, 0.0, -0.05),
"angular_momentum": NumericSpec(-0.1, 0.0, -0.025),
"is_terminated": NumericSpec(-400.0, -50.0, -200.0, False),
"joint_acc_l2": NumericSpec(-2.0e-6, 0.0, -2.5e-7),
"joint_pos_limits": NumericSpec(-30.0, -2.0, -10.0, False),
"action_rate_l2": NumericSpec(-0.2, -0.005, -0.05, False),
"foot_gait": NumericSpec(0.0, 1.5, 0.5),
"foot_clearance": NumericSpec(-3.0, 0.0, -1.0),
"foot_slip": NumericSpec(-1.0, 0.0, -0.25),
"soft_landing": NumericSpec(-5.0e-3, 0.0, -1.0e-3),
"stand_still": NumericSpec(-3.0, 0.0, -1.0),
"electrical_power": NumericSpec(-5.0e-3, 0.0, 0.0),
}
PARAMETER_SPECS: dict[str, NumericSpec] = {
"track_linear_velocity.std": NumericSpec(0.25, 1.0, math.sqrt(0.25), False),
"track_angular_velocity.std": NumericSpec(0.35, 1.2, math.sqrt(0.5), False),
"pose.std_standing_scale": NumericSpec(0.5, 2.0, 1.0, False),
"pose.std_walking_scale": NumericSpec(0.5, 2.0, 1.0, False),
"pose.std_running_scale": NumericSpec(0.5, 2.0, 1.0, False),
"pose.walking_threshold": NumericSpec(0.05, 0.5, 0.1, False),
"pose.running_threshold": NumericSpec(1.0, 2.5, 1.5, False),
"foot_gait.period": NumericSpec(0.4, 0.8, 0.6, False),
"foot_gait.threshold": NumericSpec(0.45, 0.65, 0.56, False),
"foot_gait.command_threshold": NumericSpec(0.02, 0.3, 0.1, False),
"foot_clearance.target_height": NumericSpec(0.06, 0.16, 0.1, False),
"foot_clearance.command_threshold": NumericSpec(0.02, 0.3, 0.1, False),
"foot_slip.command_threshold": NumericSpec(0.02, 0.3, 0.1, False),
"soft_landing.command_threshold": NumericSpec(0.02, 0.3, 0.1, False),
"stand_still.command_threshold": NumericSpec(0.02, 0.3, 0.1, False),
}
BASE_REWARD_CONFIGURATION: dict[str, dict[str, float]] = {
"weights": {name: spec.default for name, spec in WEIGHT_SPECS.items()},
"params": {name: spec.default for name, spec in PARAMETER_SPECS.items()},
}
def _number(name: str, value: Any, spec: NumericSpec) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise RewardConfigError(f"{name} 必须是数值")
result = float(value)
if not math.isfinite(result):
raise RewardConfigError(f"{name} 必须是有限数值")
if result == 0.0 and not spec.allow_zero:
raise RewardConfigError(f"{name} 不允许关闭")
if result < spec.minimum or result > spec.maximum:
raise RewardConfigError(f"{name} 必须在 {spec.minimum}–{spec.maximum} 之间")
return result
def _mapping(value: Any, name: str) -> Mapping[str, Any]:
if not isinstance(value, Mapping):
raise RewardConfigError(f"{name} 必须是对象")
return value
def _cross_validate(config: Mapping[str, Mapping[str, float]]) -> None:
params = config["params"]
if params["pose.walking_threshold"] >= params["pose.running_threshold"]:
raise RewardConfigError("pose.walking_threshold 必须小于 pose.running_threshold")
def _path_spec(path: str) -> tuple[str, str, NumericSpec]:
if path.startswith("weights."):
section, name = "weights", path.removeprefix("weights.")
spec = WEIGHT_SPECS.get(name)
elif path.startswith("params."):
section, name = "params", path.removeprefix("params.")
spec = PARAMETER_SPECS.get(name)
else:
section, name, spec = "", "", None
if spec is None:
raise RewardConfigError(f"未知参数约束:{path}")
return section, name, spec
def validate_constraints(value: Any) -> dict[str, dict[str, float | str]]:
"""Validate sparse per-session range/fixed safety constraints."""
root = _mapping(value, "constraints")
if len(root) > len(WEIGHT_SPECS) + len(PARAMETER_SPECS):
raise RewardConfigError("constraints 数量超过白名单参数总数")
result: dict[str, dict[str, float | str]] = {}
for raw_path, raw_constraint in root.items():
if not isinstance(raw_path, str):
raise RewardConfigError("constraint path 必须是字符串")
_, _, spec = _path_spec(raw_path)
constraint = _mapping(raw_constraint, raw_path)
kind = constraint.get("kind")
if kind == "fixed":
if set(constraint) != {"kind", "value"}:
raise RewardConfigError(f"{raw_path} fixed 约束只能包含 kind/value")
fixed = _number(f"{raw_path}.value", constraint["value"], spec)
result[raw_path] = {"kind": "fixed", "value": fixed}
elif kind == "range":
if set(constraint) != {"kind", "min", "max"}:
raise RewardConfigError(f"{raw_path} range 约束只能包含 kind/min/max")
minimum = _number(f"{raw_path}.min", constraint["min"], spec)
maximum = _number(f"{raw_path}.max", constraint["max"], spec)
if minimum > maximum:
raise RewardConfigError(f"{raw_path} 下限不能大于上限")
result[raw_path] = {"kind": "range", "min": minimum, "max": maximum}
else:
raise RewardConfigError(f"{raw_path}.kind 必须是 range 或 fixed")
return result
def validate_configuration_constraints(value: Any, constraints: Any) -> None:
"""Ensure a complete reward configuration satisfies every session constraint."""
config = validate_configuration(value)
checked = validate_constraints(constraints)
for path, constraint in checked.items():
section, name, _ = _path_spec(path)
current = config[section][name]
if constraint["kind"] == "fixed":
if current != constraint["value"]:
raise RewardConfigError(
f"{path} 已固定为 {constraint['value']},不能设为 {current}"
)
elif current < constraint["min"] or current > constraint["max"]:
raise RewardConfigError(
f"{path}={current} 超出工程锁定范围 {constraint['min']}–{constraint['max']}"
)
def validate_configuration(value: Any) -> dict[str, dict[str, float]]:
"""Validate a complete configuration and reject missing/unknown fields."""
root = _mapping(value, "rewardConfig")
if set(root) != {"weights", "params"}:
raise RewardConfigError("rewardConfig 只能包含 weights 和 params")
raw_weights = _mapping(root["weights"], "weights")
raw_params = _mapping(root["params"], "params")
if set(raw_weights) != set(WEIGHT_SPECS):
raise RewardConfigError("weights 必须完整且不能包含未知奖励项")
if set(raw_params) != set(PARAMETER_SPECS):
raise RewardConfigError("params 必须完整且不能包含未知参数")
config = {
"weights": {
name: _number(f"weights.{name}", raw_weights[name], spec)
for name, spec in WEIGHT_SPECS.items()
},
"params": {
name: _number(f"params.{name}", raw_params[name], spec)
for name, spec in PARAMETER_SPECS.items()
},
}
_cross_validate(config)
return config
def validate_proposal(
value: Any, previous: Any, constraints: Any | None = None
) -> dict[str, dict[str, float]]:
"""Validate a sparse Agent patch relative to a complete previous config and guardrails."""
current = validate_configuration(previous)
root = _mapping(value, "proposal")
if not set(root).issubset({"weights", "params"}):
raise RewardConfigError("proposal 只能包含 weights 和 params")
raw_weights = _mapping(root.get("weights", {}), "weights")
raw_params = _mapping(root.get("params", {}), "params")
if len(raw_weights) + len(raw_params) == 0:
raise RewardConfigError("proposal 至少需要一项修改")
if len(raw_weights) + len(raw_params) > MAX_PROPOSAL_CHANGES:
raise RewardConfigError(f"proposal 每轮最多修改 {MAX_PROPOSAL_CHANGES} 项")
unknown_weights = set(raw_weights) - set(WEIGHT_SPECS)
unknown_params = set(raw_params) - set(PARAMETER_SPECS)
if unknown_weights:
raise RewardConfigError(f"未知奖励项:{', '.join(sorted(unknown_weights))}")
if unknown_params:
raise RewardConfigError(f"未知奖励参数:{', '.join(sorted(unknown_params))}")
patch: dict[str, dict[str, float]] = {"weights": {}, "params": {}}
for name, raw in raw_weights.items():
value_number = _number(f"weights.{name}", raw, WEIGHT_SPECS[name])
old = current["weights"][name]
if old != 0.0 and value_number != 0.0:
ratio = abs(value_number / old)
if ratio < MIN_CHANGE_RATIO or ratio > MAX_CHANGE_RATIO:
raise RewardConfigError(
f"weights.{name} 单轮变化必须在旧值幅度的 "
f"{MIN_CHANGE_RATIO}×–{MAX_CHANGE_RATIO}×"
)
if value_number == old:
raise RewardConfigError(f"weights.{name} 没有发生变化")
patch["weights"][name] = value_number
for name, raw in raw_params.items():
value_number = _number(f"params.{name}", raw, PARAMETER_SPECS[name])
old = current["params"][name]
ratio = abs(value_number / old)
if ratio < MIN_CHANGE_RATIO or ratio > MAX_CHANGE_RATIO:
raise RewardConfigError(
f"params.{name} 单轮变化必须在旧值的 {MIN_CHANGE_RATIO}×–{MAX_CHANGE_RATIO}×"
)
if value_number == old:
raise RewardConfigError(f"params.{name} 没有发生变化")
patch["params"][name] = value_number
candidate = deepcopy(current)
candidate["weights"].update(patch["weights"])
candidate["params"].update(patch["params"])
_cross_validate(candidate)
if constraints is not None:
validate_configuration_constraints(candidate, constraints)
return patch
def merge_proposal(
previous: Any, proposal: Any, constraints: Any | None = None
) -> dict[str, dict[str, float]]:
current = validate_configuration(previous)
patch = validate_proposal(proposal, current, constraints)
merged = deepcopy(current)
merged["weights"].update(patch["weights"])
merged["params"].update(patch["params"])
return validate_configuration(merged)
def apply_reward_configuration(env_cfg: Any, value: Any) -> None:
"""Apply a validated full config to a fresh mjlab environment config."""
config = validate_configuration(value)
for name, weight in config["weights"].items():
if name not in env_cfg.rewards:
raise RewardConfigError(f"环境缺少奖励项:{name}")
env_cfg.rewards[name].weight = weight
params = config["params"]
direct = {
"track_linear_velocity.std": ("track_linear_velocity", "std"),
"track_angular_velocity.std": ("track_angular_velocity", "std"),
"pose.walking_threshold": ("pose", "walking_threshold"),
"pose.running_threshold": ("pose", "running_threshold"),
"foot_gait.period": ("foot_gait", "period"),
"foot_gait.threshold": ("foot_gait", "threshold"),
"foot_gait.command_threshold": ("foot_gait", "command_threshold"),
"foot_clearance.target_height": ("foot_clearance", "target_height"),
"foot_clearance.command_threshold": ("foot_clearance", "command_threshold"),
"foot_slip.command_threshold": ("foot_slip", "command_threshold"),
"soft_landing.command_threshold": ("soft_landing", "command_threshold"),
"stand_still.command_threshold": ("stand_still", "command_threshold"),
}
for path, (term, parameter) in direct.items():
env_cfg.rewards[term].params[parameter] = params[path]
for regime in ("standing", "walking", "running"):
key = f"std_{regime}"
scale = params[f"pose.{key}_scale"]
baseline = env_cfg.rewards["pose"].params[key]
env_cfg.rewards["pose"].params[key] = {
pattern: float(std) * scale for pattern, std in baseline.items()
}
+118
View File
@@ -0,0 +1,118 @@
"""Stable, reward-weight-independent evaluation scoring."""
from __future__ import annotations
import math
from collections.abc import Mapping
from typing import Any
DEFAULT_OBJECTIVE_WEIGHTS = {
"velocity_tracking": 0.35,
"action_smoothness": 0.20,
"posture_stability": 0.15,
"fall_avoidance": 0.15,
"foot_slip": 0.10,
"energy": 0.05,
}
REQUIRED_METRICS = {
"linear_velocity_rmse",
"angular_velocity_rmse",
"mean_action_acc",
"orientation_error",
"fall_rate",
"slip_velocity",
"mechanical_power",
}
PHYSICAL_FLOORS = {
"linear_velocity_rmse": 0.10,
"angular_velocity_rmse": 0.10,
"mean_action_acc": 0.01,
"orientation_error": 0.05,
"fall_rate": 0.02,
"slip_velocity": 0.05,
"mechanical_power": 10.0,
}
class EvaluationError(ValueError):
pass
def validate_objective_weights(value: Any) -> dict[str, float]:
if not isinstance(value, Mapping) or set(value) != set(DEFAULT_OBJECTIVE_WEIGHTS):
raise EvaluationError("objectiveWeights 必须完整包含六个目标")
result: dict[str, float] = {}
for key in DEFAULT_OBJECTIVE_WEIGHTS:
raw = value[key]
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
raise EvaluationError(f"objectiveWeights.{key} 必须是数值")
number = float(raw)
if not math.isfinite(number) or number < 0.0 or number > 1.0:
raise EvaluationError(f"objectiveWeights.{key} 必须在 0–1 之间")
result[key] = number
if not math.isclose(sum(result.values()), 1.0, abs_tol=1.0e-6):
raise EvaluationError("objectiveWeights 总和必须为 1")
return result
def validate_metrics(value: Any) -> dict[str, float]:
if not isinstance(value, Mapping):
raise EvaluationError("metrics 必须是对象")
missing = REQUIRED_METRICS - set(value)
if missing:
raise EvaluationError(f"metrics 缺少:{', '.join(sorted(missing))}")
result: dict[str, float] = {}
for key in REQUIRED_METRICS:
raw = value[key]
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
raise EvaluationError(f"metrics.{key} 必须是数值")
number = float(raw)
if not math.isfinite(number) or number < 0.0:
raise EvaluationError(f"metrics.{key} 必须是非负有限数值")
result[key] = number
if result["fall_rate"] > 1.0:
raise EvaluationError("metrics.fall_rate 必须在 0–1 之间")
return result
def _improvement(baseline: Mapping[str, float], current: Mapping[str, float], key: str) -> float:
scale = max(abs(baseline[key]), PHYSICAL_FLOORS[key])
return max(-1.0, min(1.0, (baseline[key] - current[key]) / scale))
def score_evaluation(
baseline_value: Any,
current_value: Any,
objective_weights: Any = DEFAULT_OBJECTIVE_WEIGHTS,
) -> dict[str, Any]:
baseline = validate_metrics(baseline_value)
current = validate_metrics(current_value)
weights = validate_objective_weights(objective_weights)
components = {
"velocity_tracking": 0.8 * _improvement(baseline, current, "linear_velocity_rmse")
+ 0.2 * _improvement(baseline, current, "angular_velocity_rmse"),
"action_smoothness": _improvement(baseline, current, "mean_action_acc"),
"posture_stability": _improvement(baseline, current, "orientation_error"),
"fall_avoidance": _improvement(baseline, current, "fall_rate"),
"foot_slip": _improvement(baseline, current, "slip_velocity"),
"energy": _improvement(baseline, current, "mechanical_power"),
}
tracking_limit = max(
baseline["linear_velocity_rmse"] * 1.05, baseline["linear_velocity_rmse"] + 1.0e-6
)
eligible = (
current["fall_rate"] <= baseline["fall_rate"] + 0.02
and current["linear_velocity_rmse"] <= tracking_limit
)
total = sum(weights[key] * components[key] for key in weights)
if not eligible:
total = min(total, -1.0)
return {
"eligible": eligible,
"score": total,
"components": components,
"metrics": current,
"baselineMetrics": baseline,
}
+689
View File
@@ -0,0 +1,689 @@
"""SQLite persistence for tuning sessions, trials, proposals and scalar data."""
from __future__ import annotations
import json
import sqlite3
import threading
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
SCHEMA_VERSION = 2
class StorageConflict(RuntimeError):
"""Optimistic-concurrency revision mismatch."""
def now_iso() -> str:
return datetime.now(UTC).isoformat()
def _json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
def _decode(value: str | None) -> Any:
return json.loads(value) if value else None
def _lttb(points: list[dict], threshold: int) -> list[dict]:
"""Largest-Triangle-Three-Buckets downsampling preserving peaks and endpoints."""
if threshold >= len(points) or threshold < 3:
return points[:threshold]
sampled = [points[0]]
bucket_width = (len(points) - 2) / (threshold - 2)
anchor_index = 0
for bucket in range(threshold - 2):
average_start = int((bucket + 1) * bucket_width) + 1
average_end = min(int((bucket + 2) * bucket_width) + 1, len(points))
average_bucket = points[average_start:average_end] or [points[-1]]
average_x = sum(point["step"] for point in average_bucket) / len(average_bucket)
average_y = sum(point["value"] for point in average_bucket) / len(average_bucket)
range_start = int(bucket * bucket_width) + 1
range_end = min(int((bucket + 1) * bucket_width) + 1, len(points) - 1)
anchor = points[anchor_index]
selected_index = range_start
maximum_area = -1.0
for index in range(range_start, max(range_start + 1, range_end)):
point = points[index]
area = abs(
(anchor["step"] - average_x) * (point["value"] - anchor["value"])
- (anchor["step"] - point["step"]) * (average_y - anchor["value"])
)
if area > maximum_area:
maximum_area = area
selected_index = index
sampled.append(points[selected_index])
anchor_index = selected_index
sampled.append(points[-1])
return sampled
class TuningStorage:
def __init__(self, path: Path):
self.path = path.expanduser().resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self.local = threading.local()
self._migrate()
def connection(self) -> sqlite3.Connection:
connection = getattr(self.local, "connection", None)
if connection is None:
connection = sqlite3.connect(self.path, timeout=10, isolation_level=None)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA busy_timeout=10000")
self.local.connection = connection
return connection
@contextmanager
def transaction(self) -> Iterator[sqlite3.Connection]:
connection = self.connection()
connection.execute("BEGIN IMMEDIATE")
try:
yield connection
connection.execute("COMMIT")
except Exception:
connection.execute("ROLLBACK")
raise
def _migrate(self) -> None:
connection = self.connection()
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS schema_migrations(version INTEGER PRIMARY KEY);
CREATE TABLE IF NOT EXISTS sessions(
id TEXT PRIMARY KEY, state TEXT NOT NULL, mode TEXT NOT NULL,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
config_json TEXT NOT NULL, objective_json TEXT NOT NULL,
message TEXT NOT NULL, current_trial_id TEXT, best_trial_id TEXT,
consecutive_no_improve INTEGER NOT NULL DEFAULT 0,
fallback_enabled INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS trials(
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
number INTEGER NOT NULL, state TEXT NOT NULL, rung INTEGER NOT NULL,
target_iterations INTEGER NOT NULL, reward_config_json TEXT NOT NULL,
proposal_id TEXT, run_dir TEXT NOT NULL, checkpoint_path TEXT,
policy_path TEXT, evaluation_json TEXT, score REAL, eligible INTEGER,
created_at TEXT NOT NULL, started_at TEXT, ended_at TEXT, message TEXT NOT NULL,
UNIQUE(session_id, number, rung)
);
CREATE TABLE IF NOT EXISTS proposals(
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
base_trial_id TEXT, state TEXT NOT NULL, source TEXT NOT NULL,
patch_json TEXT NOT NULL, rationale TEXT NOT NULL,
expected_json TEXT, confidence REAL NOT NULL,
created_at TEXT NOT NULL, decided_at TEXT, feedback TEXT
);
CREATE TABLE IF NOT EXISTS metric_points(
trial_id TEXT NOT NULL REFERENCES trials(id) ON DELETE CASCADE,
tag TEXT NOT NULL, step INTEGER NOT NULL,
wall_time REAL NOT NULL, value REAL NOT NULL,
PRIMARY KEY(trial_id, tag, step)
);
CREATE TABLE IF NOT EXISTS audit_events(
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
event_type TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS presets(
id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, session_id TEXT NOT NULL,
trial_id TEXT NOT NULL, reward_config_json TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS session_controls(
session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
run_policy TEXT NOT NULL DEFAULT 'continuous'
CHECK(run_policy IN ('continuous','step')),
dispatch_tokens INTEGER NOT NULL DEFAULT 0 CHECK(dispatch_tokens >= 0),
active_base_trial_id TEXT,
revision INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS session_constraints(
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
path TEXT NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('range','fixed')),
min_value REAL, max_value REAL, fixed_value REAL,
updated_at TEXT NOT NULL,
PRIMARY KEY(session_id, path)
);
CREATE INDEX IF NOT EXISTS idx_trials_session ON trials(session_id, number, rung);
CREATE INDEX IF NOT EXISTS idx_proposals_session ON proposals(session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_metrics_trial_tag ON metric_points(trial_id, tag, step);
"""
)
connection.execute(
"INSERT OR IGNORE INTO session_controls(session_id) SELECT id FROM sessions"
)
connection.execute(
"INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)", (SCHEMA_VERSION,)
)
def recover_interrupted(self) -> None:
at = now_iso()
with self.transaction() as connection:
connection.execute(
"UPDATE trials SET state='interrupted', ended_at=?, "
"message='服务重启中断,等待显式恢复' "
"WHERE state IN ('training','evaluating')",
(at,),
)
connection.execute(
"UPDATE sessions SET state='interrupted', updated_at=?, "
"message='服务重启中断,可从完整 checkpoint 恢复' "
"WHERE state IN ('running','evaluating')",
(at,),
)
def create_session(self, mode: str, config: dict, objective: dict, fallback: bool) -> dict:
session_id, at = uuid.uuid4().hex, now_iso()
with self.transaction() as connection:
connection.execute(
"INSERT INTO sessions("
"id,state,mode,created_at,updated_at,config_json,objective_json,"
"message,fallback_enabled) "
"VALUES (?, 'queued', ?, ?, ?, ?, ?, '等待基线训练', ?)",
(session_id, mode, at, at, _json(config), _json(objective), int(fallback)),
)
connection.execute("INSERT INTO session_controls(session_id) VALUES (?)", (session_id,))
connection.execute(
"INSERT INTO audit_events(session_id,event_type,payload_json,created_at) "
"VALUES (?,?,?,?)",
(session_id, "session_created", _json({"mode": mode}), at),
)
return self.get_session(session_id)
def _session(self, row: sqlite3.Row) -> dict:
return {
"id": row["id"],
"state": row["state"],
"mode": row["mode"],
"createdAt": row["created_at"],
"updatedAt": row["updated_at"],
"config": _decode(row["config_json"]),
"objectiveWeights": _decode(row["objective_json"]),
"message": row["message"],
"currentTrialId": row["current_trial_id"],
"bestTrialId": row["best_trial_id"],
"consecutiveNoImprove": row["consecutive_no_improve"],
"fallbackEnabled": bool(row["fallback_enabled"]),
}
def get_session(self, session_id: str) -> dict:
row = (
self.connection().execute("SELECT * FROM sessions WHERE id=?", (session_id,)).fetchone()
)
if row is None:
raise KeyError(session_id)
return self._session(row)
def list_sessions(self, limit: int = 50) -> list[dict]:
rows = (
self.connection()
.execute("SELECT * FROM sessions ORDER BY created_at DESC LIMIT ?", (limit,))
.fetchall()
)
return [self._session(row) for row in rows]
def update_session(self, session_id: str, **changes: Any) -> bool:
columns = {
"state": "state",
"mode": "mode",
"message": "message",
"current_trial_id": "current_trial_id",
"best_trial_id": "best_trial_id",
"consecutive_no_improve": "consecutive_no_improve",
}
values, assignments = [], []
for key, value in changes.items():
if key not in columns:
raise ValueError(key)
assignments.append(f"{columns[key]}=?")
values.append(value)
assignments.append("updated_at=?")
values.extend((now_iso(), session_id))
cursor = self.connection().execute(
f"UPDATE sessions SET {', '.join(assignments)} WHERE id=?", values
)
return cursor.rowcount == 1
def get_control(self, session_id: str) -> dict:
row = (
self.connection()
.execute("SELECT * FROM session_controls WHERE session_id=?", (session_id,))
.fetchone()
)
if row is None:
raise KeyError(session_id)
constraint_rows = (
self.connection()
.execute(
"SELECT * FROM session_constraints WHERE session_id=? ORDER BY path", (session_id,)
)
.fetchall()
)
constraints = {}
for constraint in constraint_rows:
if constraint["kind"] == "fixed":
value = {"kind": "fixed", "value": constraint["fixed_value"]}
else:
value = {
"kind": "range",
"min": constraint["min_value"],
"max": constraint["max_value"],
}
constraints[constraint["path"]] = value
return {
"runPolicy": row["run_policy"],
"dispatchTokens": row["dispatch_tokens"],
"constraintsRevision": row["revision"],
"constraints": constraints,
"activeBaseTrialId": row["active_base_trial_id"],
}
def replace_constraints(
self, session_id: str, expected_revision: int, constraints: dict
) -> dict:
at = now_iso()
with self.transaction() as connection:
row = connection.execute(
"SELECT revision FROM session_controls WHERE session_id=?", (session_id,)
).fetchone()
if row is None:
raise KeyError(session_id)
if row["revision"] != expected_revision:
raise StorageConflict(
f"参数护栏 revision 已变化(当前 {row['revision']},请求 {expected_revision})"
)
connection.execute("DELETE FROM session_constraints WHERE session_id=?", (session_id,))
for path, constraint in constraints.items():
connection.execute(
"INSERT INTO session_constraints("
"session_id,path,kind,min_value,max_value,fixed_value,updated_at) "
"VALUES (?,?,?,?,?,?,?)",
(
session_id,
path,
constraint["kind"],
constraint.get("min"),
constraint.get("max"),
constraint.get("value"),
at,
),
)
connection.execute(
"UPDATE session_controls SET revision=revision+1 WHERE session_id=?",
(session_id,),
)
return self.get_control(session_id)
def grant_dispatch_token(self, session_id: str) -> dict:
"""Atomically grant the sole outstanding one-Trial token."""
with self.transaction() as connection:
cursor = connection.execute(
"UPDATE session_controls SET run_policy='step',dispatch_tokens=1 "
"WHERE session_id=? AND dispatch_tokens=0",
(session_id,),
)
if cursor.rowcount != 1:
exists = connection.execute(
"SELECT 1 FROM session_controls WHERE session_id=?", (session_id,)
).fetchone()
if exists is None:
raise KeyError(session_id)
raise StorageConflict("已有未消费的单步 Trial 令牌")
return self.get_control(session_id)
def use_dispatch_token(self, session_id: str) -> bool:
"""Atomically consume one step token; continuous mode never needs a token."""
with self.transaction() as connection:
row = connection.execute(
"SELECT run_policy,dispatch_tokens FROM session_controls WHERE session_id=?",
(session_id,),
).fetchone()
if row is None:
raise KeyError(session_id)
if row["run_policy"] == "continuous":
return True
if row["dispatch_tokens"] <= 0:
return False
connection.execute(
"UPDATE session_controls SET dispatch_tokens=dispatch_tokens-1 WHERE session_id=?",
(session_id,),
)
return True
def set_run_policy(self, session_id: str, policy: str) -> dict:
if policy not in {"continuous", "step"}:
raise ValueError(policy)
cursor = self.connection().execute(
"UPDATE session_controls SET run_policy=?,"
"dispatch_tokens=CASE WHEN ?='continuous' THEN 0 ELSE dispatch_tokens END "
"WHERE session_id=?",
(policy, policy, session_id),
)
if cursor.rowcount != 1:
raise KeyError(session_id)
return self.get_control(session_id)
def reset_step_gate(self, session_id: str) -> dict:
cursor = self.connection().execute(
"UPDATE session_controls SET run_policy='step',dispatch_tokens=0 WHERE session_id=?",
(session_id,),
)
if cursor.rowcount != 1:
raise KeyError(session_id)
return self.get_control(session_id)
def set_active_base(self, session_id: str, trial_id: str | None) -> dict:
cursor = self.connection().execute(
"UPDATE session_controls SET active_base_trial_id=? WHERE session_id=?",
(trial_id, session_id),
)
if cursor.rowcount != 1:
raise KeyError(session_id)
return self.get_control(session_id)
def create_trial(
self,
session_id: str,
number: int,
rung: int,
target: int,
reward_config: dict,
proposal_id: str | None,
run_dir: str,
) -> dict:
trial_id, at = uuid.uuid4().hex, now_iso()
with self.transaction() as connection:
connection.execute(
"INSERT INTO trials("
"id,session_id,number,state,rung,target_iterations,reward_config_json,"
"proposal_id,run_dir,created_at,message) "
"VALUES (?,?,?,'queued',?,?,?,?,?,?,'等待训练')",
(
trial_id,
session_id,
number,
rung,
target,
_json(reward_config),
proposal_id,
run_dir,
at,
),
)
connection.execute(
"UPDATE sessions SET current_trial_id=?,updated_at=? WHERE id=?",
(trial_id, at, session_id),
)
return self.get_trial(trial_id)
def _trial(self, row: sqlite3.Row) -> dict:
return {
"id": row["id"],
"sessionId": row["session_id"],
"number": row["number"],
"state": row["state"],
"rung": row["rung"],
"targetIterations": row["target_iterations"],
"rewardConfig": _decode(row["reward_config_json"]),
"proposalId": row["proposal_id"],
"runDir": row["run_dir"],
"checkpointPath": row["checkpoint_path"],
"policyPath": row["policy_path"],
"evaluation": _decode(row["evaluation_json"]),
"score": row["score"],
"eligible": None if row["eligible"] is None else bool(row["eligible"]),
"createdAt": row["created_at"],
"startedAt": row["started_at"],
"endedAt": row["ended_at"],
"message": row["message"],
}
def get_trial(self, trial_id: str) -> dict:
row = self.connection().execute("SELECT * FROM trials WHERE id=?", (trial_id,)).fetchone()
if row is None:
raise KeyError(trial_id)
return self._trial(row)
def list_trials(self, session_id: str) -> list[dict]:
rows = (
self.connection()
.execute("SELECT * FROM trials WHERE session_id=? ORDER BY number,rung", (session_id,))
.fetchall()
)
return [self._trial(row) for row in rows]
def delete_trial(self, trial_id: str) -> bool:
cursor = self.connection().execute(
"DELETE FROM trials WHERE id=? AND state='interrupted'", (trial_id,)
)
return cursor.rowcount == 1
def update_trial(self, trial_id: str, **changes: Any) -> bool:
columns = {
"state": "state",
"message": "message",
"checkpoint_path": "checkpoint_path",
"policy_path": "policy_path",
"score": "score",
"eligible": "eligible",
"started_at": "started_at",
"ended_at": "ended_at",
"evaluation": "evaluation_json",
}
values, assignments = [], []
for key, value in changes.items():
if key not in columns:
raise ValueError(key)
if key == "evaluation":
value = _json(value)
if key == "eligible":
value = int(value)
assignments.append(f"{columns[key]}=?")
values.append(value)
values.append(trial_id)
cursor = self.connection().execute(
f"UPDATE trials SET {', '.join(assignments)} WHERE id=?", values
)
return cursor.rowcount == 1
def create_proposal(
self,
session_id: str,
base_trial_id: str | None,
patch: dict,
rationale: str,
expected: Any,
confidence: float,
source: str = "agent",
) -> dict:
proposal_id, at = uuid.uuid4().hex, now_iso()
self.connection().execute(
"INSERT INTO proposals("
"id,session_id,base_trial_id,state,source,patch_json,rationale,"
"expected_json,confidence,created_at) "
"VALUES (?,?,?,'pending',?,?,?,?,?,?)",
(
proposal_id,
session_id,
base_trial_id,
source,
_json(patch),
rationale,
_json(expected),
confidence,
at,
),
)
return self.get_proposal(proposal_id)
def _proposal(self, row: sqlite3.Row) -> dict:
return {
"id": row["id"],
"sessionId": row["session_id"],
"baseTrialId": row["base_trial_id"],
"state": row["state"],
"source": row["source"],
"patch": _decode(row["patch_json"]),
"rationale": row["rationale"],
"expectedImpact": _decode(row["expected_json"]),
"confidence": row["confidence"],
"createdAt": row["created_at"],
"decidedAt": row["decided_at"],
"feedback": row["feedback"],
}
def get_proposal(self, proposal_id: str) -> dict:
row = (
self.connection()
.execute("SELECT * FROM proposals WHERE id=?", (proposal_id,))
.fetchone()
)
if row is None:
raise KeyError(proposal_id)
return self._proposal(row)
def list_proposals(self, session_id: str) -> list[dict]:
rows = (
self.connection()
.execute(
"SELECT * FROM proposals WHERE session_id=? ORDER BY created_at", (session_id,)
)
.fetchall()
)
return [self._proposal(row) for row in rows]
def decide_proposal(
self, proposal_id: str, state: str, feedback: str | None, patch: dict | None = None
) -> bool:
at = now_iso()
assignments, values = ["state=?", "feedback=?", "decided_at=?"], [state, feedback, at]
if patch is not None:
assignments.append("patch_json=?")
values.append(_json(patch))
values.extend((proposal_id,))
cursor = self.connection().execute(
f"UPDATE proposals SET {', '.join(assignments)} WHERE id=? AND state='pending'", values
)
return cursor.rowcount == 1
def insert_metrics(self, trial_id: str, points: list[tuple[str, int, float, float]]) -> None:
self.connection().executemany(
"INSERT INTO metric_points(trial_id,tag,step,wall_time,value) "
"VALUES (?,?,?,?,?) ON CONFLICT(trial_id,tag,step) DO UPDATE SET "
"wall_time=excluded.wall_time,value=excluded.value",
[(trial_id, *point) for point in points],
)
def metrics(
self,
trial_id: str,
tags: list[str] | None = None,
max_points: int = 1000,
after_step: int | None = None,
) -> list[dict]:
parameters: list[Any] = [trial_id]
clause = "trial_id=?"
if tags:
clause += f" AND tag IN ({','.join('?' for _ in tags)})"
parameters.extend(tags)
if after_step is not None:
clause += " AND step>?"
parameters.append(after_step)
rows = (
self.connection()
.execute(
f"SELECT tag,step,wall_time,value FROM metric_points "
f"WHERE {clause} ORDER BY tag,step",
parameters,
)
.fetchall()
)
grouped: dict[str, list[dict]] = {}
for row in rows:
grouped.setdefault(row["tag"], []).append(
{"step": row["step"], "wallTime": row["wall_time"], "value": row["value"]}
)
series = []
for tag, values in grouped.items():
if len(values) > max_points:
values = _lttb(values, max_points)
series.append({"tag": tag, "points": values})
return series
def audit(self, session_id: str, event_type: str, payload: Any) -> None:
self.connection().execute(
"INSERT INTO audit_events(session_id,event_type,payload_json,created_at) "
"VALUES (?,?,?,?)",
(session_id, event_type, _json(payload), now_iso()),
)
def audit_events(self, session_id: str) -> list[dict]:
rows = (
self.connection()
.execute("SELECT * FROM audit_events WHERE session_id=? ORDER BY id", (session_id,))
.fetchall()
)
return [
{
"id": row["id"],
"type": row["event_type"],
"payload": _decode(row["payload_json"]),
"createdAt": row["created_at"],
}
for row in rows
]
def save_preset(self, name: str, session_id: str, trial_id: str, reward_config: dict) -> dict:
preset_id, at = uuid.uuid4().hex, now_iso()
self.connection().execute(
"INSERT INTO presets(id,name,session_id,trial_id,reward_config_json,created_at) "
"VALUES (?,?,?,?,?,?)",
(preset_id, name, session_id, trial_id, _json(reward_config), at),
)
return {
"id": preset_id,
"name": name,
"sessionId": session_id,
"trialId": trial_id,
"rewardConfig": reward_config,
"createdAt": at,
}
def get_preset(self, preset_id: str) -> dict:
row = self.connection().execute("SELECT * FROM presets WHERE id=?", (preset_id,)).fetchone()
if row is None:
raise KeyError(preset_id)
return {
"id": row["id"],
"name": row["name"],
"sessionId": row["session_id"],
"trialId": row["trial_id"],
"rewardConfig": _decode(row["reward_config_json"]),
"createdAt": row["created_at"],
}
def list_presets(self) -> list[dict]:
rows = (
self.connection().execute("SELECT * FROM presets ORDER BY created_at DESC").fetchall()
)
return [
{
"id": row["id"],
"name": row["name"],
"sessionId": row["session_id"],
"trialId": row["trial_id"],
"rewardConfig": _decode(row["reward_config_json"]),
"createdAt": row["created_at"],
}
for row in rows
]
+55
View File
@@ -0,0 +1,55 @@
"""Lazy Optuna study integration used for durable trial history and pruning metadata."""
from __future__ import annotations
from pathlib import Path
from typing import Any
class OptunaStudies:
def __init__(self, root: Path):
self.path = (root / "optuna.sqlite3").resolve()
self.url = f"sqlite:///{self.path}"
def _study(self, session_id: str):
import optuna
optuna.logging.set_verbosity(optuna.logging.WARNING)
return optuna.create_study(
study_name=f"reward-tuning-{session_id}",
storage=self.url,
direction="maximize",
load_if_exists=True,
pruner=optuna.pruners.SuccessiveHalvingPruner(
min_resource=300, reduction_factor=3, min_early_stopping_rate=0
),
)
def record(
self,
session_id: str,
reward_config: dict[str, Any],
score: float,
eligible: bool,
rung: int,
) -> int:
"""Record an externally proposed Agent config through Optuna ask/tell."""
import optuna
study = self._study(session_id)
trial = study.ask()
trial.set_user_attr("reward_config", reward_config)
trial.set_user_attr("eligible", eligible)
trial.set_user_attr("rung", rung)
state = optuna.trial.TrialState.COMPLETE if eligible else optuna.trial.TrialState.PRUNED
study.tell(trial, score if eligible else None, state=state)
return trial.number
def summary(self, session_id: str) -> dict[str, Any]:
study = self._study(session_id)
completed = [trial for trial in study.trials if trial.value is not None]
return {
"studyName": study.study_name,
"trialCount": len(study.trials),
"bestValue": max((trial.value for trial in completed), default=None),
}
+35
View File
@@ -0,0 +1,35 @@
"""TensorBoard scalar ingestion with optional dependency isolation."""
from __future__ import annotations
from pathlib import Path
from .storage import TuningStorage
class TensorboardUnavailable(RuntimeError):
pass
def ingest_scalars(storage: TuningStorage, trial_id: str, log_dir: Path) -> int:
"""Reload all scalar events and idempotently upsert them into SQLite."""
try:
from tensorboard.backend.event_processing.event_accumulator import EventAccumulator
except ImportError as error:
raise TensorboardUnavailable(
"缺少 tensorboard,请安装 training_server/requirements.txt"
) from error
if not log_dir.is_dir():
return 0
accumulator = EventAccumulator(str(log_dir), size_guidance={"scalars": 0})
try:
accumulator.Reload()
except (OSError, ValueError):
return 0
points: list[tuple[str, int, float, float]] = []
for tag in accumulator.Tags().get("scalars", []):
for event in accumulator.Scalars(tag):
points.append((tag, int(event.step), float(event.wall_time), float(event.value)))
if points:
storage.insert_metrics(trial_id, points)
return len(points)
+4 -1
View File
@@ -14,7 +14,8 @@ src/
├── rl/ ONNX 策略运行时、任务绑定、类型和面板 ├── rl/ ONNX 策略运行时、任务绑定、类型和面板
├── simulation/ MuJoCo 会话、物理适配器和仿真控制组件 ├── simulation/ MuJoCo 会话、物理适配器和仿真控制组件
├── telemetry/ 数据源抽象、记录器、导出和数据面板 ├── telemetry/ 数据源抽象、记录器、导出和数据面板
├── training/ 本地训练客户端、类型和面板 ├── training/ 本地训练/调参客户端、类型、共享连接和面板
├── tuning/ 独立 tuning.html 的 Agent dashboard 与 scalar 图表
├── viewer/ Three.js 场景、渲染和交互 ├── viewer/ Three.js 场景、渲染和交互
├── stores/ 跨域应用状态 ├── stores/ 跨域应用状态
└── test/ 全局测试初始化 └── test/ 全局测试初始化
@@ -38,4 +39,6 @@ src/
SimulationSession snapshot → app → viewer / 各业务面板 SimulationSession snapshot → app → viewer / 各业务面板
``` ```
主工作台由 `index.html → src/main.tsx` 启动;自调参工作台由 Vite MPA 入口 `tuning.html → src/tuning/main.tsx` 启动,避免把 MuJoCo/Three.js 主应用依赖打入监控页面。两页仅通过训练 HTTP API和严格同源的短消息交接训练服务凭据/策略导入请求,不在 URL 中传 token。
测试文件使用 `*.test.ts(x)` 与被测模块共置;端到端测试统一保存在 `e2e/`。 测试文件使用 `*.test.ts(x)` 与被测模块共置;端到端测试统一保存在 `e2e/`。
+6 -4
View File
@@ -15,7 +15,7 @@
- 内置平地、坡道、楼梯、可复现随机障碍物及 9 类系统参数化地形,可配置尺寸、摩擦、难度、种子与高度场采样精度 - 内置平地、坡道、楼梯、可复现随机障碍物及 9 类系统参数化地形,可配置尺寸、摩擦、难度、种子与高度场采样精度
- 导入单文件 `.py` 控制器,通过本地 Pyodide 在 `mj_step` 前按仿真时间同步执行 - 导入单文件 `.py` 控制器,通过本地 Pyodide 在 `mj_step` 前按仿真时间同步执行
- 导入 mjlab 导出的 `policy.onnx`,在浏览器本地执行 Go2-W 平衡/速度策略推理 - 导入 mjlab 导出的 `policy.onnx`,在浏览器本地执行 Go2-W 平衡/速度策略推理
- 从图形界面向本机训练桥接服务发起 mjlab 强化学习训练、查看进度/日志、停止任务并导入训练生成的 ONNX - 从图形界面向本机训练桥接服务发起 mjlab 强化学习训练、查看进度/日志、停止任务并导入训练生成的 ONNX;可在独立 TensorBoard 风格页面运行 DeepSeek 奖励函数自调参
- 可配置仿真遥测记录,实时查看速度、机身姿态、位置、驱动力等指标并导出 CSV/JSON - 可配置仿真遥测记录,实时查看速度、机身姿态、位置、驱动力等指标并导出 CSV/JSON
- FPS、物理耗时和主线程步进预算提示 - FPS、物理耗时和主线程步进预算提示
@@ -65,7 +65,7 @@ python3 -m http.server 8080 --directory web-platform-dist
## 地图模块 ## 地图模块
模型加载后打开右侧“地图”标签,可以选择平地、坡道、楼梯、随机障碍物、系统参数化地形或工程内地图包。参数化地形包括离散障碍、沟壑、倒金字塔阶梯、深坑、金字塔阶梯、轨道、随机粗糙、踏石和波浪地形;相同参数与随机种子会确定性生成相同碰撞层。点击“应用并重新编译”后,平台会在当前入口同目录生成临时组合 MJCF;原始工程文件不会被修改。模型编译失败时保留上一个可用仿真会话。 模型加载后打开右侧“地图”标签,即可使用统一地图资产库。工程地图、认证资产、内置程序地图和系统参数化地形共用一条“点击/拖放 → 轻量预览 → 场景树管理 → 一次应用”链路;任何来源的增删或参数修改都先进入同一个场景草稿,不会隐式触发 MuJoCo 编译。参数化地形包括离散障碍、沟壑、倒金字塔阶梯、深坑、金字塔阶梯、轨道、随机粗糙、踏石和波浪地形;相同参数与随机种子会确定性生成相同碰撞层。卡片可直接拖到三维画布,落点按 0.1 m 吸附;所有内置程序地图都可在视口选择、移动和绕 Z 旋转。认证资产的“自动落位(重力)”会查询程序地图真实表面,可落到坡道、楼梯、坑底或高度场,而不是固定假设 `z=0`。最后点击“一次编译应用”才组合 MJCF;编译失败时保留上一个可用仿真会话和完整草稿,普通模型重载也不会提前提交草稿。
工程地图由 `map.json`、静态 MJCF 碰撞层和可选的自包含 GLB 视觉层组成: 工程地图由 `map.json`、静态 MJCF 碰撞层和可选的自包含 GLB 视觉层组成:
@@ -94,7 +94,7 @@ maps/warehouse/
物理地图仅允许静态 `worldbody` 以及 mesh、heightfield、texture、material 等基础 asset,不允许 joint、mocap body、actuator、sensor、include 或 default class。OBJ/STL 应使用简化碰撞模型;高精度模型只放入 GLB。GLB 必须是 2.0 自包含文件,外部 URI 会被拒绝。地图统一使用米制、Z-up、+X 前向坐标系。 物理地图仅允许静态 `worldbody` 以及 mesh、heightfield、texture、material 等基础 asset,不允许 joint、mocap body、actuator、sensor、include 或 default class。OBJ/STL 应使用简化碰撞模型;高精度模型只放入 GLB。GLB 必须是 2.0 自包含文件,外部 URI 会被拒绝。地图统一使用米制、Z-up、+X 前向坐标系。
V3 可编辑地图使用 `schemaVersion: 2`,并增加 `"authoring": { "source": "authoring/map.scene.json" }`。创作层支持方盒、圆柱、胶囊、坡道、楼梯和出生点。“场景 · 资产库”中的认证资产可点击添加或拖到画布落位;没有可编辑地图时,首个资产会立即创建 Schema V2 场景草稿,不触发 MuJoCo 重编译。新增对象支持三种放置方式:自动贴地、沿世界 `-Z` 落到最高静态承载面的自动重力落位,以及禁止位姿编辑的锁定模式。只有点击“应用并重新编译”后才提交物理层。可以通过表单或视口 TransformControls 修改位置、绕 Z 轴旋转和原语尺寸,支持移动/旋转吸附、视口拾取、复制、删除及对齐地面;`W`/`E`/`S` 切换移动、旋转和缩放工具,`Delete` 删除,`Ctrl+Z`/`Ctrl+Y` 撤销重做。编辑只更新 Three.js 草稿预览,点击编辑器内“应用并重新编译”后才生成确定性的静态 MJCF。失败时保留旧仿真和草稿。浏览器不会直接写回原目录,可使用“导出地图 ZIP”下载当前已提交地图包。没有 `authoring.source` 的 V1/V2 地图默认只读;仅由 `box`、`cylinder`、`capsule` 构成且不含 asset、材质、碰撞过滤或隐藏姿态语义的静态 MJCF,可通过“创建可编辑副本”显式升级。转换会生成 `authoring/map.scene.json`、Schema V2 描述和确定性物理层;任何不可逆语义都会导致整体拒绝,不会静默丢失内容。 V3 可编辑地图使用 `schemaVersion: 2`,并增加 `"authoring": { "source": "authoring/map.scene.json" }`。创作层支持方盒、圆柱、胶囊、坡道、楼梯和出生点。“场景 · 资产库”中的认证资产可点击添加或拖到画布落位;没有可编辑地图时,首个资产会创建临时 Schema V2 场景,但不触发 MuJoCo 重编译,“放弃场景更改”会同时移除临时实例和三份临时工程文件。新增对象支持自动贴地、沿世界 `-Z` 自动重力落位及锁定位姿;属性面板可编辑位置、绕 Z 旋转、原语尺寸、摩擦、颜色和透明度。工程地图源与场景实例分离:同一来源可以重复放置,实例拥有独立 XY/绕 Z 位姿,物理层、GLB 视觉层和出生点同步变换;编辑 authoring 源内容会明确同步到同源实例。视口支持吸附、拾取、空白取消选择、复制、删除及对齐地面;`W`/`E`/`S` 切换移动、旋转和缩放工具,`Delete` 删除,`Ctrl+Z`/`Ctrl+Y` 撤销重做。浏览器不会直接写回原目录,可使用“导出地图 ZIP”下载当前已提交地图包。没有 `authoring.source` 的 V1/V2 地图默认只读;仅由 `box`、`cylinder`、`capsule` 构成且不含 asset、材质、碰撞过滤或隐藏姿态语义的静态 MJCF,可通过“创建可编辑副本”显式升级;任何不可逆语义都会导致整体拒绝,不会静默丢失内容。
物理地图超过 2000 个 geom 会产生性能警告,超过 10000 个会被拒绝;GLB 超过 100 万三角面会警告,超过 300 万会被拒绝。当前原生 URDF 模式不支持地图,请切换到“转换为 MJCF”。 物理地图超过 2000 个 geom 会产生性能警告,超过 10000 个会被拒绝;GLB 超过 100 万三角面会警告,超过 300 万会被拒绝。当前原生 URDF 模式不支持地图,请切换到“转换为 MJCF”。
@@ -119,7 +119,9 @@ npm run training-server -- \
--trainer-python /path/to/training-env/bin/python --trainer-python /path/to/training-env/bin/python
``` ```
服务启动时会在终端输出一个随机访问令牌;在界面中填写该令牌后连接。令牌仅保存在当前标签页的 `sessionStorage`。界面默认连接 `http://127.0.0.1:8765`,可选择服务端允许的任务、并行环境数、训练迭代、随机种子、CPU/GPU、GPU 编号和实验记录方式。W&B 默认为本地离线模式,无需登录或 API Key;也可完全禁用,只有明确选择在线模式时才会联网登录。训练期间页面轮询迭代进度与最近日志,可以停止任务;训练成功后点击“导入策略”,生成的 `policy.onnx` 会进入现有 ONNX 加载流程。 服务启动时会在终端输出一个随机访问令牌;在界面中填写该令牌后连接。令牌仅保存在当前标签页的 `sessionStorage`。界面默认连接 `http://127.0.0.1:8765`,可选择服务端允许的任务、并行环境数、训练迭代、随机种子、CPU/GPU、GPU 编号和实验记录方式。W&B 默认为本地离线模式,无需登录或 API Key;也可完全禁用,只有明确选择在线模式时才会联网登录。训练期间页面轮询迭代进度与最近日志,可以停止任务;训练成功后点击“导入策略”,生成的 `policy.onnx` 会进入现有 ONNX 加载流程。普通训练还可以选择自调参产生的命名 reward preset,而不会改写仓库默认配置。
连接服务后点击“打开自调参 Agent 工作台”会打开独立 `tuning.html`。该页面采用 Cyber-Industrial 三栏控制台:左侧展示 Session/ASHA 晋级树和 Trial 对比选择,中间使用 uPlot 叠加多 Trial 增量收敛曲线(金线标记历史最优)及六维物理评分,右侧以可折叠因果时间线展示 rationale、expected impact、置信度和评估结果。工具栏支持自动/逐轮审批切换、一次一 Trial 的调度令牌、服务端参数范围/固定值护栏、回滚历史最优或复现任意安全 Trial;Reward Merge Patch 与回滚差异由按需加载的 Monaco Diff 审查。scalar 以 1 Hz 非重入方式增量轮询,进入固定容量环形缓冲并由 `requestAnimationFrame` 合批后调用 `uPlot.setData`,不会在每次轮询时重建图表。新标签页 URL 不包含 token;同源 opener 会一次性交接凭据,直接打开页面时也可手工输入。DeepSeek key 始终由本地 Python 服务的 `DEEPSEEK_API_KEY` 环境变量读取,浏览器不会接触该 key。
桥接服务只监听本机回环地址,并检查 Host、Origin 和 Bearer Token;仅接受允许列表中的任务和经过范围校验的参数,不执行前端提供的 Shell 命令;一次只运行一个训练进程。默认任务使用仓库内置的 Go2 机器人资产与环境配置,**不会自动把浏览器中临时编辑的 MJCF/URDF 作为训练环境**。自定义浏览器模型训练需要在兼容的外部训练工程中注册 task,并通过服务的 `--trainer-root` 指定该工程。服务配置、接口和安全边界见 [`../training_server/README.md`](../training_server/README.md)。 桥接服务只监听本机回环地址,并检查 Host、Origin 和 Bearer Token;仅接受允许列表中的任务和经过范围校验的参数,不执行前端提供的 Shell 命令;一次只运行一个训练进程。默认任务使用仓库内置的 Go2 机器人资产与环境配置,**不会自动把浏览器中临时编辑的 MJCF/URDF 作为训练环境**。自定义浏览器模型训练需要在兼容的外部训练工程中注册 task,并通过服务的 `--trainer-root` 指定该工程。服务配置、接口和安全边界见 [`../training_server/README.md`](../training_server/README.md)。
+337 -46
View File
@@ -77,6 +77,14 @@ const LARGE_MODEL = `
</worldbody> </worldbody>
</mujoco>`; </mujoco>`;
test('独立自调参工作台不需要加载 MuJoCo 主应用即可打开', async ({ page }) => {
await page.goto('/tuning.html');
await expect(page.getByRole('heading', { name: 'Go2 奖励函数自调参 Agent' })).toBeVisible();
await expect(page.getByText('新建 Unitree-Go2-Flat 调参 Session')).toBeVisible();
await expect(page.getByLabel('访问令牌(仅当前标签页)')).toBeVisible();
await expect(page.getByRole('button', { name: '启动自调参' })).toBeVisible();
});
test('显示中文平台骨架并加载单文件模型', async ({ page }) => { test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
page.on('console', (message) => console.log(`[browser:${message.type()}] ${message.text()}`)); page.on('console', (message) => console.log(`[browser:${message.type()}] ${message.text()}`));
page.on('pageerror', (error) => console.log(`[browser:error] ${error.message}`)); page.on('pageerror', (error) => console.log(`[browser:error] ${error.message}`));
@@ -137,6 +145,7 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
}); });
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await page.getByRole('button', { name: '显示设置' }).click(); await page.getByRole('button', { name: '显示设置' }).click();
const displayDialog = page.getByRole('dialog', { name: '视图显示设置' }); const displayDialog = page.getByRole('dialog', { name: '视图显示设置' });
await expect(displayDialog).toBeVisible(); await expect(displayDialog).toBeVisible();
@@ -154,16 +163,22 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
await page.getByRole('button', { name: /FPS .*物理/ }).click(); await page.getByRole('button', { name: /FPS .*物理/ }).click();
await expect(page.getByRole('dialog', { name: '性能详情' })).toBeVisible(); await expect(page.getByRole('dialog', { name: '性能详情' })).toBeVisible();
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await page.getByRole('tab', { name: '控制' }).click(); await page.getByRole('tab', { name: '控制台' }).click();
await page.getByRole('button', { name: 'Actuator' }).click(); const tools = page.getByRole('tabpanel', { name: '控制台' });
await expect(page.getByText('motor', { exact: true })).toBeVisible(); await expect(page.getByRole('dialog', { name: '工作区工具' })).toHaveCount(0);
await expect( await expect(page.locator('main canvas')).toBeVisible();
page.getByRole('tabpanel', { name: '控制' }).getByText('slide', { exact: true }), await tools.getByRole('button', { name: /执行器实时控制/ }).click();
).toBeVisible(); await expect(tools.getByText('motor', { exact: true })).toBeVisible();
await page.getByRole('tab', { name: '模型结构' }).click(); await expect(tools.getByText('关节:slide', { exact: true })).toBeVisible();
await page.getByRole('tab', { name: '数据录制' }).click();
await expect(page.getByRole('tabpanel', { name: '数据录制' })).toContainText('仿真遥测记录');
await expect(page.locator('main canvas')).toBeVisible();
await page.getByRole('tab', { name: '检查器' }).click();
const structure = page.getByRole('navigation', { name: '模型结构树' }); const structure = page.getByRole('navigation', { name: '模型结构树' });
await expect(structure).toBeVisible(); await expect(structure).toBeVisible();
await structure.getByRole('treeitem', { name: /hinge/ }).hover(); const hinge = structure.getByRole('treeitem', { name: /hinge/ });
await hinge.hover();
await hinge.click();
await expect(page.getByRole('alert')).toHaveCount(0); await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.getByRole('button', { name: '重置关节' })).toBeVisible(); await expect(page.getByRole('button', { name: '重置关节' })).toBeVisible();
await page.getByRole('button', { name: '高级' }).click(); await page.getByRole('button', { name: '高级' }).click();
@@ -187,10 +202,10 @@ test('窄视口默认保留完整视口并可按需打开侧栏', async ({ page
await page.goto('/'); await page.goto('/');
await expect(page.getByRole('main')).toBeInViewport(); await expect(page.getByRole('main')).toBeInViewport();
await expect(page.getByRole('button', { name: '显示工程面板' })).toBeVisible(); await expect(page.getByRole('button', { name: '显示工程面板' })).toBeVisible();
await expect(page.getByRole('button', { name: '显示属性面板' })).toBeVisible(); await expect(page.getByRole('button', { name: '显示右侧面板' })).toBeVisible();
await page.getByRole('button', { name: '显示属性面板' }).click(); await page.getByRole('button', { name: '显示右侧面板' }).click();
await expect( await expect(
page.getByRole('complementary').filter({ hasText: '导入模型后显示属性' }), page.getByRole('complementary').filter({ hasText: '导入模型后显示检查器' }),
).toBeVisible(); ).toBeVisible();
}); });
@@ -288,7 +303,6 @@ test('加载引用 OBJ 的 URDF 工程', async ({ page }) => {
await options.getByRole('button', { name: '转换并加载' }).click(); await options.getByRole('button', { name: '转换并加载' }).click();
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('2 个文件')).toBeVisible(); await expect(page.getByText('2 个文件')).toBeVisible();
await page.getByRole('button', { name: 'URDF 处理方式' }).click();
await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf'); await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf');
await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating'); await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating');
await page.getByRole('button', { name: '通知中心' }).click(); await page.getByRole('button', { name: '通知中心' }).click();
@@ -336,8 +350,11 @@ test('URDF 自动生成的关节驱动器与摄像头可通过 MuJoCo 编译', a
await expect(notifications).toContainText('已为 1 个 hinge/slide 关节生成 motor 驱动器'); await expect(notifications).toContainText('已为 1 个 hinge/slide 关节生成 motor 驱动器');
await expect(notifications).toContainText('已将 640×480 摄像头固连到 arm'); await expect(notifications).toContainText('已将 640×480 摄像头固连到 arm');
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await page.getByRole('tab', { name: '控制' }).click(); await page.getByRole('tab', { name: '控制台' }).click();
await page.getByRole('button', { name: 'Actuator' }).click(); await page
.getByRole('tabpanel', { name: '控制台' })
.getByRole('button', { name: /执行器实时控制/ })
.click();
await expect(page.getByText('shoulder_motor')).toBeVisible(); await expect(page.getByText('shoulder_motor')).toBeVisible();
await expect(page.getByText('关节:shoulder')).toBeVisible(); await expect(page.getByText('关节:shoulder')).toBeVisible();
await expect(page.getByText('N·m', { exact: true })).toBeVisible(); await expect(page.getByText('N·m', { exact: true })).toBeVisible();
@@ -405,8 +422,10 @@ test('slide 关节向屏幕轴正方向拖动时 qpos 同向增加', async ({ pa
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(x + 70, y, { steps: 8 }); await page.mouse.move(x + 70, y, { steps: 8 });
await page.mouse.up(); await page.mouse.up();
await page.getByRole('tab', { name: '控制' }).click(); await page.getByRole('tab', { name: '控制台' }).click();
const jointSection = page.getByRole('button', { name: '关节 1' }); const jointSection = page
.getByRole('tabpanel', { name: '控制台' })
.getByRole('button', { name: /关节姿态调试/ });
if ((await jointSection.getAttribute('aria-expanded')) === 'false') await jointSection.click(); if ((await jointSection.getAttribute('aria-expanded')) === 'false') await jointSection.click();
const output = page.getByText('screen_x').locator('..').locator('output'); const output = page.getByText('screen_x').locator('..').locator('output');
await expect await expect
@@ -421,7 +440,11 @@ test('可导入并启用 Python 控制器', async ({ page }) => {
.first() .first()
.setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) }); .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('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '控制' }).click(); await page.getByRole('tab', { name: '控制台' }).click();
await page
.getByRole('tabpanel', { name: '控制台' })
.getByRole('button', { name: /Python 脚本控制/ })
.click();
const python = `NAME = "测试 PD 控制器"\nCONTROL_HZ = 100\ndef init(api):\n return {"joint": api.joint("slide"), "actuator": api.actuator("motor"), "body": api.body("box")}\ndef step(ctx, state):\n assert len(ctx.body_quat(state["body"])) == 4\n assert len(ctx.body_position(state["body"])) == 3\n ctx.set_control(state["actuator"], -ctx.qpos(state["joint"]) - 0.1 * ctx.qvel(state["joint"]))\n`; const python = `NAME = "测试 PD 控制器"\nCONTROL_HZ = 100\ndef init(api):\n return {"joint": api.joint("slide"), "actuator": api.actuator("motor"), "body": api.body("box")}\ndef step(ctx, state):\n assert len(ctx.body_quat(state["body"])) == 4\n assert len(ctx.body_position(state["body"])) == 3\n ctx.set_control(state["actuator"], -ctx.qpos(state["joint"]) - 0.1 * ctx.qvel(state["joint"]))\n`;
await page await page
.locator('input[accept=".py,text/x-python"]') .locator('input[accept=".py,text/x-python"]')
@@ -429,7 +452,9 @@ test('可导入并启用 Python 控制器', async ({ page }) => {
await expect(page.getByText('测试 PD 控制器', { exact: true })).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('测试 PD 控制器', { exact: true })).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('Python / Pyodide')).toBeVisible(); await expect(page.getByText('Python / Pyodide')).toBeVisible();
await page.getByRole('button', { name: '启用', exact: true }).click(); await page.getByRole('button', { name: '启用', exact: true }).click();
await expect(page.getByText('运行中')).toBeVisible(); await expect(
page.getByRole('tabpanel', { name: '控制台' }).getByRole('button', { name: /Python 脚本控制/ }),
).toContainText('运行');
}); });
test('中等规模模型持续步进并可重复加载', async ({ page }) => { test('中等规模模型持续步进并可重复加载', async ({ page }) => {
@@ -471,16 +496,15 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page })
buffer: Buffer.from(SIMPLE_MODEL), buffer: Buffer.from(SIMPLE_MODEL),
}); });
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
const library = page.getByLabel('认证资产'); const library = page.getByLabel('地图资产库');
await expect(library.getByText('点击添加,或按住资产拖到画布落位。')).toBeVisible(); await expect(library.getByText('物理几何原语', { exact: true })).toBeVisible();
await library.getByRole('button', { name: '添加基础方盒' }).click(); await library.getByRole('button', { name: '添加基础方盒' }).click();
await expect(page.getByText('正在加载 MuJoCo 与模型…')).toHaveCount(0); await expect(page.getByText('正在加载 MuJoCo 与模型…')).toHaveCount(0);
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json', { await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json', {
timeout: 30_000, timeout: 30_000,
}); });
await expect(page.getByText('V3 地图编辑器')).toBeVisible(); await expect(page.getByLabel('地图物体检查器')).toBeVisible();
await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible(); await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
await page.locator('[data-map-asset="ramp"]').dragTo(page.locator('main canvas')); await page.locator('[data-map-asset="ramp"]').dragTo(page.locator('main canvas'));
@@ -488,9 +512,9 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page })
await expect(page.getByText('地图草稿尚未应用')).toBeVisible(); await expect(page.getByText('地图草稿尚未应用')).toBeVisible();
await page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒').click(); await page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒').click();
await page.getByLabel('对象放置方式').selectOption('locked'); await page.getByLabel('贴地检测模式').selectOption('locked');
await expect(page.getByLabel('对象位置X')).toBeDisabled(); await expect(page.getByLabel('对象位置X')).toBeDisabled();
await page.getByLabel('对象放置方式').selectOption('auto_ground'); await page.getByLabel('贴地检测模式').selectOption('auto_ground');
await expect(page.getByLabel('对象位置X')).toBeEnabled(); await expect(page.getByLabel('对象位置X')).toBeEnabled();
const gizmoLine = page.getByRole('img', { name: 'XYZ 方向指示器' }).locator('line').first(); const gizmoLine = page.getByRole('img', { name: 'XYZ 方向指示器' }).locator('line').first();
@@ -502,6 +526,236 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page })
await page.mouse.move(canvasBox!.x + 104, canvasBox!.y + 50, { steps: 8 }); await page.mouse.move(canvasBox!.x + 104, canvasBox!.y + 50, { steps: 8 });
await page.mouse.up({ button: 'left' }); await page.mouse.up({ button: 'left' });
await expect.poll(() => gizmoLine.getAttribute('x2')).not.toBe(beforeRotation); await expect.poll(() => gizmoLine.getAttribute('x2')).not.toBe(beforeRotation);
await expect(page.getByLabel('对象名称')).toHaveCount(0);
await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
});
test('认证资产自动打开地图属性并与参数地形一次编译', async ({ page }) => {
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'unified-map-model.xml',
mimeType: 'text/xml',
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
const library = page.getByLabel('地图资产库');
await library.getByRole('button', { name: '添加基础方盒' }).click();
await expect(page.getByText('Map / Object')).toBeVisible();
await expect(page.getByLabel('地图物体检查器')).toBeVisible();
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
const sceneTree = page.getByLabel('场景资产树');
await expect(sceneTree.getByText('基础方盒')).toBeVisible();
await sceneTree.getByRole('treeitem', { name: 'box', exact: true }).click();
await expect(page.getByText('Robot / Body')).toBeVisible();
await sceneTree.getByRole('treeitem', { name: /基础方盒/ }).click();
await expect(page.getByText('Map / Object')).toBeVisible();
await library.getByRole('button', { name: '添加随机粗糙地形' }).click();
await page.getByLabel('位置 X(m)').fill('4');
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
// 切换到参数地形后,尚未编译的认证资产仍保留在画布中并可直接选回。
const draftCanvasBox = await page.locator('main canvas').first().boundingBox();
expect(draftCanvasBox).not.toBeNull();
let selectedDraftAsset = false;
for (let y = 0.15; y <= 0.85 && !selectedDraftAsset; y += 0.05) {
for (let x = 0.05; x <= 0.95; x += 0.05) {
await page.mouse.click(
draftCanvasBox!.x + draftCanvasBox!.width * x,
draftCanvasBox!.y + draftCanvasBox!.height * y,
);
await page.waitForTimeout(20);
if (await page.getByLabel('对象名称').count()) {
selectedDraftAsset = true;
break;
}
}
}
expect(selectedDraftAsset).toBe(true);
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json');
await sceneTree.getByRole('treeitem', { name: /随机粗糙地形/ }).click();
await expect(page.getByLabel('地图来源')).toHaveValue('builtin');
await page.getByRole('button', { name: '应用场景' }).click();
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
await expect(page.getByText(/已加载工程地图“场景 1”(1 个物理几何/)).toBeVisible();
await expect(page.getByText(/已加载随机粗糙地形物理地图/)).toBeVisible();
await expect(page.getByLabel('地图来源')).toHaveValue('builtin');
// 当前仍选中参数地形时,直接点已编译的认证资产也必须反查场景与对象,首次点击即挂载操纵器。
const canvasBox = await page.locator('main canvas').first().boundingBox();
expect(canvasBox).not.toBeNull();
let selectedCompiledAsset = false;
for (let y = 0.15; y <= 0.85 && !selectedCompiledAsset; y += 0.05) {
for (let x = 0.05; x <= 0.95; x += 0.05) {
await page.mouse.click(
canvasBox!.x + canvasBox!.width * x,
canvasBox!.y + canvasBox!.height * y,
);
await page.waitForTimeout(20);
if (await page.getByLabel('对象名称').count()) {
selectedCompiledAsset = true;
break;
}
}
}
expect(selectedCompiledAsset).toBe(true);
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json');
await expect(page.getByLabel('对象名称')).toHaveValue('基础方盒');
await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
await expect(page.getByText('地图草稿尚未应用')).toHaveCount(0);
});
test('放弃首次认证资产会完整回滚临时场景和工程文件', async ({ page }) => {
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'discard-map-model.xml',
mimeType: 'text/xml',
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
const library = page.getByLabel('地图资产库');
await library.getByRole('button', { name: '添加基础方盒' }).click();
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
await expect(page.getByLabel('场景资产树')).toContainText('场景 1');
await page.getByRole('button', { name: '放弃更改' }).click();
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
await expect(page.getByLabel('场景资产树')).not.toContainText('场景 1');
await expect(library.getByText('场景 1')).toHaveCount(0);
// 文件和 map id 也必须回滚;再次创建应复用 scene_1,而不是泄漏出 scene_2。
await library.getByRole('button', { name: '添加基础方盒' }).click();
await expect(page.getByLabel('场景资产树')).toContainText('场景 1');
await expect(page.getByLabel('场景资产树')).not.toContainText('场景 2');
});
test('工程地图与参数地形共享放置草稿、实例变换和回滚入口', async ({ page }) => {
const mapJson = JSON.stringify({
schemaVersion: 1,
id: 'warehouse-draft',
name: '草稿仓库',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'physics/world.xml' },
spawnPoints: [],
});
const project = zipSync({
'model.xml': Buffer.from(SIMPLE_MODEL),
'maps/warehouse/map.json': Buffer.from(mapJson),
'maps/warehouse/physics/world.xml': Buffer.from(
'<mujoco><worldbody><geom name="wall" type="box" pos="0 0 .5" size=".1 1 .5"/></worldbody></mujoco>',
),
});
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'unified-project-map.zip',
mimeType: 'application/zip',
buffer: Buffer.from(project),
});
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
const library = page.getByLabel('地图资产库');
await library.getByRole('button', { name: '放置工程地图 草稿仓库' }).click();
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
await expect(page.getByLabel('场景资产树')).toContainText('待应用');
await expect(page.getByText('正在准备仿真')).toHaveCount(0);
await page.getByLabel('位置 X(m)').fill('3');
await page.getByLabel('位置 Y(m)').fill('-2');
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
await library.getByRole('button', { name: '添加波浪地形' }).click();
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
await page.getByRole('button', { name: '应用场景' }).click();
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
await expect(page.getByText(/已加载工程地图“草稿仓库”/)).toBeVisible();
await expect(page.getByText(/已加载波浪地形物理地图/)).toBeVisible();
await page.getByRole('button', { name: '删除地图实例 草稿仓库' }).click();
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
await expect(page.getByLabel('场景资产树')).not.toContainText('草稿仓库');
await page.getByRole('button', { name: '放弃更改' }).click();
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
await expect(page.getByLabel('场景资产树')).toContainText('草稿仓库');
});
test('认证资产重力放置使用参数地形的真实承载高度', async ({ page }) => {
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'terrain-surface-model.xml',
mimeType: 'text/xml',
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
const library = page.getByLabel('地图资产库');
await library.getByRole('button', { name: '添加深坑地形' }).click();
await page.getByLabel('地形难度(0–1)').fill('1');
await library.getByLabel('新增资产放置方式').selectOption('gravity');
await library.getByRole('button', { name: '添加基础方盒' }).click();
await expect(page.getByLabel('贴地检测模式')).toHaveValue('gravity');
await expect(page.getByLabel('对象位置Z')).toHaveValue('-0.3');
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
});
test('参数化地形可连续拖到画布并一次性编译', async ({ page }) => {
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'terrain-draft-model.xml',
mimeType: 'text/xml',
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
await page.locator('[data-system-terrain="rough"]').dragTo(page.locator('main canvas'));
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
await expect(page.getByText('正在准备仿真')).toHaveCount(0);
await page.locator('[data-system-terrain="wave"]').dragTo(page.locator('main canvas'));
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
await expect(page.getByLabel('场景资产树')).toContainText('随机粗糙地形');
await expect(page.getByLabel('场景资产树')).toContainText('波浪地形');
await page.getByLabel('场景资产树').getByRole('treeitem', { name: 'box' }).click();
await expect(page.getByText('Robot / Body')).toBeVisible();
const canvasBox = await page.locator('main canvas').first().boundingBox();
expect(canvasBox).not.toBeNull();
await page.mouse.click(
canvasBox!.x + canvasBox!.width * 0.5,
canvasBox!.y + canvasBox!.height * 0.78,
);
await expect(page.getByText('Map / Instance')).toBeVisible();
await expect(page.getByLabel('地图视口工具')).toBeVisible();
await expect(page.getByText('地图与对象属性')).toBeVisible();
await page.getByRole('button', { name: '应用场景' }).click();
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
await expect(page.getByText(/已加载随机粗糙地形物理地图/)).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText(/已加载波浪地形物理地图/)).toBeVisible();
}); });
test('应用内置 MJCF 楼梯物理地图', async ({ page }) => { test('应用内置 MJCF 楼梯物理地图', async ({ page }) => {
@@ -516,7 +770,6 @@ test('应用内置 MJCF 楼梯物理地图', async ({ page }) => {
buffer: Buffer.from(SIMPLE_MODEL), buffer: Buffer.from(SIMPLE_MODEL),
}); });
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption('builtin'); await page.getByLabel('地图来源').selectOption('builtin');
await page.getByLabel('物理地图预设').selectOption('stairs'); await page.getByLabel('物理地图预设').selectOption('stairs');
await page.getByLabel('台阶数量').fill('6'); await page.getByLabel('台阶数量').fill('6');
@@ -548,7 +801,6 @@ test('依次应用全部系统参数化地形', async ({ page }) => {
buffer: Buffer.from(SIMPLE_MODEL), buffer: Buffer.from(SIMPLE_MODEL),
}); });
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption('builtin'); await page.getByLabel('地图来源').selectOption('builtin');
for (const [preset, label] of terrains) { for (const [preset, label] of terrains) {
await page.getByLabel('物理地图预设').selectOption(preset); await page.getByLabel('物理地图预设').selectOption(preset);
@@ -592,7 +844,6 @@ test('导入并应用分层工程地图包', async ({ page }) => {
buffer: Buffer.from(project), buffer: Buffer.from(project),
}); });
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption({ label: '测试场景' }); await page.getByLabel('地图来源').selectOption({ label: '测试场景' });
await expect(page.getByLabel('地图出生点')).toHaveValue('start'); await expect(page.getByLabel('地图出生点')).toHaveValue('start');
await page.getByRole('button', { name: '应用并重新编译' }).click(); await page.getByRole('button', { name: '应用并重新编译' }).click();
@@ -627,14 +878,15 @@ test('将受支持的只读物理地图转换为可编辑副本', async ({ page
buffer: Buffer.from(project), buffer: Buffer.from(project),
}); });
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption({ label: '旧版基础场景' }); await page.getByLabel('地图来源').selectOption({ label: '旧版基础场景' });
await page.getByRole('button', { name: '应用并重新编译' }).click(); await page.getByRole('button', { name: '应用并重新编译' }).click();
const editor = page.getByText('V3 地图编辑器').locator('..'); const editor = page.getByText('认证资产与场景对象属性').locator('..');
await expect(editor.getByText(/保持只读/)).toBeVisible({ timeout: 30_000 }); await expect(editor.getByText(/保持只读/)).toBeVisible({ timeout: 30_000 });
await editor.getByRole('button', { name: '创建可编辑副本' }).click(); await editor.getByRole('button', { name: '创建可编辑副本' }).click();
await expect(page.getByText('已创建可编辑地图副本')).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('已创建可编辑地图副本')).toBeVisible({ timeout: 30_000 });
await expect(editor.getByRole('button', { name: '移动工具 W' })).toBeVisible(); await expect(
page.getByLabel('地图视口工具').getByRole('button', { name: '移动工具 W' }),
).toBeVisible();
await expect(editor.getByRole('button', { name: /floor · 方盒/ })).toBeVisible(); await expect(editor.getByRole('button', { name: /floor · 方盒/ })).toBeVisible();
await expect(editor.getByRole('button', { name: /wall · 方盒/ })).toBeVisible(); await expect(editor.getByRole('button', { name: /wall · 方盒/ })).toBeVisible();
}); });
@@ -673,30 +925,56 @@ test('编辑 V3 地图对象并事务式应用', async ({ page }) => {
buffer: Buffer.from(project), buffer: Buffer.from(project),
}); });
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption({ label: '可编辑场景' }); await page.getByLabel('地图来源').selectOption({ label: '可编辑场景' });
await page.getByRole('button', { name: '应用并重新编译' }).click(); await page.getByRole('button', { name: '应用并重新编译' }).click();
await expect(page.getByText('V3 地图编辑器')).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('认证资产与场景对象属性')).toBeVisible({ timeout: 30_000 });
const editor = page.getByText('V3 地图编辑器').locator('..'); const mapTools = page.getByLabel('地图视口工具');
await expect(editor.getByRole('button', { name: '移动工具 W' })).toHaveAttribute( await expect(mapTools.getByRole('button', { name: '移动工具 W' })).toHaveAttribute(
'aria-pressed', 'aria-pressed',
'true', 'true',
); );
await page.keyboard.press('e'); await page.keyboard.press('e');
await expect(editor.getByRole('button', { name: '旋转工具 E' })).toHaveAttribute( await expect(mapTools.getByRole('button', { name: '旋转工具 E' })).toHaveAttribute(
'aria-pressed', 'aria-pressed',
'true', 'true',
); );
await page.keyboard.press('s'); await page.keyboard.press('r');
await expect(editor.getByRole('button', { name: '缩放工具 S' })).toHaveAttribute( await expect(mapTools.getByRole('button', { name: '缩放工具 R' })).toHaveAttribute(
'aria-pressed', 'aria-pressed',
'true', 'true',
); );
await editor.getByRole('button', { name: '新增', exact: true }).click(); await page.getByRole('button', { name: '新增', exact: true }).click();
await editor.getByLabel('对象位置X').fill('2'); await page.getByLabel('对象位置X').fill('2');
await editor.getByRole('button', { name: '应用并重新编译' }).click(); await page.getByLabel('对象位置X').blur();
await expect(editor.getByRole('button', { name: /box · 方盒/ })).toBeVisible({ timeout: 30_000 }); await page.keyboard.press('f');
await expect(editor.getByText('地图草稿尚未应用')).toHaveCount(0); const draftStatus = page.getByLabel('地图草稿状态');
await expect(draftStatus).toContainText('未保存改动');
await page.keyboard.press('Control+s');
await expect(
page.getByLabel('地图对象列表').getByRole('button', { name: /box · 方盒/ }),
).toBeVisible({
timeout: 30_000,
});
await expect(draftStatus).toContainText('地图草稿已同步');
await page
.getByLabel('地图对象列表')
.getByRole('button', { name: /box · 方盒/ })
.click();
await page.getByLabel('对象位置X').fill('3');
await expect(draftStatus).toContainText('未保存改动');
await draftStatus.getByRole('button', { name: '丢弃地图草稿' }).click();
await page
.getByLabel('地图对象列表')
.getByRole('button', { name: /box · 方盒/ })
.click();
await expect(page.getByLabel('对象位置X')).toHaveValue('2');
await expect(draftStatus).toContainText('地图草稿已同步');
await page.getByRole('button', { name: '新增', exact: true }).click();
await expect(page.getByLabel('地图对象列表').getByRole('button')).toHaveCount(2);
await page.keyboard.press('Delete');
await expect(page.getByLabel('地图对象列表').getByRole('button')).toHaveCount(1);
await expect(page.getByText('WASM 已加载')).toBeVisible(); await expect(page.getByText('WASM 已加载')).toBeVisible();
}); });
@@ -727,15 +1005,28 @@ test('工程地图编译失败时保留上一仿真会话', async ({ page }) =>
buffer: Buffer.from(project), buffer: Buffer.from(project),
}); });
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click(); await page.getByRole('button', { name: '▶ 播放' }).click();
await page.waitForTimeout(200);
const runningTime = Number(
(await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0,
);
await page.getByLabel('地图来源').selectOption({ label: '动态错误地图' }); await page.getByLabel('地图来源').selectOption({ label: '动态错误地图' });
await page.getByRole('button', { name: '应用并重新编译' }).click(); await page.getByRole('button', { name: '应用并重新编译' }).click();
await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 }); await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 });
await expect(page.getByLabel('地图来源')).toHaveValue('none'); await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/invalid/map.json');
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
await expect(page.getByLabel('场景资产树')).toContainText('待应用');
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible();
await page.getByRole('button', { name: '关闭错误' }).click(); await page.getByRole('button', { name: '关闭错误' }).click();
await page.getByRole('button', { name: '▶ 播放' }).click(); await page.getByRole('button', { name: '▶ 播放' }).click();
await page.waitForTimeout(300); await expect
await expect(page.locator('footer')).not.toContainText('时间 0.000 s'); .poll(async () =>
Number((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0),
)
.toBeGreaterThan(runningTime);
await page.getByRole('button', { name: '放弃更改' }).click();
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
await expect(page.getByLabel('地图来源')).toHaveValue('none');
}); });
test('无效模型显示中文诊断且保留工程树', async ({ page }) => { test('无效模型显示中文诊断且保留工程树', async ({ page }) => {
+1053 -185
View File
File diff suppressed because it is too large Load Diff
@@ -43,7 +43,7 @@ export function LayoutSettingsDialog({
onClick={() => onRightOpen(!rightOpen)} onClick={() => onRightOpen(!rightOpen)}
icon={<PanelRight className="h-3.5 w-3.5" />} icon={<PanelRight className="h-3.5 w-3.5" />}
> >
属性面板 右侧面板
</Button> </Button>
</div> </div>
<h3 className="mb-2 mt-4 text-xs font-semibold">布局预设</h3> <h3 className="mb-2 mt-4 text-xs font-semibold">布局预设</h3>
@@ -0,0 +1,99 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MapDraftStatusOverlay, MapViewportToolbar } from './MapViewportTools';
const noop = () => {};
describe('MapViewportToolbar', () => {
it('统一联动变换、吸附与贴地检测命令', () => {
const onModeChange = vi.fn();
const onSnappingChange = vi.fn();
const onPlacementModeChange = vi.fn();
const onAlignToSurface = vi.fn();
render(
<MapViewportToolbar
visible
interactionActive
mode="translate"
snapping
placementMode="auto_ground"
hasSelectedObject
allowScale
loading={false}
onActivate={noop}
onModeChange={onModeChange}
onSnappingChange={onSnappingChange}
onPlacementModeChange={onPlacementModeChange}
onAlignToSurface={onAlignToSurface}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '旋转工具 E' }));
fireEvent.click(screen.getByRole('button', { name: '网格吸附' }));
fireEvent.change(screen.getByLabelText('贴地检测模式'), {
target: { value: 'gravity' },
});
fireEvent.click(screen.getByRole('button', { name: '立即贴合承载面' }));
expect(onModeChange).toHaveBeenCalledWith('rotate');
expect(onSnappingChange).toHaveBeenCalledWith(false);
expect(onPlacementModeChange).toHaveBeenCalledWith('gravity');
expect(onAlignToSurface).toHaveBeenCalledOnce();
});
it('参数地形或未选中对象时不提供缩放与贴合动作', () => {
render(
<MapViewportToolbar
visible
interactionActive
mode="translate"
snapping={false}
placementMode="auto_ground"
hasSelectedObject={false}
allowScale={false}
loading={false}
onActivate={noop}
onModeChange={noop}
onSnappingChange={noop}
onPlacementModeChange={noop}
onAlignToSurface={noop}
/>,
);
expect(screen.queryByRole('button', { name: '缩放工具 R' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '立即贴合承载面' })).toBeDisabled();
});
});
describe('MapDraftStatusOverlay', () => {
it('常驻显示未保存数量并提供提交与丢弃入口', () => {
const onCommit = vi.fn();
const onDiscard = vi.fn();
render(
<MapDraftStatusOverlay
visible
changeCount={3}
loading={false}
onCommit={onCommit}
onDiscard={onDiscard}
/>,
);
expect(screen.getByText('3 项未保存改动')).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '提交地图草稿' }));
fireEvent.click(screen.getByRole('button', { name: '丢弃地图草稿' }));
expect(onCommit).toHaveBeenCalledOnce();
expect(onDiscard).toHaveBeenCalledOnce();
});
it('无草稿时显示已同步并禁用动作', () => {
render(
<MapDraftStatusOverlay
visible
changeCount={0}
loading={false}
onCommit={noop}
onDiscard={noop}
/>,
);
expect(screen.getByText('地图草稿已同步')).toBeVisible();
expect(screen.getByRole('button', { name: '提交地图草稿' })).toBeDisabled();
});
});
@@ -0,0 +1,202 @@
import {
ArrowDownToLine,
Check,
Grid3X3,
LockKeyhole,
MapPinned,
Move3d,
Rotate3d,
Scaling,
Trash2,
} from 'lucide-react';
import {
Button,
IconButton,
Select,
ToolbarToggleGroup,
type ToolbarItem,
} from '../../components/ui';
import {
MAP_OBJECT_PLACEMENT_LABELS,
type MapEditorTransformMode,
type MapObjectPlacementMode,
} from '../../map/editor/types';
const TRANSFORM_TOOLS: ToolbarItem<MapEditorTransformMode>[] = [
{ value: 'translate', label: '移动工具 W', icon: Move3d },
{ value: 'rotate', label: '旋转工具 E', icon: Rotate3d },
{ value: 'scale', label: '缩放工具 R', icon: Scaling },
];
export function MapViewportToolbar({
visible,
interactionActive,
mode,
snapping,
placementMode,
hasSelectedObject,
allowScale,
loading,
onActivate,
onModeChange,
onSnappingChange,
onPlacementModeChange,
onAlignToSurface,
}: {
visible: boolean;
interactionActive: boolean;
mode: MapEditorTransformMode;
snapping: boolean;
placementMode: MapObjectPlacementMode;
hasSelectedObject: boolean;
allowScale: boolean;
loading: boolean;
onActivate: () => void;
onModeChange: (mode: MapEditorTransformMode) => void;
onSnappingChange: (value: boolean) => void;
onPlacementModeChange: (mode: MapObjectPlacementMode) => void;
onAlignToSurface: () => void;
}) {
if (!visible) return null;
return (
<div
aria-label="地图视口工具"
className="engineering-glass map-tool-enter absolute left-1/2 top-3 z-20 flex max-w-[calc(100%-24px)] -translate-x-1/2 items-center gap-1.5 rounded-xl border p-1.5"
>
<button
type="button"
aria-label="激活地图编辑"
aria-pressed={interactionActive}
disabled={loading}
onClick={onActivate}
className={`hidden h-8 items-center gap-1.5 rounded-lg px-2 text-[10px] font-semibold transition-colors sm:flex ${interactionActive ? 'bg-accent-soft text-accent' : 'bg-surface/80 text-text-secondary hover:bg-element-hover'}`}
>
<MapPinned className="h-3.5 w-3.5" aria-hidden="true" />
地图编辑
</button>
<ToolbarToggleGroup
items={TRANSFORM_TOOLS.filter((item) => allowScale || item.value !== 'scale')}
value={mode}
onChange={onModeChange}
label="地图变换模式"
/>
<IconButton
active={snapping}
tooltip={snapping ? '关闭网格吸附(当前 0.1 m / 5°)' : '开启网格吸附'}
aria-label="网格吸附"
aria-pressed={snapping}
disabled={loading}
onClick={() => onSnappingChange(!snapping)}
>
<Grid3X3 className="h-3.5 w-3.5" />
</IconButton>
<span aria-hidden="true" className="mx-0.5 h-5 w-px bg-border" />
<label className="flex items-center gap-1 text-[10px] text-text-tertiary">
<span className="hidden lg:inline">贴地</span>
<Select
aria-label="贴地检测模式"
className="w-[112px]"
value={placementMode}
disabled={loading}
onChange={(event) => onPlacementModeChange(event.target.value as MapObjectPlacementMode)}
>
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map((value) => (
<option key={value} value={value}>
{value === 'auto_ground' ? '地面 z=0' : value === 'gravity' ? '表面检测' : '锁定位姿'}
</option>
))}
</Select>
</label>
<IconButton
tooltip={hasSelectedObject ? '重新检测并贴合承载面' : '选中地图物体后可贴合表面'}
aria-label="立即贴合承载面"
disabled={loading || !hasSelectedObject || placementMode === 'locked'}
onClick={onAlignToSurface}
>
{placementMode === 'locked' ? (
<LockKeyhole className="h-3.5 w-3.5" />
) : (
<ArrowDownToLine className="h-3.5 w-3.5" />
)}
</IconButton>
</div>
);
}
export function MapDraftStatusOverlay({
visible,
changeCount,
loading,
onCommit,
onDiscard,
}: {
visible: boolean;
changeCount: number;
loading: boolean;
onCommit: () => void;
onDiscard: () => void;
}) {
if (!visible) return null;
const dirty = changeCount > 0;
return (
<div
aria-label="地图草稿状态"
role="status"
className="engineering-glass absolute bottom-3 left-1/2 z-20 flex max-w-[calc(100%-24px)] -translate-x-1/2 items-center gap-2 rounded-xl border px-2 py-1.5"
>
<span
aria-hidden="true"
className={`h-2 w-2 shrink-0 rounded-full ${dirty ? 'draft-dirty-dot bg-warning' : 'bg-success'}`}
/>
<span className="min-w-0 whitespace-nowrap text-[10px] font-medium text-text-secondary sm:text-[11px]">
{dirty ? `${changeCount} 项未保存改动` : '地图草稿已同步'}
</span>
<Button
variant="ghost"
aria-label="丢弃地图草稿"
disabled={loading || !dirty}
icon={<Trash2 className="h-3 w-3" />}
onClick={onDiscard}
>
<span className="hidden sm:inline">丢弃</span>
</Button>
<Button
variant="primary"
aria-label="提交地图草稿"
disabled={loading || !dirty}
icon={<Check className="h-3 w-3" />}
onClick={onCommit}
>
<span className="hidden sm:inline">提交</span>
</Button>
</div>
);
}
export interface MapAssetDropTarget {
left: number;
top: number;
position: readonly [number, number, number] | null;
}
export function MapAssetDropIndicator({ target }: { target?: MapAssetDropTarget }) {
if (!target) return null;
const valid = Boolean(target.position);
return (
<div className="pointer-events-none absolute inset-0 z-30" aria-label="地图资产放置预览">
<div
className={`absolute -translate-x-1/2 -translate-y-1/2 rounded-full border-2 p-1 shadow-2xl ${valid ? 'border-accent bg-accent/25 text-accent' : 'border-danger bg-danger/20 text-danger'}`}
style={{ left: target.left, top: target.top }}
>
<span className="grid h-8 w-8 place-items-center rounded-full border border-current bg-panel/85 backdrop-blur">
<MapPinned className="h-4 w-4" />
</span>
<span className="absolute left-1/2 top-full mt-2 -translate-x-1/2 whitespace-nowrap rounded-lg border border-border-strong bg-panel/90 px-2 py-1 text-[10px] font-medium shadow-xl backdrop-blur">
{valid
? `释放放置 · ${target.position![0].toFixed(1)}, ${target.position![1].toFixed(1)}`
: '请拖到 3D 地面'}
</span>
</div>
</div>
);
}
@@ -0,0 +1,162 @@
import type { ComponentProps } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { DEFAULT_PHYSICAL_MAP_CONFIG } from '../../map/types';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import { ModelControlsSidebar } from './ModelControlsSidebar';
const snapshot = {
bodies: [
{ id: 0, name: 'world', parentId: 0 },
{ id: 1, name: 'base', parentId: 0 },
{ id: 2, name: 'arm', parentId: 1 },
],
joints: [
{
id: 7,
name: 'arm_joint',
bodyId: 2,
type: 3,
value: 0.25,
min: -1,
max: 1,
limitMin: -1,
limitMax: 1,
limited: true,
limitsIgnored: false,
editable: true,
axis: [0, 0, 1],
},
],
actuators: [],
model: { nbody: 3, njnt: 1, ngeom: 2, ncam: 0, nactuator: 0, nu: 0, nq: 1, nv: 1 },
} as unknown as SimulationSnapshot;
const noop = () => {};
const asyncTrue = async () => true;
function props(
patch: Partial<ComponentProps<typeof ModelControlsSidebar>> = {},
): ComponentProps<typeof ModelControlsSidebar> {
return {
snapshot,
selection: null,
viewerSelection: null,
loading: false,
urdfMode: 'mjcf',
baseMode: 'floating',
showCollision: false,
ignoreJointLimits: false,
jointAdvanced: false,
angleUnit: 'rad',
mapSelection: { kind: 'none' },
mapSceneDirty: false,
maps: [],
showVisualMap: true,
showMapCollision: false,
editorDocument: null,
workspaceTool: null,
onWorkspaceToolChange: noop,
onSelectJoint: noop,
onUrdfMode: noop,
onBaseMode: noop,
onShowCollision: noop,
onResetJoints: noop,
onToggleJointLimits: noop,
onToggleAdvanced: noop,
onToggleAngleUnit: noop,
onActuator: noop,
onActuatorParameters: noop,
onJoint: noop,
onApplyMap: noop,
onMapDraft: noop,
onEditorPreview: noop,
onEditorDraftChange: noop,
onEditorApply: asyncTrue,
onEditorExport: noop,
onEditorConvert: asyncTrue,
onEditorBindInteraction: noop,
onEditorSelect: noop,
onEditorSessionStateChange: noop,
onMapDisplay: noop,
...patch,
};
}
describe('ModelControlsSidebar', () => {
it('没有选择时显示场景摘要,并保留基础地图快速入口', () => {
render(<ModelControlsSidebar {...props()} />);
expect(screen.getByText('未选择对象')).toBeVisible();
expect(screen.getByText('模型摘要')).toBeVisible();
expect(screen.getByText('未选择地图实例')).toBeVisible();
});
it('按照统一选择自动路由 Body 与 Joint 检查器', () => {
const view = render(
<ModelControlsSidebar {...props({ selection: { kind: 'body', bodyId: 2 } })} />,
);
expect(screen.getByText('Robot / Body')).toBeVisible();
expect(screen.getByText('arm', { selector: '[title="arm"]' })).toBeVisible();
view.rerender(
<ModelControlsSidebar {...props({ selection: { kind: 'joint', jointId: 7, bodyId: 2 } })} />,
);
expect(screen.getByText('Robot / Joint')).toBeVisible();
expect(screen.getByText('Hinge · arm')).toBeVisible();
expect(screen.getByText('关联 Actuator')).toBeVisible();
});
it('控制台与数据录制在右侧面板内切换,不创建模态窗口', () => {
const onWorkspaceToolChange = vi.fn();
render(
<ModelControlsSidebar
{...props({
selection: { kind: 'body', bodyId: 2 },
workspaceTool: 'controls',
workspaceTools: (
<div role="tabpanel" aria-label="控制台">
内嵌控制工具
</div>
),
onWorkspaceToolChange,
})}
/>,
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.getByRole('tabpanel', { name: '控制台' })).toBeVisible();
expect(screen.getByText('内嵌控制工具')).toBeVisible();
const inspector = document.querySelector('[role="tabpanel"][aria-label="检查器"]');
expect(inspector).not.toBeNull();
expect(inspector).not.toBeVisible();
const controlsTab = screen.getByRole('tab', { name: '控制台' });
expect(controlsTab).toHaveAttribute('aria-selected', 'true');
fireEvent.keyDown(controlsTab, { key: 'ArrowRight' });
expect(onWorkspaceToolChange).toHaveBeenCalledWith('data');
fireEvent.click(screen.getByRole('tab', { name: '检查器' }));
expect(onWorkspaceToolChange).toHaveBeenCalledWith(null);
});
it('选择地图实例后自动展示地图检查器,并把全局工具移到右侧入口', () => {
const onWorkspaceToolChange = vi.fn();
render(
<ModelControlsSidebar
{...props({
selection: { kind: 'map', mapAssetId: 'terrain-a' },
activeMapAssetId: 'terrain-a',
activeMapAssetName: '随机粗糙地形',
mapSelection: {
kind: 'builtin',
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough' },
},
onWorkspaceToolChange,
})}
/>,
);
expect(screen.getByText('Map / Instance')).toBeVisible();
expect(screen.getByText('随机粗糙地形', { selector: '[title="随机粗糙地形"]' })).toBeVisible();
fireEvent.click(screen.getByRole('tab', { name: '控制台' }));
fireEvent.click(screen.getByRole('tab', { name: '数据录制' }));
expect(onWorkspaceToolChange.mock.calls).toEqual([['controls'], ['data']]);
});
});
@@ -1,38 +1,31 @@
import { useState } from 'react'; import type { ReactNode } from 'react';
import { Database, Info, Map as MapIcon, SlidersHorizontal } from 'lucide-react'; import { Box, Database, MapPinned, PanelRight, SlidersHorizontal } from 'lucide-react';
import type { MapEntry, ModelEntry } from '../../project/types'; import type { MapEntry, ModelEntry } from '../../project/types';
import type { ActuatorParameters, SimulationSnapshot } from '../../simulation/SimulationSession'; import type { ActuatorParameters, SimulationSnapshot } from '../../simulation/SimulationSession';
import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter'; import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter';
import type { DataRecorderConfig } from '../../telemetry/DataRecorder';
import type { ViewerSelection } from '../../viewer/MuJoCoViewer'; import type { ViewerSelection } from '../../viewer/MuJoCoViewer';
import type { ControllerCommand, ControllerStatus } from '../../controller/types'; import type { MapSelection, PlacedMapSelection } from '../../map/types';
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
import {
Badge,
Button,
CollapsibleSection,
CopyButton,
PropertyRow,
Select,
Tabs,
} from '../../components/ui';
import type { MapSelection } from '../../map/types';
import type { import type {
EditableMapDocument, EditableMapDocument,
MapEditorInteractionCallbacks, MapEditorInteractionCallbacks,
MapEditorTransformMode, MapEditorSessionState,
} from '../../map/editor/types'; } from '../../map/editor/types';
import { DataRecordingPanel } from '../../telemetry/DataRecordingPanel';
import { ActuatorControl, Check, ControlSlider } from '../../simulation/ActuatorControl';
import { LocalTrainingPanel } from '../../training/LocalTrainingPanel';
import { PhysicalMapPanel } from '../../map/PhysicalMapPanel'; import { PhysicalMapPanel } from '../../map/PhysicalMapPanel';
import { PythonControllerPanel } from '../../controller/PythonControllerPanel'; import {
import { RLPolicyPanel } from '../../rl/RLPolicyPanel'; BodyInspector,
InspectorIdentity,
JointInspector,
ModelSummaryInspector,
} from '../../simulation/RobotInspector';
import type { EditorSelection } from '../editorSelection';
import { SidebarPanel } from './SidebarPanel'; import { SidebarPanel } from './SidebarPanel';
import type { WorkspaceTool } from './WorkspaceToolsPanel';
import { RightSidebarTabs } from './RightSidebarTabs';
interface ModelControlsProps { interface ModelControlsProps {
snapshot?: SimulationSnapshot; snapshot?: SimulationSnapshot;
selection: ViewerSelection | null; selection: EditorSelection | null;
viewerSelection: ViewerSelection | null;
selectedFormat?: ModelEntry['format']; selectedFormat?: ModelEntry['format'];
loading: boolean; loading: boolean;
visible?: boolean; visible?: boolean;
@@ -42,18 +35,19 @@ interface ModelControlsProps {
ignoreJointLimits: boolean; ignoreJointLimits: boolean;
jointAdvanced: boolean; jointAdvanced: boolean;
angleUnit: 'rad' | 'deg'; angleUnit: 'rad' | 'deg';
forceScale: number;
controllerPaths: string[];
selectedControllerPath?: string;
controllerStatus?: ControllerStatus;
policyPaths: string[];
selectedPolicyPath?: string;
policyStatus?: RLPolicyStatus;
mapSelection: MapSelection; mapSelection: MapSelection;
activeMapAssetId?: string;
activeMapAssetName?: string;
mapSceneDirty: boolean;
maps: MapEntry[]; maps: MapEntry[];
showVisualMap: boolean; showVisualMap: boolean;
showMapCollision: boolean; showMapCollision: boolean;
editorDocument: EditableMapDocument | null; editorDocument: EditableMapDocument | null;
editorDraftDocument?: EditableMapDocument;
workspaceTool: WorkspaceTool | null;
workspaceTools?: ReactNode;
onWorkspaceToolChange: (tool: WorkspaceTool | null) => void;
onSelectJoint: (jointId: number, bodyId: number) => void;
onUrdfMode: (value: UrdfLoadMode) => void; onUrdfMode: (value: UrdfLoadMode) => void;
onBaseMode: (value: UrdfBaseMode) => void; onBaseMode: (value: UrdfBaseMode) => void;
onShowCollision: (value: boolean) => void; onShowCollision: (value: boolean) => void;
@@ -64,304 +58,89 @@ interface ModelControlsProps {
onActuator: (id: number, value: number) => void; onActuator: (id: number, value: number) => void;
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void; onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
onJoint: (id: number, value: number) => void; onJoint: (id: number, value: number) => void;
onForceScale: (value: number) => void;
onSelectControllerPath: (path: string) => void;
onLoadControllerPath: (path: string) => void;
onImportController: (file: File) => void;
onToggleController: (enabled: boolean) => void;
onControllerCommand: (command: ControllerCommand) => void;
onRemoveController: () => void;
onSelectPolicyPath: (path: string) => void;
onLoadPolicyPath: (path: string) => void;
onImportPolicy: (file: File) => void;
onTogglePolicy: (enabled: boolean) => void;
onPolicyCommand: (command: RLCommand) => void;
onRemovePolicy: () => void;
onApplyMap: (value: MapSelection) => void; onApplyMap: (value: MapSelection) => void;
onMapDraft: (value: PlacedMapSelection) => void;
onEditorPreview: (document: EditableMapDocument | null) => void; onEditorPreview: (document: EditableMapDocument | null) => void;
onEditorDraftChange: (
descriptorPath: string,
document: EditableMapDocument,
dirty: boolean,
) => void;
onEditorApply: (document: EditableMapDocument) => Promise<boolean>; onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
onEditorExport: () => void; onEditorExport: () => void;
onEditorConvert: () => Promise<boolean>; onEditorConvert: () => Promise<boolean>;
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void; onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onEditorSelect: (id: string | null) => void; onEditorSelect: (id: string | null) => void;
onEditorTransformMode: (mode: MapEditorTransformMode) => void; onEditorSessionStateChange?: (
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void; descriptorPath: string,
state: MapEditorSessionState | null,
) => void;
onEditorSurfaceHeight?: (position: readonly [number, number, number]) => number | null;
onMapDisplay: (visual: boolean, collision: boolean) => void; onMapDisplay: (visual: boolean, collision: boolean) => void;
onMapTabOpen?: () => void;
onDataRecorderConfigure: (patch: Partial<DataRecorderConfig>) => void;
onDataRecordingStart: () => void;
onDataRecordingStop: () => void;
onDataRecordingClear: () => void;
onDataRecordingExport: (format: 'csv' | 'json') => void;
} }
export function ModelControlsSidebar(props: ModelControlsProps) { export function ModelControlsSidebar(props: ModelControlsProps) {
const [tab, setTab] = useState<'properties' | 'controls' | 'data' | 'map'>('properties'), const snapshot = props.snapshot;
s = props.snapshot; const activeView = props.workspaceTool ?? 'inspector';
if (!s) const title =
activeView === 'controls' ? '控制台' : activeView === 'data' ? '数据录制' : '检查器';
const icon =
activeView === 'controls' ? (
<SlidersHorizontal />
) : activeView === 'data' ? (
<Database />
) : (
<PanelRight />
);
const tabs = (
<RightSidebarTabs
value={activeView}
onChange={(value) => props.onWorkspaceToolChange(value === 'inspector' ? null : value)}
/>
);
if (!snapshot)
return ( return (
<SidebarPanel title="属性与参数" side="right" visible={props.visible}> <SidebarPanel title={title} side="right" visible={props.visible} icon={icon}>
<div className="p-4 text-sm text-text-tertiary">导入模型后显示属性</div> {tabs}
{props.workspaceTool ? (
props.workspaceTools
) : (
<div className="p-4 text-sm text-text-tertiary">导入模型后显示检查器</div>
)}
</SidebarPanel> </SidebarPanel>
); );
const properties = (
<> const selection = props.selection;
<CollapsibleSection title="模型信息" defaultOpen badge={<Badge>{s.model.nbody} Body</Badge>}> const selectedBody =
<div> selection?.kind === 'body'
<PropertyRow label="Body" value={s.model.nbody} /> ? snapshot.bodies.find((body) => body.id === selection.bodyId)
<PropertyRow label="Joint" value={s.model.njnt} /> : undefined;
<PropertyRow label="Geom" value={s.model.ngeom} /> const selectedJoint =
<PropertyRow label="Actuator" value={s.model.nactuator} /> selection?.kind === 'joint'
<PropertyRow label="qpos / qvel" value={`${s.model.nq} / ${s.model.nv}`} /> ? snapshot.joints.find((joint) => joint.id === selection.jointId)
</div> : undefined;
</CollapsibleSection> const mapSelected =
{props.selectedFormat === 'urdf' && ( (selection?.kind === 'map' || selection?.kind === 'map-object') &&
<CollapsibleSection title="URDF 处理方式" defaultOpen={false}> selection.mapAssetId === props.activeMapAssetId;
<Select const selectedMapObject =
aria-label="URDF 处理方式" selection?.kind === 'map-object' && mapSelected
className="w-full" ? (props.editorDraftDocument ?? props.editorDocument)?.objects.find(
value={props.urdfMode} (object) => object.id === selection.objectId,
disabled={props.loading} )
onChange={(event) => props.onUrdfMode(event.target.value as UrdfLoadMode)} : undefined;
> const mapPanel = (
<option value="mjcf">转换为 MJCF(推荐)</option>
<option value="native">MuJoCo 原生 URDF</option>
</Select>
<label className="mt-3 block text-xs text-text-secondary">
<span className="mb-1 block">基座类型</span>
<Select
aria-label="URDF 基座类型"
className="w-full"
value={props.baseMode}
disabled={props.loading || props.urdfMode === 'native'}
onChange={(event) => props.onBaseMode(event.target.value as UrdfBaseMode)}
>
<option value="floating">浮动基座(Free Joint)</option>
<option value="fixed">固定基座(连接世界)</option>
</Select>
</label>
<p className="mt-2 text-xs text-text-tertiary">
MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。
</p>
<Check
label="显示碰撞几何"
checked={props.showCollision}
onChange={props.onShowCollision}
/>
</CollapsibleSection>
)}
<CollapsibleSection title="当前选择" defaultOpen>
{props.selection ? (
<div className="text-xs">
<PropertyRow
label="Body"
value={props.selection.bodyName}
action={<CopyButton value={props.selection.bodyName} label="复制 Body 名称" />}
/>
<PropertyRow
label="标识"
value={`${props.selection.bodyId} / ${props.selection.geomId} / ${props.selection.geomType}`}
action={
<CopyButton
value={`body ${props.selection.bodyId}, geom ${props.selection.geomId}, type ${props.selection.geomType}`}
label="复制标识"
/>
}
/>
<PropertyRow
label="位置"
value={props.selection.position.map((value) => value.toFixed(3)).join(', ')}
action={<CopyButton value={props.selection.position.join(', ')} label="复制位置" />}
/>
</div>
) : (
<p className="flex items-center gap-2 text-xs text-text-tertiary">
<Info className="h-3.5 w-3.5" />
在视口中单击物体
</p>
)}
</CollapsibleSection>
</>
);
const controls = (
<>
<CollapsibleSection
title="ONNX 强化学习策略"
defaultOpen
badge={s.rlPolicy ? <Badge>{s.rlPolicy.enabled ? '推理' : '停止'}</Badge> : undefined}
>
<RLPolicyPanel
paths={props.policyPaths}
selectedPath={props.selectedPolicyPath}
status={props.policyStatus ?? s.rlPolicy}
loading={props.loading}
onSelectPath={props.onSelectPolicyPath}
onLoadPath={props.onLoadPolicyPath}
onImport={props.onImportPolicy}
onToggle={props.onTogglePolicy}
onCommand={props.onPolicyCommand}
onRemove={props.onRemovePolicy}
/>
</CollapsibleSection>
<CollapsibleSection title="本地强化学习训练" defaultOpen={false}>
<LocalTrainingPanel onPolicyReady={props.onImportPolicy} />
</CollapsibleSection>
<CollapsibleSection
title="Python 控制器"
defaultOpen
badge={s.controller ? <Badge>{s.controller.enabled ? '运行' : '停止'}</Badge> : undefined}
>
<PythonControllerPanel
paths={props.controllerPaths}
selectedPath={props.selectedControllerPath}
status={props.controllerStatus ?? s.controller}
loading={props.loading}
onSelectPath={props.onSelectControllerPath}
onLoadPath={props.onLoadControllerPath}
onImport={props.onImportController}
onToggle={props.onToggleController}
onCommand={props.onControllerCommand}
onRemove={props.onRemoveController}
/>
</CollapsibleSection>
<CollapsibleSection
title="Actuator"
defaultOpen={false}
badge={<Badge>{s.actuators.length}</Badge>}
>
{s.actuators.length ? (
s.actuators.map((actuator) => (
<ActuatorControl
key={actuator.id}
actuator={actuator}
onControl={(value) => props.onActuator(actuator.id, value)}
onParameters={(parameters) => props.onActuatorParameters(actuator.id, parameters)}
/>
))
) : (
<p className="text-xs text-text-tertiary">模型没有驱动器</p>
)}
</CollapsibleSection>
<CollapsibleSection title="关节" defaultOpen badge={<Badge>{s.joints.length}</Badge>}>
<div className="mb-4 grid grid-cols-2 gap-2">
<Button onClick={props.onResetJoints}>重置关节</Button>
<Button
variant={props.ignoreJointLimits ? 'primary' : 'secondary'}
aria-pressed={props.ignoreJointLimits}
onClick={props.onToggleJointLimits}
>
忽略关节限位
</Button>
<Button
variant={props.jointAdvanced ? 'primary' : 'secondary'}
aria-pressed={props.jointAdvanced}
onClick={props.onToggleAdvanced}
>
高级
</Button>
<Button
variant={props.angleUnit === 'deg' ? 'primary' : 'secondary'}
aria-pressed={props.angleUnit === 'deg'}
onClick={props.onToggleAngleUnit}
>
{props.angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
</Button>
</div>
{s.joints.map((joint) => {
const scale = joint.type === 3 && props.angleUnit === 'deg' ? 180 / Math.PI : 1,
unit =
joint.type === 3
? props.angleUnit === 'deg'
? '°'
: ' rad'
: joint.type === 2
? ' m'
: '';
return (
<ControlSlider
key={joint.id}
label={`${joint.name}${joint.editable ? '' : '(只读)'}`}
value={joint.value * scale}
min={joint.min * scale}
max={joint.max * scale}
unit={unit}
advanced={props.jointAdvanced}
limited={joint.limited}
limitsIgnored={joint.limitsIgnored}
limitMin={joint.limitMin * scale}
limitMax={joint.limitMax * scale}
disabled={!joint.editable}
onChange={(value) => props.onJoint(joint.id, value / scale)}
/>
);
})}
</CollapsibleSection>
<CollapsibleSection title="外力强度" defaultOpen={false}>
<ControlSlider
label={`${props.forceScale.toFixed(0)} N/屏幕单位`}
value={props.forceScale}
min={5}
max={200}
onChange={props.onForceScale}
/>
<p className="text-xs text-text-tertiary">
选择“外力施加”,在动态物体上按住拖动,松开即清零。
</p>
</CollapsibleSection>
</>
);
return (
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
<Tabs
label="模型控制侧栏"
value={tab}
onValueChange={(value) => {
setTab(value);
if (value === 'map') props.onMapTabOpen?.();
}}
items={[
{
value: 'properties',
label: '属性',
icon: <Info className="h-3.5 w-3.5" />,
content: properties,
},
{
value: 'controls',
label: '控制',
icon: <SlidersHorizontal className="h-3.5 w-3.5" />,
content: controls,
},
{
value: 'data',
label: '数据',
icon: <Database className="h-3.5 w-3.5" />,
content: (
<DataRecordingPanel
status={s.telemetry}
bodies={s.bodies}
onConfigure={props.onDataRecorderConfigure}
onStart={props.onDataRecordingStart}
onStop={props.onDataRecordingStop}
onClear={props.onDataRecordingClear}
onExport={props.onDataRecordingExport}
/>
),
},
{
value: 'map',
label: '地图',
icon: <MapIcon className="h-3.5 w-3.5" />,
content: (
<PhysicalMapPanel <PhysicalMapPanel
key={JSON.stringify(props.mapSelection)} key={`${props.activeMapAssetId ?? 'none'}:${
props.mapSelection.kind === 'project'
? props.mapSelection.descriptorPath
: props.mapSelection.kind
}`}
value={props.mapSelection} value={props.mapSelection}
maps={props.maps} maps={props.maps}
rootBodies={s.bodies rootBodies={snapshot.bodies
.filter( .filter(
(body) => (body) =>
body.id !== 0 && body.id !== 0 && body.parentId === 0 && !body.name.startsWith('__platform_map_'),
body.parentId === 0 &&
!body.name.startsWith('__platform_map_'),
) )
.map((body) => body.name)} .map((body) => body.name)}
loading={props.loading} loading={props.loading}
@@ -369,21 +148,106 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
showVisualMap={props.showVisualMap} showVisualMap={props.showVisualMap}
showMapCollision={props.showMapCollision} showMapCollision={props.showMapCollision}
editorDocument={props.editorDocument} editorDocument={props.editorDocument}
editorDraftDocument={props.editorDraftDocument}
sceneDirty={props.mapSceneDirty}
onEditorPreview={props.onEditorPreview} onEditorPreview={props.onEditorPreview}
onEditorDraftChange={props.onEditorDraftChange}
onEditorApply={props.onEditorApply} onEditorApply={props.onEditorApply}
onEditorExport={props.onEditorExport} onEditorExport={props.onEditorExport}
onEditorConvert={props.onEditorConvert} onEditorConvert={props.onEditorConvert}
selectedEditorObjectId={
selection?.kind === 'map-object' && mapSelected ? selection.objectId : undefined
}
onEditorBindInteraction={props.onEditorBindInteraction} onEditorBindInteraction={props.onEditorBindInteraction}
onEditorSelect={props.onEditorSelect} onEditorSelect={props.onEditorSelect}
onEditorTransformMode={props.onEditorTransformMode} onEditorSessionStateChange={props.onEditorSessionStateChange}
onEditorSnapping={props.onEditorSnapping} onEditorSurfaceHeight={props.onEditorSurfaceHeight}
onMapDisplay={props.onMapDisplay} onMapDisplay={props.onMapDisplay}
onDraft={props.onMapDraft}
onApply={props.onApplyMap} onApply={props.onApplyMap}
/> />
), );
},
]} let inspector: ReactNode;
if (selectedBody) {
inspector = (
<BodyInspector
body={selectedBody}
snapshot={snapshot}
viewerSelection={props.viewerSelection}
showCollision={props.showCollision}
onShowCollision={props.onShowCollision}
onSelectJoint={props.onSelectJoint}
/> />
);
} else if (selectedJoint) {
inspector = (
<JointInspector
joint={selectedJoint}
snapshot={snapshot}
loading={props.loading}
ignoreJointLimits={props.ignoreJointLimits}
jointAdvanced={props.jointAdvanced}
angleUnit={props.angleUnit}
onResetJoints={props.onResetJoints}
onToggleJointLimits={props.onToggleJointLimits}
onToggleAdvanced={props.onToggleAdvanced}
onToggleAngleUnit={props.onToggleAngleUnit}
onJoint={props.onJoint}
onActuator={props.onActuator}
onActuatorParameters={props.onActuatorParameters}
/>
);
} else if (mapSelected) {
const mapName = selectedMapObject?.name ?? props.activeMapAssetName ?? '地图实例';
inspector = (
<>
<InspectorIdentity
icon={selectedMapObject ? <Box /> : <MapPinned />}
eyebrow={selectedMapObject ? 'Map / Object' : 'Map / Instance'}
name={mapName}
meta={
selectedMapObject
? `${selectedMapObject.type} · ${props.activeMapAssetName ?? '地图'}`
: props.mapSelection.kind === 'builtin'
? '参数化地形'
: '工程地图包实例'
}
/>
{mapPanel}
</>
);
} else {
inspector = (
<>
<ModelSummaryInspector
snapshot={snapshot}
selectedFormat={props.selectedFormat}
loading={props.loading}
urdfMode={props.urdfMode}
baseMode={props.baseMode}
showCollision={props.showCollision}
onUrdfMode={props.onUrdfMode}
onBaseMode={props.onBaseMode}
onShowCollision={props.onShowCollision}
/>
{props.mapSelection.kind === 'none' && mapPanel}
</>
);
}
return (
<SidebarPanel title={title} side="right" visible={props.visible} icon={icon}>
{tabs}
<div
hidden={props.workspaceTool !== null}
role="tabpanel"
aria-label="检查器"
className="min-h-0 flex-1 overflow-auto panel-scroll"
>
{inspector}
</div>
{props.workspaceTool && props.workspaceTools}
</SidebarPanel> </SidebarPanel>
); );
} }
@@ -1,20 +1,17 @@
import { useState } from 'react'; import { useState } from 'react';
import { Box, FolderTree, Map as MapIcon } from 'lucide-react'; import { FolderTree, Library, Search } from 'lucide-react';
import type { ModelEntry } from '../../project/types'; import type { MapEntry, ModelEntry } from '../../project/types';
import { import {
countProjectSearchResults, countProjectSearchResults,
ProjectTree, ProjectTree,
type ProjectTreeFile, type ProjectTreeFile,
} from '../../project/ProjectTree'; } from '../../project/ProjectTree';
import {
countModelStructureSearchResults,
ModelStructureTree,
} from '../../project/ModelStructureTree';
import type { SimulationSnapshot } from '../../simulation/SimulationSession'; import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import { Button, Tabs } from '../../components/ui'; import { Button, Tabs, VerticalSplitPane } from '../../components/ui';
import { import {
DEFAULT_PHYSICAL_MAP_CONFIG, DEFAULT_PHYSICAL_MAP_CONFIG,
type MapSelection, type MapSelection,
type PlacedMapAsset,
type SystemTerrainPreset, type SystemTerrainPreset,
} from '../../map/types'; } from '../../map/types';
import type { import type {
@@ -23,28 +20,46 @@ import type {
MapObjectPlacementMode, MapObjectPlacementMode,
} from '../../map/editor/types'; } from '../../map/editor/types';
import { MapAssetLibrary } from '../../map/MapAssetLibrary'; import { MapAssetLibrary } from '../../map/MapAssetLibrary';
import type { EditorSelection } from '../editorSelection';
import { ProjectBreadcrumb } from './ProjectBreadcrumb'; import { ProjectBreadcrumb } from './ProjectBreadcrumb';
import { SceneOutliner, countSceneSearchResults } from './SceneOutliner';
import { SidebarPanel } from './SidebarPanel'; import { SidebarPanel } from './SidebarPanel';
import { TreeSearchField } from './TreeSearchField'; import { TreeSearchField } from './TreeSearchField';
export type ProjectResourceTab = 'assets' | 'files';
export function ProjectSidebar({ export function ProjectSidebar({
projectName, projectName,
files, files,
entries, entries,
selectedEntry, selectedEntry,
snapshot, snapshot,
selection,
loading, loading,
visible = true, visible = true,
nativeUrdf, nativeUrdf,
mapSelection, mapSelection,
editorDocument, maps,
placedMaps,
pendingSceneChangeCount,
pendingSceneIds,
editorDocuments,
assetPlacementMode,
activeTab, activeTab,
onActiveTabChange, onActiveTabChange,
onRemove, onRemove,
onSelectEntry, onSelectEntry,
onSelectBody,
onSelectJoint,
onJointHover, onJointHover,
onAddMapAsset, onAddMapAsset,
onAddProjectMap,
onSelectTerrain, onSelectTerrain,
onAssetPlacementModeChange,
onApplyScene,
onDiscardScene,
onSelectMap,
onRemoveMap,
onSelectMapObject, onSelectMapObject,
}: { }: {
projectName?: string; projectName?: string;
@@ -52,33 +67,46 @@ export function ProjectSidebar({
entries: ModelEntry[]; entries: ModelEntry[];
selectedEntry?: string; selectedEntry?: string;
snapshot?: SimulationSnapshot; snapshot?: SimulationSnapshot;
selection: EditorSelection | null;
loading: boolean; loading: boolean;
visible?: boolean; visible?: boolean;
nativeUrdf: boolean; nativeUrdf: boolean;
mapSelection: MapSelection; mapSelection: MapSelection;
editorDocument: EditableMapDocument | null; maps: MapEntry[];
activeTab?: 'project' | 'structure' | 'assets'; placedMaps: PlacedMapAsset[];
onActiveTabChange?: (value: 'project' | 'structure' | 'assets') => void; pendingSceneChangeCount: number;
pendingSceneIds: string[];
editorDocuments: ReadonlyMap<string, EditableMapDocument>;
assetPlacementMode?: MapObjectPlacementMode;
activeTab?: ProjectResourceTab;
onActiveTabChange?: (value: ProjectResourceTab) => void;
onRemove: () => void; onRemove: () => void;
onSelectEntry: (path: string) => void; onSelectEntry: (path: string) => void;
onSelectBody: (bodyId: number) => void;
onSelectJoint: (jointId: number, bodyId: number) => void;
onJointHover: (jointId: number | null) => void; onJointHover: (jointId: number | null) => void;
onAddMapAsset: ( onAddMapAsset: (
type: EditableMapObjectType, type: EditableMapObjectType,
placementMode: MapObjectPlacementMode, placementMode: MapObjectPlacementMode,
) => void | Promise<void>; ) => void | Promise<void>;
onAddProjectMap: (descriptorPath: string) => void;
onSelectTerrain: (preset: SystemTerrainPreset) => void; onSelectTerrain: (preset: SystemTerrainPreset) => void;
onSelectMapObject: (id: string) => void; onAssetPlacementModeChange?: (mode: MapObjectPlacementMode) => void;
onApplyScene: () => void;
onDiscardScene: () => void;
onSelectMap: (id: string) => void;
onRemoveMap: (id: string) => void;
onSelectMapObject: (mapId: string, objectId: string) => void;
}) { }) {
const [internalTab, setInternalTab] = useState<'project' | 'structure' | 'assets'>('project'), const [internalTab, setInternalTab] = useState<ProjectResourceTab>('assets');
[fileQuery, setFileQuery] = useState(''), const [fileQuery, setFileQuery] = useState('');
[structureQuery, setStructureQuery] = useState(''); const [sceneQuery, setSceneQuery] = useState('');
const tab = activeTab ?? internalTab, const tab = activeTab ?? internalTab;
fileMatches = countProjectSearchResults(files, fileQuery), const fileMatches = countProjectSearchResults(files, fileQuery);
structureMatches = snapshot const sceneMatches = countSceneSearchResults(snapshot, placedMaps, editorDocuments, sceneQuery);
? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery)
: 0;
return ( return (
<SidebarPanel title="资产与结构" side="left" visible={visible}> <SidebarPanel title="场景大纲与资产中心" side="left" visible={visible} icon={<Library />}>
{projectName ? ( {projectName ? (
<> <>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5"> <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
@@ -86,21 +114,62 @@ export function ProjectSidebar({
<div className="truncate text-sm font-medium text-accent" title={projectName}> <div className="truncate text-sm font-medium text-accent" title={projectName}>
{projectName} {projectName}
</div> </div>
<div className="mt-0.5 text-[10px] text-text-tertiary">{files.length} 个文件</div> <div className="mt-0.5 truncate text-[10px] text-text-tertiary">
{snapshot
? `${snapshot.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')).length} Body`
: '模型未加载'}{' '}
· {placedMaps.length} 个地图实例 · {files.length} 个文件
</div>
</div> </div>
<Button variant="danger" onClick={onRemove} disabled={loading}> <Button variant="danger" onClick={onRemove} disabled={loading}>
移除 移除
</Button> </Button>
</div> </div>
<ProjectBreadcrumb
projectName={projectName} <VerticalSplitPane
entries={entries} storageKey="mujoco-project-sidebar-scene-ratio"
selectedEntry={selectedEntry} separatorLabel="调整场景大纲与资源面板高度"
loading={loading} defaultRatio={0.42}
onSelect={onSelectEntry} minFirstSize={160}
minSecondSize={160}
firstClassName="flex flex-col"
secondClassName="flex flex-col"
first={
<>
<div className="flex h-8 shrink-0 items-center gap-2 px-3 text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">
<Search className="h-3.5 w-3.5" aria-hidden="true" />
场景大纲
</div>
<TreeSearchField
value={sceneQuery}
onChange={setSceneQuery}
resultCount={sceneMatches}
label="搜索场景对象"
placeholder="搜索 Body、关节或地图物体…"
/> />
<SceneOutliner
snapshot={snapshot}
maps={placedMaps}
documents={editorDocuments}
selection={selection}
query={sceneQuery}
loading={loading}
pendingSceneChangeCount={pendingSceneChangeCount}
pendingSceneIds={pendingSceneIds}
onSelectBody={onSelectBody}
onSelectJoint={onSelectJoint}
onJointHover={onJointHover}
onSelectMap={onSelectMap}
onSelectMapObject={onSelectMapObject}
onRemoveMap={onRemoveMap}
onApplyScene={onApplyScene}
onDiscardScene={onDiscardScene}
/>
</>
}
second={
<Tabs <Tabs
label="工程侧栏" label="资产与工程文件"
value={tab} value={tab}
onValueChange={(value) => { onValueChange={(value) => {
setInternalTab(value); setInternalTab(value);
@@ -108,11 +177,40 @@ export function ProjectSidebar({
}} }}
items={[ items={[
{ {
value: 'project', value: 'assets',
label: '工程', label: '资产库',
icon: <Library className="h-3.5 w-3.5" />,
disabled: !snapshot,
content: (
<MapAssetLibrary
disabled={loading || nativeUrdf}
terrainSize={
mapSelection.kind === 'builtin'
? mapSelection.config.size
: DEFAULT_PHYSICAL_MAP_CONFIG.size
}
maps={maps}
placementMode={assetPlacementMode}
onPlacementModeChange={onAssetPlacementModeChange}
onAdd={onAddMapAsset}
onAddProjectMap={onAddProjectMap}
onSelectTerrain={onSelectTerrain}
/>
),
},
{
value: 'files',
label: '工程文件',
icon: <FolderTree className="h-3.5 w-3.5" />, icon: <FolderTree className="h-3.5 w-3.5" />,
content: ( content: (
<> <>
<ProjectBreadcrumb
projectName={projectName}
entries={entries}
selectedEntry={selectedEntry}
loading={loading}
onSelect={onSelectEntry}
/>
<TreeSearchField <TreeSearchField
value={fileQuery} value={fileQuery}
onChange={setFileQuery} onChange={setFileQuery}
@@ -132,58 +230,13 @@ export function ProjectSidebar({
</> </>
), ),
}, },
{
value: 'structure',
label: '模型结构',
icon: <Box className="h-3.5 w-3.5" />,
disabled: !snapshot,
content: snapshot ? (
<>
<TreeSearchField
value={structureQuery}
onChange={setStructureQuery}
resultCount={structureMatches}
label="搜索模型结构"
placeholder="搜索 Body 或关节…"
/>
<div className="px-2 pb-3">
<ModelStructureTree
bodies={snapshot.bodies}
joints={snapshot.joints}
onJointHover={onJointHover}
query={structureQuery}
/>
</div>
</>
) : (
<p className="p-4 text-center text-xs text-text-tertiary">加载模型后显示结构</p>
),
},
{
value: 'assets',
label: '资产',
icon: <MapIcon className="h-3.5 w-3.5" />,
disabled: !snapshot,
content: (
<MapAssetLibrary
disabled={loading || nativeUrdf}
terrainSize={
mapSelection.kind === 'builtin'
? mapSelection.config.size
: DEFAULT_PHYSICAL_MAP_CONFIG.size
}
document={editorDocument}
onAdd={onAddMapAsset}
onSelectTerrain={onSelectTerrain}
onSelectObject={onSelectMapObject}
/>
),
},
]} ]}
/> />
}
/>
</> </>
) : ( ) : (
<div className="p-4 text-center text-sm text-text-tertiary">导入模型后显示工程资源</div> <div className="p-4 text-center text-sm text-text-tertiary">导入模型后显示场景与资产</div>
)} )}
</SidebarPanel> </SidebarPanel>
); );
@@ -0,0 +1,65 @@
import { Database, PanelRight, SlidersHorizontal, type LucideIcon } from 'lucide-react';
export type RightSidebarView = 'inspector' | 'controls' | 'data';
const VIEWS: ReadonlyArray<{
value: RightSidebarView;
label: string;
icon: LucideIcon;
}> = [
{ value: 'inspector', label: '检查器', icon: PanelRight },
{ value: 'controls', label: '控制台', icon: SlidersHorizontal },
{ value: 'data', label: '数据录制', icon: Database },
];
export function RightSidebarTabs({
value,
onChange,
}: {
value: RightSidebarView;
onChange: (value: RightSidebarView) => void;
}) {
return (
<div
role="tablist"
aria-label="右侧工作区视图"
className="grid shrink-0 grid-cols-3 gap-1 border-b border-border bg-panel-muted/40 p-2"
>
{VIEWS.map((view, index) => {
const Icon = view.icon;
const selected = value === view.value;
return (
<button
key={view.value}
type="button"
role="tab"
aria-selected={selected}
tabIndex={selected ? 0 : -1}
className={`flex min-w-0 items-center justify-center gap-1.5 rounded-md px-1 py-2 text-[11px] font-medium transition-colors ${
selected
? 'bg-panel text-accent shadow-sm'
: 'text-text-tertiary hover:bg-element-hover hover:text-text-primary'
}`}
onClick={() => onChange(view.value)}
onKeyDown={(event) => {
let nextIndex: number | undefined;
if (event.key === 'ArrowLeft') nextIndex = (index + VIEWS.length - 1) % VIEWS.length;
else if (event.key === 'ArrowRight') nextIndex = (index + 1) % VIEWS.length;
else if (event.key === 'Home') nextIndex = 0;
else if (event.key === 'End') nextIndex = VIEWS.length - 1;
if (nextIndex === undefined) return;
event.preventDefault();
onChange(VIEWS[nextIndex].value);
const tabs =
event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="tab"]');
tabs?.[nextIndex]?.focus();
}}
>
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{view.label}</span>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,120 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { EditableMapDocument } from '../../map/editor/types';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import { SceneOutliner, countSceneSearchResults } from './SceneOutliner';
const snapshot = {
bodies: [
{ id: 0, name: 'world', parentId: 0 },
{ id: 1, name: 'base', parentId: 0 },
{ id: 2, name: 'arm', parentId: 1 },
],
joints: [
{
id: 7,
name: 'arm_joint',
bodyId: 2,
type: 3,
value: 0,
min: -1,
max: 1,
limitMin: -1,
limitMax: 1,
limited: true,
limitsIgnored: false,
editable: true,
axis: [0, 0, 1],
},
],
model: { nbody: 3 },
} as SimulationSnapshot;
const document: EditableMapDocument = {
schemaVersion: 1,
mapId: 'warehouse',
revision: 0,
objects: [
{
id: 'crate-1',
name: '木箱',
type: 'box',
pose: { position: [0, 0, 0.5], quaternion: [1, 0, 0, 0] },
parameters: { sizeX: 1, sizeY: 1, sizeZ: 1 },
friction: [1, 0.005, 0.0001],
rgba: [0.5, 0.6, 0.7, 1],
placementMode: 'auto_ground',
enabled: true,
},
],
spawnPoints: [],
};
const maps = [
{
id: 'map-a',
name: '仓库',
selection: { kind: 'project' as const, descriptorPath: 'maps/warehouse/map.json' },
},
];
const documents = new Map([['maps/warehouse/map.json', document]]);
function renderOutliner(overrides: Record<string, unknown> = {}) {
const props = {
snapshot,
maps,
documents,
selection: null,
query: '',
loading: false,
pendingSceneChangeCount: 0,
pendingSceneIds: [],
onSelectBody: vi.fn(),
onSelectJoint: vi.fn(),
onJointHover: vi.fn(),
onSelectMap: vi.fn(),
onSelectMapObject: vi.fn(),
onRemoveMap: vi.fn(),
onApplyScene: vi.fn(),
onDiscardScene: vi.fn(),
...overrides,
};
render(<SceneOutliner {...props} />);
return props;
}
describe('SceneOutliner', () => {
it('在一棵场景大纲中统一展示机器人、地图实例和地图物体', () => {
renderOutliner();
expect(screen.getByLabelText('场景资产树')).toHaveTextContent('机器人');
expect(screen.getByLabelText('场景资产树')).toHaveTextContent('地图与环境');
expect(screen.getByRole('treeitem', { name: /arm_joint/ })).toBeVisible();
expect(screen.getByRole('treeitem', { name: /木箱/ })).toBeVisible();
expect(countSceneSearchResults(snapshot, maps, documents, '木箱')).toBe(2);
});
it('把所有选择统一上报为稳定 id', () => {
const props = renderOutliner();
fireEvent.click(screen.getByRole('treeitem', { name: 'base' }));
fireEvent.click(screen.getByRole('treeitem', { name: /arm_joint/ }));
fireEvent.click(screen.getByRole('treeitem', { name: /^仓库/ }));
fireEvent.click(screen.getByRole('treeitem', { name: /木箱/ }));
expect(props.onSelectBody).toHaveBeenCalledWith(1);
expect(props.onSelectJoint).toHaveBeenCalledWith(7, 2);
expect(props.onSelectMap).toHaveBeenCalledWith('map-a');
expect(props.onSelectMapObject).toHaveBeenCalledWith('map-a', 'crate-1');
});
it('在大纲中集中处理场景草稿与实例删除', () => {
const props = renderOutliner({
pendingSceneChangeCount: 2,
pendingSceneIds: ['map-a'],
});
expect(screen.getByRole('status')).toHaveTextContent('2 项场景更改待应用');
fireEvent.click(screen.getByRole('button', { name: '应用场景' }));
fireEvent.click(screen.getByRole('button', { name: '放弃更改' }));
fireEvent.click(screen.getByRole('button', { name: '删除地图实例 仓库' }));
expect(props.onApplyScene).toHaveBeenCalledOnce();
expect(props.onDiscardScene).toHaveBeenCalledOnce();
expect(props.onRemoveMap).toHaveBeenCalledWith('map-a');
});
});
@@ -0,0 +1,275 @@
import { useMemo } from 'react';
import { Box, Bot, Layers3, MapPinned, Trash2, Zap } from 'lucide-react';
import { Button, EmptySearchState, SearchHighlight } from '../../components/ui';
import type { EditableMapDocument } from '../../map/editor/types';
import type { PlacedMapAsset } from '../../map/types';
import {
countModelStructureSearchResults,
ModelStructureTree,
} from '../../project/ModelStructureTree';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import type { EditorSelection } from '../editorSelection';
interface VisibleMap {
asset: PlacedMapAsset;
objects: EditableMapDocument['objects'];
}
function mapDocument(
asset: PlacedMapAsset,
documents: ReadonlyMap<string, EditableMapDocument>,
): EditableMapDocument | undefined {
return asset.selection.kind === 'project'
? documents.get(asset.selection.descriptorPath)
: undefined;
}
// eslint-disable-next-line react-refresh/only-export-components
export function countSceneSearchResults(
snapshot: SimulationSnapshot | undefined,
maps: readonly PlacedMapAsset[],
documents: ReadonlyMap<string, EditableMapDocument>,
query: string,
): number {
const normalized = query.trim().toLocaleLowerCase();
const robotBodies =
snapshot?.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')) ??
[];
const robotBodyIds = new Set(robotBodies.map((body) => body.id));
const robotCount = snapshot
? countModelStructureSearchResults(
robotBodies,
snapshot.joints.filter((joint) => robotBodyIds.has(joint.bodyId)),
normalized,
)
: 0;
if (!normalized)
return (
robotCount +
maps.reduce((count, map) => count + 1 + (mapDocument(map, documents)?.objects.length ?? 0), 0)
);
return (
robotCount +
maps.reduce((count, map) => {
const document = mapDocument(map, documents);
if (map.name.toLocaleLowerCase().includes(normalized))
return count + 1 + (document?.objects.length ?? 0);
const objectCount =
document?.objects.filter((object) =>
`${object.name} ${object.type}`.toLocaleLowerCase().includes(normalized),
).length ?? 0;
return count + (objectCount ? 1 + objectCount : 0);
}, 0)
);
}
export function SceneOutliner({
snapshot,
maps,
documents,
selection,
query,
loading,
pendingSceneChangeCount,
pendingSceneIds,
onSelectBody,
onSelectJoint,
onJointHover,
onSelectMap,
onSelectMapObject,
onRemoveMap,
onApplyScene,
onDiscardScene,
}: {
snapshot?: SimulationSnapshot;
maps: readonly PlacedMapAsset[];
documents: ReadonlyMap<string, EditableMapDocument>;
selection: EditorSelection | null;
query: string;
loading: boolean;
pendingSceneChangeCount: number;
pendingSceneIds: readonly string[];
onSelectBody: (bodyId: number) => void;
onSelectJoint: (jointId: number, bodyId: number) => void;
onJointHover: (jointId: number | null) => void;
onSelectMap: (mapId: string) => void;
onSelectMapObject: (mapId: string, objectId: string) => void;
onRemoveMap: (mapId: string) => void;
onApplyScene: () => void;
onDiscardScene: () => void;
}) {
const normalized = query.trim().toLocaleLowerCase();
const robotBodies = useMemo(
() =>
snapshot?.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')) ??
[],
[snapshot?.bodies],
);
const robotBodyIds = useMemo(() => new Set(robotBodies.map((body) => body.id)), [robotBodies]);
const robotJoints = useMemo(
() => snapshot?.joints.filter((joint) => robotBodyIds.has(joint.bodyId)) ?? [],
[snapshot?.joints, robotBodyIds],
);
const visibleMaps = useMemo<VisibleMap[]>(
() =>
maps.flatMap((asset) => {
const objects = mapDocument(asset, documents)?.objects ?? [];
if (!normalized || asset.name.toLocaleLowerCase().includes(normalized))
return [{ asset, objects }];
const matches = objects.filter((object) =>
`${object.name} ${object.type}`.toLocaleLowerCase().includes(normalized),
);
return matches.length ? [{ asset, objects: matches }] : [];
}),
[documents, maps, normalized],
);
const hasRobotMatches = snapshot
? countModelStructureSearchResults(robotBodies, robotJoints, normalized) > 0
: false;
const pending = new Set(pendingSceneIds);
return (
<section
aria-label="场景资产树"
className="min-h-0 flex-1 overflow-auto px-2 pb-2 panel-scroll"
>
{pendingSceneChangeCount > 0 && (
<div
role="status"
className="mb-2 rounded-lg border border-accent/30 bg-accent-soft p-2.5 text-[10px] text-text-secondary"
>
<div className="flex items-center gap-1.5 font-medium text-accent">
<Zap className="h-3.5 w-3.5" aria-hidden="true" />
{pendingSceneChangeCount} 项场景更改待应用
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<Button variant="primary" disabled={loading} onClick={onApplyScene}>
应用场景
</Button>
<Button variant="ghost" disabled={loading} onClick={onDiscardScene}>
放弃更改
</Button>
</div>
</div>
)}
{snapshot && hasRobotMatches && (
<details open className="group/robot">
<summary className="flex cursor-pointer select-none items-center gap-2 rounded px-1.5 py-1.5 text-xs font-semibold text-text-primary hover:bg-element-hover">
<Bot className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">机器人</span>
<span className="technical-value text-[9px] font-normal text-text-tertiary">
{robotBodies.length} Body
</span>
</summary>
<div className="ml-2 border-l border-border pl-1">
<ModelStructureTree
bodies={robotBodies}
joints={robotJoints}
query={query}
selectedBodyId={selection?.kind === 'body' ? selection.bodyId : undefined}
selectedJointId={selection?.kind === 'joint' ? selection.jointId : undefined}
onSelectBody={onSelectBody}
onSelectJoint={(joint) => onSelectJoint(joint.id, joint.bodyId)}
onJointHover={onJointHover}
/>
</div>
</details>
)}
{visibleMaps.length > 0 && (
<details open className="mt-1">
<summary className="flex cursor-pointer select-none items-center gap-2 rounded px-1.5 py-1.5 text-xs font-semibold text-text-primary hover:bg-element-hover">
<Layers3 className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">地图与环境</span>
<span className="technical-value text-[9px] font-normal text-text-tertiary">
{maps.length} 实例
</span>
</summary>
<ul role="tree" aria-label="地图物体树" className="ml-2 border-l border-border pl-1">
{visibleMaps.map(({ asset, objects }) => {
const mapSelected =
(selection?.kind === 'map' || selection?.kind === 'map-object') &&
selection.mapAssetId === asset.id;
return (
<li key={asset.id} role="none">
<div
className={`flex items-center rounded ${
selection?.kind === 'map' && mapSelected
? 'bg-accent-soft text-accent'
: 'text-text-secondary hover:bg-element-hover'
}`}
>
<button
type="button"
role="treeitem"
aria-selected={selection?.kind === 'map' && mapSelected}
className="flex min-w-0 flex-1 items-center gap-1.5 px-1.5 py-1 text-left text-xs"
disabled={loading}
onClick={() => onSelectMap(asset.id)}
>
<MapPinned className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">
<SearchHighlight text={asset.name} query={query} />
</span>
{pending.has(asset.id) && (
<span className="rounded bg-warning/10 px-1 text-[9px] text-warning">
待应用
</span>
)}
</button>
<button
type="button"
aria-label={`删除地图实例 ${asset.name}`}
className="mr-1 rounded p-1 text-text-tertiary hover:bg-danger/10 hover:text-danger"
disabled={loading}
onClick={() => onRemoveMap(asset.id)}
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</button>
</div>
{objects.length > 0 && (
<ul role="group" className="ml-3 border-l border-border pl-1">
{objects.map((object) => {
const selected =
selection?.kind === 'map-object' &&
selection.mapAssetId === asset.id &&
selection.objectId === object.id;
return (
<li role="none" key={object.id}>
<button
type="button"
role="treeitem"
aria-selected={selected}
disabled={loading}
className={`flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-[11px] ${
selected
? 'bg-accent-soft text-accent'
: 'text-text-secondary hover:bg-element-hover'
}`}
onClick={() => onSelectMapObject(asset.id, object.id)}
>
<Box className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">
<SearchHighlight text={object.name} query={query} />
</span>
<span className="text-[9px] text-text-tertiary">{object.type}</span>
</button>
</li>
);
})}
</ul>
)}
</li>
);
})}
</ul>
</details>
)}
{!hasRobotMatches && !visibleMaps.length && (
<EmptySearchState label={normalized ? '没有匹配的场景对象' : '加载模型后显示场景层级'} />
)}
</section>
);
}
@@ -1,10 +1,18 @@
import { Dialog, Kbd, Separator } from '../../components/ui'; import { Dialog, Kbd, Separator } from '../../components/ui';
const shortcuts = [ const shortcuts = [
['Space', '播放 / 暂停'], ['Space', '播放 / 暂停'],
['R', '重置仿真'], ['R', '重置仿真(非地图编辑)'],
['1', '选择模式'], ['1', '选择模式'],
['2', '关节拖动'], ['2', '关节拖动'],
['3', '外力施加'], ['3', '外力施加'],
['W / E / R', '地图移动 / 旋转 / 缩放'],
['F', '聚焦当前对象'],
['Delete', '删除当前地图对象'],
['Ctrl / Cmd + S', '保存并编译地图草稿'],
['Ctrl / Cmd + Z', '撤销地图编辑'],
['Ctrl / Cmd + Y / Shift + Z', '重做地图编辑'],
['G', '切换地图网格吸附'],
['Esc', '取消选择或关闭浮层'],
]; ];
export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) { export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
return ( return (
@@ -29,7 +37,8 @@ export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose:
<li>左键拖动:旋转相机或执行当前交互工具</li> <li>左键拖动:旋转相机或执行当前交互工具</li>
<li>右键拖动:平移相机</li> <li>右键拖动:平移相机</li>
<li>滚轮:缩放视口</li> <li>滚轮:缩放视口</li>
<li>选择物体后可在右侧“属性”中查看信息</li> <li>选择物体后可在右侧 Inspector 中编辑尺寸、位姿、材质和摩擦</li>
<li>从左侧资产库拖动几何障碍物到视口地面即可放置</li>
</ul> </ul>
</section> </section>
</Dialog> </Dialog>
@@ -7,20 +7,22 @@ export function SidebarPanel({
side, side,
children, children,
visible = true, visible = true,
icon,
}: { }: {
title: string; title: string;
side: 'left' | 'right'; side: 'left' | 'right';
children: ReactNode; children: ReactNode;
visible?: boolean; visible?: boolean;
icon?: ReactNode;
}) { }) {
return ( return (
<ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}> <ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}>
<aside <aside
className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side === 'left' ? 'border-r' : 'border-l'} border-border`} className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side === 'left' ? 'border-r' : 'border-l'} border-border`}
> >
<h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel/95 px-3 text-sm font-semibold tracking-tight text-text-primary"> <h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-gradient-to-r from-panel via-surface/70 to-panel px-3 text-sm font-semibold tracking-tight text-text-primary shadow-[inset_0_-1px_0_rgb(255_255_255/0.025)]">
<span className="grid h-6 w-6 place-items-center rounded-md bg-accent-soft text-accent"> <span className="grid h-6 w-6 place-items-center rounded-md bg-accent-soft text-accent [&>svg]:h-3.5 [&>svg]:w-3.5">
<Settings2 aria-hidden="true" className="h-3.5 w-3.5" /> {icon ?? <Settings2 aria-hidden="true" className="h-3.5 w-3.5" />}
</span> </span>
{title} {title}
</h2> </h2>
@@ -1,4 +1,4 @@
import { Crosshair, Hand, MousePointer2, RotateCcw } from 'lucide-react'; import { Crosshair, Hand, MapPinned, MousePointer2, RotateCcw } from 'lucide-react';
import type { InteractionMode } from '../../viewer/MuJoCoViewer'; import type { InteractionMode } from '../../viewer/MuJoCoViewer';
import type { ViewerDisplayOptions } from '../../viewer/displayOptions'; import type { ViewerDisplayOptions } from '../../viewer/displayOptions';
import { IconButton, ToolbarToggleGroup, type ToolbarItem } from '../../components/ui'; import { IconButton, ToolbarToggleGroup, type ToolbarItem } from '../../components/ui';
@@ -11,12 +11,18 @@ const tools: ToolbarItem<InteractionMode>[] = [
export function ViewerToolDock({ export function ViewerToolDock({
mode, mode,
display, display,
mapEditContext,
onModeChange, onModeChange,
onDisplayChange, onDisplayChange,
onResetCamera, onResetCamera,
}: { }: {
mode: InteractionMode; mode: InteractionMode;
display: ViewerDisplayOptions; display: ViewerDisplayOptions;
mapEditContext?: {
active: boolean;
label: string;
onActivate: () => void;
};
onModeChange: (mode: InteractionMode) => void; onModeChange: (mode: InteractionMode) => void;
onDisplayChange: (next: ViewerDisplayOptions) => void; onDisplayChange: (next: ViewerDisplayOptions) => void;
onResetCamera: () => void; onResetCamera: () => void;
@@ -24,6 +30,18 @@ export function ViewerToolDock({
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<ToolbarToggleGroup items={tools} value={mode} onChange={onModeChange} label="视口交互模式" /> <ToolbarToggleGroup items={tools} value={mode} onChange={onModeChange} label="视口交互模式" />
{mapEditContext && (
<button
type="button"
aria-label={`地图编辑联动:${mapEditContext.label}`}
aria-pressed={mapEditContext.active}
onClick={mapEditContext.onActivate}
className={`hidden h-7 items-center gap-1.5 rounded-lg border px-2 text-[10px] font-semibold transition-colors md:flex ${mapEditContext.active ? 'border-accent/40 bg-accent-soft text-accent' : 'border-border bg-surface/80 text-text-tertiary hover:bg-element-hover hover:text-text-primary'}`}
>
<MapPinned className="h-3.5 w-3.5" aria-hidden="true" />
{mapEditContext.label}
</button>
)}
<ViewerDisplayPopover value={display} onChange={onDisplayChange} /> <ViewerDisplayPopover value={display} onChange={onDisplayChange} />
<IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}> <IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}>
<RotateCcw className="h-3.5 w-3.5" /> <RotateCcw className="h-3.5 w-3.5" />
@@ -16,11 +16,13 @@ export function ViewportHUD({
mode, mode,
selection, selection,
ready, ready,
mapEditing = false,
}: { }: {
paused: boolean; paused: boolean;
mode: InteractionMode; mode: InteractionMode;
selection: ViewerSelection | null; selection: ViewerSelection | null;
ready: boolean; ready: boolean;
mapEditing?: boolean;
}) { }) {
if (!ready) return null; if (!ready) return null;
return ( return (
@@ -45,7 +47,7 @@ export function ViewportHUD({
</div> </div>
<div <div
aria-label="视口操作提示" aria-label="视口操作提示"
className="pointer-events-none absolute bottom-3 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel px-3 py-1.5 text-[10px] text-text-secondary shadow-xl lg:flex" className="pointer-events-none absolute bottom-14 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel/85 px-3 py-1.5 text-[10px] text-text-secondary shadow-xl backdrop-blur lg:flex"
> >
<Mouse aria-hidden="true" className="h-3 w-3 text-text-secondary" /> <Mouse aria-hidden="true" className="h-3 w-3 text-text-secondary" />
<span>{primaryGestures[mode]}</span> <span>{primaryGestures[mode]}</span>
@@ -57,7 +59,20 @@ export function ViewportHUD({
· ·
</span> </span>
<span>滚轮缩放</span> <span>滚轮缩放</span>
{mode !== 'select' && ( {mapEditing && mode === 'select' ? (
<>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<Kbd>W</Kbd>
<Kbd>E</Kbd>
<Kbd>R</Kbd>
<span>变换</span>
<Kbd>F</Kbd>
<span>聚焦</span>
</>
) : (
mode !== 'select' && (
<> <>
<span aria-hidden="true" className="text-border-strong"> <span aria-hidden="true" className="text-border-strong">
· ·
@@ -65,6 +80,7 @@ export function ViewportHUD({
<Kbd>1</Kbd> <Kbd>1</Kbd>
<span>旋转视角</span> <span>旋转视角</span>
</> </>
)
)} )}
</div> </div>
</> </>
@@ -75,7 +75,7 @@ export function WorkbenchHeader({
onToggleFullscreen: () => void; onToggleFullscreen: () => void;
}) { }) {
return ( return (
<header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel/95 px-2.5 shadow-[0_1px_0_rgba(255,255,255,0.02)] backdrop-blur-md"> <header className="workbench-header relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border px-2.5 shadow-[0_1px_0_rgba(255,255,255,0.02)] backdrop-blur-md">
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
<div className="mr-2 hidden items-center gap-2 border-r border-border pr-3 xl:flex"> <div className="mr-2 hidden items-center gap-2 border-r border-border pr-3 xl:flex">
<span className="grid h-7 w-7 place-items-center rounded-lg border border-accent/25 bg-accent-soft text-accent shadow-sm"> <span className="grid h-7 w-7 place-items-center rounded-lg border border-accent/25 bg-accent-soft text-accent shadow-sm">
@@ -170,8 +170,8 @@ export function WorkbenchHeader({
<PanelLeft className="h-4 w-4" /> <PanelLeft className="h-4 w-4" />
</IconButton> </IconButton>
<IconButton <IconButton
tooltip={rightOpen ? '隐藏属性面板' : '显示属性面板'} tooltip={rightOpen ? '隐藏右侧面板' : '显示右侧面板'}
aria-label={rightOpen ? '隐藏属性面板' : '显示属性面板'} aria-label={rightOpen ? '隐藏右侧面板' : '显示右侧面板'}
aria-expanded={rightOpen} aria-expanded={rightOpen}
onClick={onToggleRight} onClick={onToggleRight}
> >
@@ -0,0 +1,80 @@
import type { ComponentProps } from 'react';
import { render, screen } from '@testing-library/react';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import { WorkspaceToolsPanel } from './WorkspaceToolsPanel';
const snapshot = {
actuators: [],
joints: [],
controller: { enabled: true },
rlPolicy: { enabled: false },
} as unknown as SimulationSnapshot;
const noop = () => {};
function props(): ComponentProps<typeof WorkspaceToolsPanel> {
return {
active: 'controls',
snapshot,
loading: false,
ignoreJointLimits: false,
jointAdvanced: false,
angleUnit: 'rad',
forceScale: 50,
controllerPaths: [],
controllerStatus: { enabled: true } as ComponentProps<
typeof WorkspaceToolsPanel
>['controllerStatus'],
policyPaths: [],
policyStatus: { enabled: false } as ComponentProps<typeof WorkspaceToolsPanel>['policyStatus'],
onResetJoints: noop,
onToggleJointLimits: noop,
onToggleAdvanced: noop,
onToggleAngleUnit: noop,
onActuator: noop,
onActuatorParameters: noop,
onJoint: noop,
onForceScale: noop,
onSelectControllerPath: noop,
onLoadControllerPath: noop,
onImportController: noop,
onToggleController: noop,
onControllerCommand: noop,
onRemoveController: noop,
onSelectPolicyPath: noop,
onLoadPolicyPath: noop,
onImportPolicy: noop,
onTogglePolicy: noop,
onPolicyCommand: noop,
onRemovePolicy: noop,
onDataRecorderConfigure: noop,
onDataRecordingStart: noop,
onDataRecordingStop: noop,
onDataRecordingClear: noop,
onDataRecordingExport: noop,
};
}
describe('WorkspaceToolsPanel', () => {
it('按实时控制到自动化流程排序,并默认折叠所有控制台组件', () => {
render(<WorkspaceToolsPanel {...props()} />);
const sections = screen
.getAllByRole('button')
.filter((button) => button.hasAttribute('aria-expanded'));
const expected = [
['执行器实时控制', '控制'],
['关节姿态调试', '调试'],
['外力交互参数', '交互'],
['Python 脚本控制', '控制', '运行'],
['ONNX 策略运行', '推理', '停止'],
['强化学习任务', '训练'],
];
expect(sections).toHaveLength(expected.length);
sections.forEach((section, index) => {
expect(section).toHaveAttribute('aria-expanded', 'false');
expected[index].forEach((label) => expect(section).toHaveTextContent(label));
});
});
});
@@ -0,0 +1,295 @@
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
import { PythonControllerPanel } from '../../controller/PythonControllerPanel';
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
import { RLPolicyPanel } from '../../rl/RLPolicyPanel';
import { ActuatorControl, ControlSlider } from '../../simulation/ActuatorControl';
import type { ActuatorParameters, SimulationSnapshot } from '../../simulation/SimulationSession';
import type { DataRecorderConfig } from '../../telemetry/DataRecorder';
import { DataRecordingPanel } from '../../telemetry/DataRecordingPanel';
import { LocalTrainingPanel } from '../../training/LocalTrainingPanel';
import { Badge, Button, CollapsibleSection } from '../../components/ui';
import type { RightSidebarView } from './RightSidebarTabs';
export type WorkspaceTool = Exclude<RightSidebarView, 'inspector'>;
function ConsoleSectionBadges({
category,
count,
state,
running = false,
}: {
category: string;
count?: number;
state?: string;
running?: boolean;
}) {
return (
<span className="flex shrink-0 items-center gap-1">
<Badge tone="accent">{category}</Badge>
{count !== undefined && <Badge>{count}</Badge>}
{state && <Badge tone={running ? 'success' : 'neutral'}>{state}</Badge>}
</span>
);
}
export function WorkspaceToolsPanel({
active,
snapshot,
loading,
ignoreJointLimits,
jointAdvanced,
angleUnit,
forceScale,
controllerPaths,
selectedControllerPath,
controllerStatus,
policyPaths,
selectedPolicyPath,
policyStatus,
onResetJoints,
onToggleJointLimits,
onToggleAdvanced,
onToggleAngleUnit,
onActuator,
onActuatorParameters,
onJoint,
onForceScale,
onSelectControllerPath,
onLoadControllerPath,
onImportController,
onToggleController,
onControllerCommand,
onRemoveController,
onSelectPolicyPath,
onLoadPolicyPath,
onImportPolicy,
onTogglePolicy,
onPolicyCommand,
onRemovePolicy,
onDataRecorderConfigure,
onDataRecordingStart,
onDataRecordingStop,
onDataRecordingClear,
onDataRecordingExport,
}: {
active: WorkspaceTool;
snapshot?: SimulationSnapshot;
loading: boolean;
ignoreJointLimits: boolean;
jointAdvanced: boolean;
angleUnit: 'rad' | 'deg';
forceScale: number;
controllerPaths: string[];
selectedControllerPath?: string;
controllerStatus?: ControllerStatus;
policyPaths: string[];
selectedPolicyPath?: string;
policyStatus?: RLPolicyStatus;
onResetJoints: () => void;
onToggleJointLimits: () => void;
onToggleAdvanced: () => void;
onToggleAngleUnit: () => void;
onActuator: (id: number, value: number) => void;
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
onJoint: (id: number, value: number) => void;
onForceScale: (value: number) => void;
onSelectControllerPath: (path: string) => void;
onLoadControllerPath: (path: string) => void;
onImportController: (file: File) => void;
onToggleController: (enabled: boolean) => void;
onControllerCommand: (command: ControllerCommand) => void;
onRemoveController: () => void;
onSelectPolicyPath: (path: string) => void;
onLoadPolicyPath: (path: string) => void;
onImportPolicy: (file: File) => void;
onTogglePolicy: (enabled: boolean) => void;
onPolicyCommand: (command: RLCommand) => void;
onRemovePolicy: () => void;
onDataRecorderConfigure: (patch: Partial<DataRecorderConfig>) => void;
onDataRecordingStart: () => void;
onDataRecordingStop: () => void;
onDataRecordingClear: () => void;
onDataRecordingExport: (format: 'csv' | 'json') => void;
}) {
const resolvedControllerStatus = controllerStatus ?? snapshot?.controller;
const resolvedPolicyStatus = policyStatus ?? snapshot?.rlPolicy;
const controls = snapshot ? (
<>
<CollapsibleSection
title="执行器实时控制"
defaultOpen={false}
badge={<ConsoleSectionBadges category="控制" count={snapshot.actuators.length} />}
>
{snapshot.actuators.length ? (
snapshot.actuators.map((actuator) => (
<ActuatorControl
key={actuator.id}
actuator={actuator}
onControl={(value) => onActuator(actuator.id, value)}
onParameters={(parameters) => onActuatorParameters(actuator.id, parameters)}
/>
))
) : (
<p className="text-xs text-text-tertiary">模型没有驱动器</p>
)}
</CollapsibleSection>
<CollapsibleSection
title="关节姿态调试"
defaultOpen={false}
badge={<ConsoleSectionBadges category="调试" count={snapshot.joints.length} />}
>
<div className="mb-4 grid grid-cols-2 gap-2">
<Button onClick={onResetJoints}>重置关节</Button>
<Button
variant={ignoreJointLimits ? 'primary' : 'secondary'}
aria-pressed={ignoreJointLimits}
onClick={onToggleJointLimits}
>
忽略关节限位
</Button>
<Button
variant={jointAdvanced ? 'primary' : 'secondary'}
aria-pressed={jointAdvanced}
onClick={onToggleAdvanced}
>
高级
</Button>
<Button
variant={angleUnit === 'deg' ? 'primary' : 'secondary'}
aria-pressed={angleUnit === 'deg'}
onClick={onToggleAngleUnit}
>
{angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
</Button>
</div>
{snapshot.joints.map((joint) => {
const scale = joint.type === 3 && angleUnit === 'deg' ? 180 / Math.PI : 1;
const unit =
joint.type === 3 ? (angleUnit === 'deg' ? '°' : ' rad') : joint.type === 2 ? ' m' : '';
return (
<ControlSlider
key={joint.id}
label={`${joint.name}${joint.editable ? '' : '(只读)'}`}
value={joint.value * scale}
min={joint.min * scale}
max={joint.max * scale}
unit={unit}
advanced={jointAdvanced}
limited={joint.limited}
limitsIgnored={joint.limitsIgnored}
limitMin={joint.limitMin * scale}
limitMax={joint.limitMax * scale}
disabled={!joint.editable}
onChange={(value) => onJoint(joint.id, value / scale)}
/>
);
})}
</CollapsibleSection>
<CollapsibleSection
title="外力交互参数"
defaultOpen={false}
badge={<ConsoleSectionBadges category="交互" />}
>
<ControlSlider
label={`${forceScale.toFixed(0)} N/屏幕单位`}
value={forceScale}
min={5}
max={200}
onChange={onForceScale}
/>
<p className="text-xs text-text-tertiary">
选择“外力施加”,在动态物体上按住拖动,松开即清零。
</p>
</CollapsibleSection>
<CollapsibleSection
title="Python 脚本控制"
defaultOpen={false}
badge={
<ConsoleSectionBadges
category="控制"
state={
resolvedControllerStatus
? resolvedControllerStatus.enabled
? '运行'
: '停止'
: undefined
}
running={Boolean(resolvedControllerStatus?.enabled)}
/>
}
>
<PythonControllerPanel
paths={controllerPaths}
selectedPath={selectedControllerPath}
status={resolvedControllerStatus}
loading={loading}
onSelectPath={onSelectControllerPath}
onLoadPath={onLoadControllerPath}
onImport={onImportController}
onToggle={onToggleController}
onCommand={onControllerCommand}
onRemove={onRemoveController}
/>
</CollapsibleSection>
<CollapsibleSection
title="ONNX 策略运行"
defaultOpen={false}
badge={
<ConsoleSectionBadges
category="推理"
state={
resolvedPolicyStatus ? (resolvedPolicyStatus.enabled ? '运行' : '停止') : undefined
}
running={Boolean(resolvedPolicyStatus?.enabled)}
/>
}
>
<RLPolicyPanel
paths={policyPaths}
selectedPath={selectedPolicyPath}
status={resolvedPolicyStatus}
loading={loading}
onSelectPath={onSelectPolicyPath}
onLoadPath={onLoadPolicyPath}
onImport={onImportPolicy}
onToggle={onTogglePolicy}
onCommand={onPolicyCommand}
onRemove={onRemovePolicy}
/>
</CollapsibleSection>
<CollapsibleSection
title="强化学习任务"
defaultOpen={false}
badge={<ConsoleSectionBadges category="训练" />}
>
<LocalTrainingPanel onPolicyReady={onImportPolicy} />
</CollapsibleSection>
</>
) : (
<p className="p-4 text-sm text-text-tertiary">导入模型后显示控制工具</p>
);
return (
<div
role="tabpanel"
aria-label={active === 'controls' ? '控制台' : '数据录制'}
className="min-h-0 flex-1 overflow-y-auto panel-scroll"
>
{active === 'controls' ? (
controls
) : snapshot ? (
<DataRecordingPanel
status={snapshot.telemetry}
bodies={snapshot.bodies}
onConfigure={onDataRecorderConfigure}
onStart={onDataRecordingStart}
onStop={onDataRecordingStop}
onClear={onDataRecordingClear}
onExport={onDataRecordingExport}
/>
) : (
<p className="p-4 text-sm text-text-tertiary">导入模型后显示数据录制工具</p>
)}
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
export type EditorSelection =
| { kind: 'body'; bodyId: number }
| { kind: 'joint'; jointId: number; bodyId: number }
| { kind: 'map'; mapAssetId: string }
| { kind: 'map-object'; mapAssetId: string; objectId: string };
export function isMapEditorSelection(
selection: EditorSelection | null,
): selection is Extract<EditorSelection, { kind: 'map' | 'map-object' }> {
return selection?.kind === 'map' || selection?.kind === 'map-object';
}
@@ -0,0 +1,103 @@
import { render } from '@testing-library/react';
import { useMapEditorShortcuts, type MapEditorShortcutOptions } from './useMapEditorShortcuts';
function Harness({ options }: { options: MapEditorShortcutOptions }) {
useMapEditorShortcuts(options);
return <input aria-label="数值字段" />;
}
function press(code: string, init: KeyboardEventInit = {}): KeyboardEvent {
const event = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
code,
key: init.key ?? code.replace('Key', '').toLowerCase(),
...init,
});
window.dispatchEvent(event);
return event;
}
function options(patch: Partial<MapEditorShortcutOptions> = {}): MapEditorShortcutOptions {
return {
enabled: true,
mapEditing: true,
dirty: true,
loading: false,
hasSelection: true,
canDelete: true,
canScale: true,
onTransformMode: vi.fn(),
onFocusSelection: vi.fn(),
onDeleteSelection: vi.fn(),
onToggleSnapping: vi.fn(),
onSave: vi.fn(),
...patch,
};
}
describe('useMapEditorShortcuts', () => {
it('处理 W/E/R、F、Delete 与 Ctrl+S', () => {
const value = options();
render(<Harness options={value} />);
press('KeyW');
press('KeyE');
press('KeyR');
press('KeyF');
press('KeyG');
press('Delete', { key: 'Delete' });
const saveEvent = press('KeyS', { key: 's', ctrlKey: true });
expect(value.onTransformMode).toHaveBeenNthCalledWith(1, 'translate');
expect(value.onTransformMode).toHaveBeenNthCalledWith(2, 'rotate');
expect(value.onTransformMode).toHaveBeenNthCalledWith(3, 'scale');
expect(value.onFocusSelection).toHaveBeenCalledOnce();
expect(value.onDeleteSelection).toHaveBeenCalledOnce();
expect(value.onToggleSnapping).toHaveBeenCalledOnce();
expect(value.onSave).toHaveBeenCalledOnce();
expect(saveEvent.defaultPrevented).toBe(true);
});
it('文本输入时避免误触变换和删除,但仍允许保存', () => {
const value = options();
const view = render(<Harness options={value} />);
const input = view.getByLabelText('数值字段');
input.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Delete',
code: 'Delete',
}),
);
input.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 's',
code: 'KeyS',
ctrlKey: true,
}),
);
expect(value.onDeleteSelection).not.toHaveBeenCalled();
expect(value.onSave).toHaveBeenCalledOnce();
});
it('非地图上下文仍支持 F 聚焦,但不劫持变换键', () => {
const value = options({ mapEditing: false });
render(<Harness options={value} />);
press('KeyF');
const transformEvent = press('KeyW');
expect(value.onFocusSelection).toHaveBeenCalledOnce();
expect(value.onTransformMode).not.toHaveBeenCalled();
expect(transformEvent.defaultPrevented).toBe(false);
});
it('缩放不可用时消费 R 但不切换模式', () => {
const value = options({ canScale: false });
render(<Harness options={value} />);
const event = press('KeyR');
expect(value.onTransformMode).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
});
});
@@ -0,0 +1,101 @@
import { useEffect } from 'react';
import type { MapEditorTransformMode } from '../../map/editor/types';
export interface MapEditorShortcutOptions {
enabled: boolean;
mapEditing: boolean;
dirty: boolean;
loading: boolean;
hasSelection: boolean;
canDelete: boolean;
canScale: boolean;
onTransformMode: (mode: MapEditorTransformMode) => void;
onFocusSelection: () => void;
onDeleteSelection: () => void;
onToggleSnapping: () => void;
onSave: () => void;
}
function isTextEditingTarget(target: EventTarget | null): boolean {
return (
target instanceof HTMLElement &&
(target.matches('input, textarea, select') || target.isContentEditable)
);
}
/**
* 地图编辑快捷键的唯一监听入口。
*
* W/E/R: 平移/旋转/缩放,F: 聚焦,Delete: 删除,Ctrl/Cmd+S: 提交草稿。
* 对话框优先持有键盘;文本输入期间只保留 Ctrl/Cmd+S,避免误删对象。
*/
export function useMapEditorShortcuts({
enabled,
mapEditing,
dirty,
loading,
hasSelection,
canDelete,
canScale,
onTransformMode,
onFocusSelection,
onDeleteSelection,
onToggleSnapping,
onSave,
}: MapEditorShortcutOptions): void {
useEffect(() => {
if (!enabled) return;
const keydown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.isComposing) return;
const target = event.target;
if (target instanceof HTMLElement && target.closest('[role="dialog"]')) return;
const save =
(mapEditing || dirty) && (event.ctrlKey || event.metaKey) && event.code === 'KeyS';
if (save) {
event.preventDefault();
event.stopImmediatePropagation();
if (!loading && dirty && !event.repeat) onSave();
return;
}
if (loading || isTextEditingTarget(target) || event.ctrlKey || event.metaKey || event.altKey)
return;
if (event.code === 'KeyF' && hasSelection) {
event.preventDefault();
event.stopImmediatePropagation();
onFocusSelection();
return;
}
if (!mapEditing) return;
let handled = true;
if (event.code === 'KeyW') onTransformMode('translate');
else if (event.code === 'KeyE') onTransformMode('rotate');
else if (event.code === 'KeyR') {
if (canScale) onTransformMode('scale');
} else if (event.code === 'KeyG') onToggleSnapping();
else if (event.key === 'Delete' && canDelete && !event.repeat) onDeleteSelection();
else handled = false;
if (handled) {
event.preventDefault();
event.stopImmediatePropagation();
}
};
window.addEventListener('keydown', keydown, true);
return () => window.removeEventListener('keydown', keydown, true);
}, [
enabled,
mapEditing,
dirty,
loading,
hasSelection,
canDelete,
canScale,
onTransformMode,
onFocusSelection,
onDeleteSelection,
onToggleSnapping,
onSave,
]);
}
+1 -1
View File
@@ -19,7 +19,7 @@ export function Badge({
return ( return (
<span <span
title={title} title={title}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${toneClass} ${className}`} className={`engineering-badge inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${toneClass} ${className}`}
> >
{children} {children}
</span> </span>
+1 -1
View File
@@ -30,7 +30,7 @@ export function Button({
return ( return (
<button <button
type={type} type={type}
className={`inline-flex shrink-0 select-none items-center justify-center border font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()} className={`inline-flex shrink-0 select-none items-center justify-center border font-medium transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:hover:-translate-y-px enabled:active:translate-y-0 enabled:active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()}
{...props} {...props}
> >
{icon && ( {icon && (
@@ -16,7 +16,7 @@ export function IconButton({
<button <button
type={type} type={type}
aria-pressed={active || undefined} aria-pressed={active || undefined}
className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${active ? 'border-accent/40 bg-accent-soft text-accent' : 'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()} className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${active ? 'tool-active-glow border-accent/40 bg-accent-soft text-accent' : 'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()}
{...props} {...props}
/> />
); );
@@ -106,7 +106,7 @@ export function ResizablePanel({
<button <button
type="button" type="button"
role="separator" role="separator"
aria-label={side === 'left' ? '调整工程面板宽度' : '调整属性面板宽度'} aria-label={side === 'left' ? '调整工程面板宽度' : '调整右侧面板宽度'}
aria-orientation="vertical" aria-orientation="vertical"
aria-valuemin={minWidth} aria-valuemin={minWidth}
aria-valuemax={Math.round(panelMaxWidth(minWidth))} aria-valuemax={Math.round(panelMaxWidth(minWidth))}
@@ -0,0 +1,45 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { ScrubbableNumberInput } from './ScrubbableNumberInput';
describe('ScrubbableNumberInput', () => {
it('保留标准数值输入行为', () => {
const onValueChange = vi.fn();
render(
<ScrubbableNumberInput label="位置 X" value={1} step={0.1} onValueChange={onValueChange} />,
);
fireEvent.change(screen.getByLabelText('位置 X'), { target: { value: '2.5' } });
expect(onValueChange).toHaveBeenCalledWith(2.5);
});
it('在标签上水平拖拽并支持 Shift 精调', () => {
const onValueChange = vi.fn();
render(
<ScrubbableNumberInput
label="摩擦力"
value={1}
step={0.1}
min={0}
onValueChange={onValueChange}
/>,
);
const handle = screen.getByRole('button', { name: '拖拽调节数值' });
fireEvent.pointerDown(handle, { button: 0, pointerId: 3, clientX: 20 });
fireEvent.pointerMove(window, { pointerId: 3, clientX: 60 });
expect(onValueChange).toHaveBeenLastCalledWith(2);
fireEvent.pointerMove(window, { pointerId: 3, clientX: 80, shiftKey: true });
expect(onValueChange).toHaveBeenLastCalledWith(1.15);
fireEvent.pointerUp(window, { pointerId: 3, clientX: 80 });
expect(document.body).not.toHaveClass('is-scrubbing-number');
});
it('通过键盘箭头进行无鼠标调节', () => {
const onValueChange = vi.fn();
render(
<ScrubbableNumberInput label="尺寸" value={2} step={0.5} onValueChange={onValueChange} />,
);
fireEvent.keyDown(screen.getByRole('button', { name: '拖拽调节数值' }), {
key: 'ArrowRight',
});
expect(onValueChange).toHaveBeenCalledWith(2.5);
});
});
@@ -0,0 +1,190 @@
import {
useEffect,
useId,
useRef,
useState,
type InputHTMLAttributes,
type PointerEvent as ReactPointerEvent,
} from 'react';
import { GripHorizontal } from 'lucide-react';
function clamp(value: number, min?: number, max?: number): number {
return Math.min(
max ?? Number.POSITIVE_INFINITY,
Math.max(min ?? Number.NEGATIVE_INFINITY, value),
);
}
function decimals(step: number): number {
const source = String(step);
if (source.includes('e-')) return Math.min(8, Number(source.split('e-')[1]));
return Math.min(8, source.split('.')[1]?.length ?? 0);
}
function normalize(value: number, step: number, min?: number, max?: number): number {
const bounded = clamp(value, min, max);
return Number(bounded.toFixed(decimals(step)));
}
export interface ScrubbableNumberInputProps extends Omit<
InputHTMLAttributes<HTMLInputElement>,
'type' | 'value' | 'defaultValue' | 'step' | 'min' | 'max' | 'onChange'
> {
label: string;
value: number;
step?: number;
min?: number;
max?: number;
onValueChange: (value: number) => void;
containerClassName?: string;
labelClassName?: string;
inputClassName?: string;
}
/**
* 工程编辑器数值输入:可键入,也可在字段标签上水平拖拽。
* Shift 为精细调节,Alt 为超精细调节,Ctrl/Meta 为粗调。
*/
export function ScrubbableNumberInput({
label,
value,
step = 0.1,
min,
max,
disabled,
onValueChange,
containerClassName = '',
labelClassName = '',
inputClassName = '',
'aria-label': ariaLabel,
onFocus: onInputFocus,
onBlur: onInputBlur,
...inputProps
}: ScrubbableNumberInputProps) {
const [text, setText] = useState(() => String(value));
const [scrubbing, setScrubbing] = useState(false);
const labelId = useId();
const editing = useRef(false);
const cleanup = useRef<() => void>(() => {});
useEffect(() => {
if (!editing.current && !scrubbing) setText(String(value));
}, [value, scrubbing]);
useEffect(
() => () => {
cleanup.current();
},
[],
);
const emit = (next: number, quantizationStep = step) => {
const normalized = normalize(next, quantizationStep, min, max);
setText(String(normalized));
onValueChange(normalized);
};
const startScrub = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (disabled || event.button !== 0) return;
event.preventDefault();
cleanup.current();
const startX = event.clientX;
const startValue = Number.isFinite(value) ? value : 0;
const pointerId = event.pointerId;
setScrubbing(true);
document.body.classList.add('is-scrubbing-number');
const move = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
const modifier = moveEvent.altKey
? 0.01
: moveEvent.shiftKey
? 0.1
: moveEvent.ctrlKey || moveEvent.metaKey
? 10
: 1;
emit(startValue + (moveEvent.clientX - startX) * step * 0.25 * modifier, step * modifier);
};
const stop = (stopEvent: PointerEvent) => {
if (stopEvent.pointerId !== pointerId) return;
cleanup.current();
};
cleanup.current = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', stop);
window.removeEventListener('pointercancel', stop);
document.body.classList.remove('is-scrubbing-number');
setScrubbing(false);
cleanup.current = () => {};
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop);
window.addEventListener('pointercancel', stop);
};
return (
<div
className={`scrubbable-number ${scrubbing ? 'scrubbable-number-active' : ''} ${containerClassName}`.trim()}
>
<button
type="button"
className={`scrubbable-number-label ${labelClassName}`.trim()}
aria-label="拖拽调节数值"
aria-describedby={labelId}
title={`拖拽调节 ${label};Shift 精调,Alt 超精调,Ctrl 粗调`}
disabled={disabled}
onPointerDown={startScrub}
onKeyDown={(event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
const modifier = event.altKey
? 0.01
: event.shiftKey
? 0.1
: event.ctrlKey || event.metaKey
? 10
: 1;
emit(value + (event.key === 'ArrowRight' ? 1 : -1) * step * modifier, step * modifier);
}}
>
<span id={labelId} className="min-w-0 truncate">
{label}
</span>
<GripHorizontal className="h-3 w-3 shrink-0 opacity-45" aria-hidden="true" />
</button>
<input
{...inputProps}
aria-label={ariaLabel ?? label}
type="number"
inputMode="decimal"
value={text}
step={step}
min={min}
max={max}
disabled={disabled}
className={`scrubbable-number-input ${inputClassName}`.trim()}
onFocus={(event) => {
editing.current = true;
onInputFocus?.(event);
}}
onChange={(event) => {
const nextText = event.currentTarget.value;
setText(nextText);
const next = Number(nextText);
if (nextText !== '' && Number.isFinite(next)) onValueChange(clamp(next, min, max));
}}
onBlur={(event) => {
editing.current = false;
const next = Number(text);
if (text !== '' && Number.isFinite(next)) emit(next);
else setText(String(value));
onInputBlur?.(event);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
inputProps.onKeyDown?.(event);
}}
/>
</div>
);
}
@@ -0,0 +1,55 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { VerticalSplitPane } from './VerticalSplitPane';
const storageKey = 'test-vertical-split-ratio';
function renderSplit() {
render(
<VerticalSplitPane
first={<div>上方内容</div>}
second={<div>下方内容</div>}
storageKey={storageKey}
separatorLabel="调整上下区域高度"
defaultRatio={0.4}
/>,
);
const separator = screen.getByRole('separator', { name: '调整上下区域高度' });
Object.defineProperty(separator.parentElement, 'clientHeight', {
configurable: true,
value: 600,
});
return separator;
}
describe('VerticalSplitPane', () => {
beforeEach(() => localStorage.removeItem(storageKey));
it('支持通过指针上下拖动并持久化分割比例', () => {
const separator = renderSplit();
fireEvent.pointerDown(separator, { button: 0, clientY: 200, pointerId: 1 });
fireEvent.pointerMove(window, { clientY: 320, pointerId: 1 });
expect(separator).toHaveAttribute('aria-valuenow', '60');
expect(
Number.parseFloat((separator.previousElementSibling as HTMLElement).style.flexBasis),
).toBeCloseTo(60);
fireEvent.pointerUp(window, { pointerId: 1 });
expect(Number(localStorage.getItem(storageKey))).toBeCloseTo(0.6);
expect(document.body.style.cursor).toBe('');
});
it('支持键盘调整、边界限制和双击复位', () => {
const separator = renderSplit();
fireEvent.keyDown(separator, { key: 'End' });
expect(separator).toHaveAttribute('aria-valuenow', '72');
fireEvent.keyDown(separator, { key: 'Home' });
expect(separator).toHaveAttribute('aria-valuenow', '27');
fireEvent.doubleClick(separator);
expect(separator).toHaveAttribute('aria-valuenow', '40');
expect(Number(localStorage.getItem(storageKey))).toBeCloseTo(0.4);
});
});
@@ -0,0 +1,210 @@
import {
useEffect,
useId,
useRef,
useState,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react';
const HANDLE_SIZE = 8;
const FALLBACK_MIN_RATIO = 0.15;
const FALLBACK_MAX_RATIO = 0.85;
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
function storedRatio(storageKey: string, fallback: number): number {
try {
const value = Number(localStorage.getItem(storageKey));
return clamp(
Number.isFinite(value) && value > 0 ? value : fallback,
FALLBACK_MIN_RATIO,
FALLBACK_MAX_RATIO,
);
} catch {
return clamp(fallback, FALLBACK_MIN_RATIO, FALLBACK_MAX_RATIO);
}
}
function ratioBounds(height: number, minFirstSize: number, minSecondSize: number) {
if (height <= HANDLE_SIZE) {
return { min: FALLBACK_MIN_RATIO, max: FALLBACK_MAX_RATIO };
}
const min = minFirstSize / height;
const max = (height - HANDLE_SIZE - minSecondSize) / height;
if (min <= max) return { min, max };
const available = Math.max(0, height - HANDLE_SIZE);
const totalMinimum = minFirstSize + minSecondSize;
const shared = totalMinimum > 0 ? (available * minFirstSize) / totalMinimum / height : 0.5;
return { min: shared, max: shared };
}
export function VerticalSplitPane({
first,
second,
storageKey,
separatorLabel,
defaultRatio = 0.5,
minFirstSize = 160,
minSecondSize = 160,
className = '',
firstClassName = '',
secondClassName = '',
}: {
first: ReactNode;
second: ReactNode;
storageKey: string;
separatorLabel: string;
defaultRatio?: number;
minFirstSize?: number;
minSecondSize?: number;
className?: string;
firstClassName?: string;
secondClassName?: string;
}) {
const [ratio, setRatio] = useState(() => storedRatio(storageKey, defaultRatio));
const [dragging, setDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const ratioRef = useRef(ratio);
const cleanupRef = useRef<() => void>(() => {});
const id = useId();
const constrain = (next: number, height = containerRef.current?.clientHeight ?? 0) => {
const bounds = ratioBounds(height, minFirstSize, minSecondSize);
return clamp(next, bounds.min, bounds.max);
};
const persist = (value: number) => {
try {
localStorage.setItem(storageKey, String(value));
} catch {
/* 无持久化权限时仍可调整 */
}
};
const update = (next: number, save = false) => {
const value = constrain(next);
ratioRef.current = value;
setRatio(value);
if (save) persist(value);
};
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver(() => {
if (container.clientHeight <= HANDLE_SIZE) return;
const bounds = ratioBounds(container.clientHeight, minFirstSize, minSecondSize);
const value = clamp(ratioRef.current, bounds.min, bounds.max);
if (value === ratioRef.current) return;
ratioRef.current = value;
setRatio(value);
});
observer.observe(container);
return () => observer.disconnect();
}, [minFirstSize, minSecondSize]);
useEffect(
() => () => {
cleanupRef.current();
},
[],
);
const start = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) return;
event.preventDefault();
cleanupRef.current();
const container = containerRef.current;
if (!container) return;
const origin = event.clientY;
const startRatio = ratioRef.current;
const height = container.clientHeight;
const pointerId = event.pointerId;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
setDragging(true);
const move = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
moveEvent.preventDefault();
update(startRatio + (moveEvent.clientY - origin) / Math.max(height, 1));
};
const stop = (stopEvent: PointerEvent) => {
if (stopEvent.pointerId !== pointerId) return;
persist(ratioRef.current);
cleanup();
};
const cleanup = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', stop);
window.removeEventListener('pointercancel', stop);
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
setDragging(false);
cleanupRef.current = () => {};
};
cleanupRef.current = cleanup;
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop);
window.addEventListener('pointercancel', stop);
};
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
const height = containerRef.current?.clientHeight ?? 0;
const bounds = ratioBounds(height, minFirstSize, minSecondSize);
const step = (event.shiftKey ? 48 : 16) / Math.max(height, 320);
let next: number;
if (event.key === 'Home') next = bounds.min;
else if (event.key === 'End') next = bounds.max;
else if (event.key === 'ArrowUp') next = ratioRef.current - step;
else if (event.key === 'ArrowDown') next = ratioRef.current + step;
else return;
event.preventDefault();
update(next, true);
};
return (
<div ref={containerRef} className={`flex min-h-0 flex-1 flex-col ${className}`}>
<div
id={`${id}-first`}
className={`min-h-0 shrink-0 overflow-hidden ${firstClassName}`}
style={{ flexBasis: `${ratio * 100}%` }}
>
{first}
</div>
<button
type="button"
role="separator"
aria-label={separatorLabel}
aria-orientation="horizontal"
aria-controls={`${id}-first ${id}-second`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(ratio * 100)}
aria-valuetext={`上方区域占 ${Math.round(ratio * 100)}%`}
data-dragging={dragging || undefined}
onPointerDown={start}
onKeyDown={onKeyDown}
onDoubleClick={() => update(defaultRatio, true)}
className="group relative z-20 h-2 w-full shrink-0 touch-none select-none cursor-row-resize bg-transparent outline-none before:absolute before:inset-x-0 before:top-1/2 before:h-px before:-translate-y-1/2 before:bg-border before:transition-colors hover:before:bg-accent focus-visible:before:h-0.5 focus-visible:before:bg-accent data-[dragging=true]:before:h-0.5 data-[dragging=true]:before:bg-accent"
>
<span className="absolute left-1/2 top-1/2 h-1 w-9 -translate-x-1/2 -translate-y-1/2 rounded-full border border-border bg-panel transition-colors group-hover:border-accent group-focus-visible:border-accent group-data-[dragging=true]:border-accent group-data-[dragging=true]:bg-accent-soft" />
</button>
<div id={`${id}-second`} className={`min-h-0 flex-1 overflow-hidden ${secondClassName}`}>
{second}
</div>
</div>
);
}
+2
View File
@@ -10,6 +10,7 @@ export * from './Separator';
export * from './Skeleton'; export * from './Skeleton';
export * from './Tabs'; export * from './Tabs';
export * from './ResizablePanel'; export * from './ResizablePanel';
export * from './VerticalSplitPane';
export * from './Kbd'; export * from './Kbd';
export * from './PropertyRow'; export * from './PropertyRow';
export * from './CopyButton'; export * from './CopyButton';
@@ -22,3 +23,4 @@ export * from './DropdownMenu';
export * from './ProgressBar'; export * from './ProgressBar';
export * from './LiveRegion'; export * from './LiveRegion';
export * from './SearchableCombobox'; export * from './SearchableCombobox';
export * from './ScrubbableNumberInput';
+84 -6
View File
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { MAP_ASSET_DRAG_MIME, MAP_ASSET_PLACEMENT_MIME } from './editor/assetCatalog'; import { decodeMapLibraryDragPayload, MAP_LIBRARY_DRAG_MIME } from './editor/assetCatalog';
import { MapAssetLibrary } from './MapAssetLibrary'; import { MapAssetLibrary } from './MapAssetLibrary';
const renderLibrary = ( const renderLibrary = (
@@ -16,7 +16,53 @@ const renderLibrary = (
); );
describe('MapAssetLibrary', () => { describe('MapAssetLibrary', () => {
it('按所选放置方式添加认证资产', () => { it('只负责地图包、物理原语和预设地形的放置入口', () => {
expect(decodeMapLibraryDragPayload('not-json')).toBeNull();
expect(decodeMapLibraryDragPayload('{"kind":"terrain","preset":"unknown"}')).toBeNull();
renderLibrary();
const library = screen.getByLabelText('地图资产库');
expect(library).toHaveTextContent('场景资产库');
expect(library).toHaveTextContent('物理几何原语');
expect(library).toHaveTextContent('预设地形');
expect(library).not.toHaveTextContent('已放置地图');
});
it('地图包使用与资产卡片一致的添加和拖放入口', () => {
const values = new Map<string, string>();
const onAddProjectMap = vi.fn();
const dataTransfer = {
effectAllowed: 'none',
setData: (type: string, value: string) => values.set(type, value),
} as unknown as DataTransfer;
render(
<MapAssetLibrary
disabled={false}
terrainSize={8}
maps={[
{
descriptorPath: 'maps/warehouse/map.json',
schemaVersion: 1,
id: 'warehouse',
name: '仓库',
spawnPoints: [],
},
]}
onAdd={() => {}}
onAddProjectMap={onAddProjectMap}
onSelectTerrain={() => {}}
/>,
);
const source = document.querySelector('[data-project-map="maps/warehouse/map.json"]')!;
fireEvent.dragStart(source, { dataTransfer });
fireEvent.click(screen.getByRole('button', { name: '放置工程地图 仓库' }));
expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({
kind: 'project',
descriptorPath: 'maps/warehouse/map.json',
});
expect(onAddProjectMap).toHaveBeenCalledWith('maps/warehouse/map.json');
});
it('按所选放置方式添加物理几何原语', () => {
const onAdd = vi.fn(); const onAdd = vi.fn();
renderLibrary(onAdd); renderLibrary(onAdd);
fireEvent.change(screen.getByLabelText('新增资产放置方式'), { fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
@@ -26,7 +72,26 @@ describe('MapAssetLibrary', () => {
expect(onAdd).toHaveBeenCalledWith('box', 'gravity'); expect(onAdd).toHaveBeenCalledWith('box', 'gravity');
}); });
it('拖动资产时写入类型和放置方式', () => { it('可与视口工具条共享受控放置方式', () => {
const onPlacementModeChange = vi.fn();
render(
<MapAssetLibrary
disabled={false}
terrainSize={8}
placementMode="locked"
onPlacementModeChange={onPlacementModeChange}
onAdd={() => {}}
onSelectTerrain={() => {}}
/>,
);
expect(screen.getByLabelText('新增资产放置方式')).toHaveValue('locked');
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
target: { value: 'gravity' },
});
expect(onPlacementModeChange).toHaveBeenCalledWith('gravity');
});
it('拖动物理原语时写入类型和放置方式', () => {
const values = new Map<string, string>(); const values = new Map<string, string>();
const dataTransfer = { const dataTransfer = {
effectAllowed: 'none', effectAllowed: 'none',
@@ -37,15 +102,28 @@ describe('MapAssetLibrary', () => {
target: { value: 'locked' }, target: { value: 'locked' },
}); });
fireEvent.dragStart(document.querySelector('[data-map-asset="ramp"]')!, { dataTransfer }); fireEvent.dragStart(document.querySelector('[data-map-asset="ramp"]')!, { dataTransfer });
expect(values.get(MAP_ASSET_DRAG_MIME)).toBe('ramp'); expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({
expect(values.get(MAP_ASSET_PLACEMENT_MIME)).toBe('locked'); kind: 'certified',
type: 'ramp',
placementMode: 'locked',
});
}); });
it('以示意图卡片选择系统参数化地形', () => { it('以示意图卡片添加或拖动预设地形', () => {
const onSelectTerrain = vi.fn(); const onSelectTerrain = vi.fn();
const values = new Map<string, string>();
const dataTransfer = {
effectAllowed: 'none',
setData: (type: string, value: string) => values.set(type, value),
} as unknown as DataTransfer;
renderLibrary(undefined, onSelectTerrain); renderLibrary(undefined, onSelectTerrain);
expect(screen.getAllByText('8.00 × 8.00 m')).toHaveLength(9); expect(screen.getAllByText('8.00 × 8.00 m')).toHaveLength(9);
fireEvent.click(screen.getByRole('button', { name: '添加随机粗糙地形' })); fireEvent.click(screen.getByRole('button', { name: '添加随机粗糙地形' }));
fireEvent.dragStart(document.querySelector('[data-system-terrain="wave"]')!, { dataTransfer });
expect(onSelectTerrain).toHaveBeenCalledWith('rough'); expect(onSelectTerrain).toHaveBeenCalledWith('rough');
expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({
kind: 'terrain',
preset: 'wave',
});
}); });
}); });
+95 -40
View File
@@ -1,17 +1,17 @@
import { useState } from 'react'; import { useState } from 'react';
import { BadgeCheck, CheckCircle2, Mountain, Plus } from 'lucide-react'; import { BadgeCheck, CheckCircle2, MapPinned, Mountain, Plus } from 'lucide-react';
import { Button, Select } from '../components/ui'; import { Button, Select } from '../components/ui';
import { import {
CERTIFIED_MAP_ASSETS, CERTIFIED_MAP_ASSETS,
MAP_ASSET_DRAG_MIME, encodeMapLibraryDragPayload,
MAP_ASSET_PLACEMENT_MIME, MAP_LIBRARY_DRAG_MIME,
} from './editor/assetCatalog'; } from './editor/assetCatalog';
import { import {
MAP_OBJECT_PLACEMENT_LABELS, MAP_OBJECT_PLACEMENT_LABELS,
type EditableMapDocument,
type EditableMapObjectType, type EditableMapObjectType,
type MapObjectPlacementMode, type MapObjectPlacementMode,
} from './editor/types'; } from './editor/types';
import type { MapEntry } from '../project/types';
import { import {
PHYSICAL_MAP_PRESET_LABELS, PHYSICAL_MAP_PRESET_LABELS,
SYSTEM_TERRAIN_PRESETS, SYSTEM_TERRAIN_PRESETS,
@@ -135,58 +135,94 @@ function AddAction({ label }: { label: string }) {
export function MapAssetLibrary({ export function MapAssetLibrary({
disabled, disabled,
terrainSize, terrainSize,
document, maps = [],
placementMode: controlledPlacementMode,
onPlacementModeChange,
onAdd, onAdd,
onAddProjectMap,
onSelectTerrain, onSelectTerrain,
onSelectObject,
}: { }: {
disabled: boolean; disabled: boolean;
terrainSize: number; terrainSize: number;
document?: EditableMapDocument | null; maps?: MapEntry[];
placementMode?: MapObjectPlacementMode;
onPlacementModeChange?: (mode: MapObjectPlacementMode) => void;
onAdd: ( onAdd: (
type: EditableMapObjectType, type: EditableMapObjectType,
placementMode: MapObjectPlacementMode, placementMode: MapObjectPlacementMode,
) => void | Promise<void>; ) => void | Promise<void>;
onAddProjectMap?: (descriptorPath: string) => void;
onSelectTerrain: (preset: SystemTerrainPreset) => void; onSelectTerrain: (preset: SystemTerrainPreset) => void;
onSelectObject?: (id: string) => void;
}) { }) {
const [placementMode, setPlacementMode] = useState<MapObjectPlacementMode>('auto_ground'); const [internalPlacementMode, setInternalPlacementMode] =
useState<MapObjectPlacementMode>('auto_ground');
const placementMode = controlledPlacementMode ?? internalPlacementMode;
const changePlacementMode = (mode: MapObjectPlacementMode) => {
setInternalPlacementMode(mode);
onPlacementModeChange?.(mode);
};
return ( return (
<section aria-label="认证资产" className="space-y-3 p-3"> <section aria-label="地图资产库" className="space-y-3 p-3">
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5"> <header>
<div className="flex items-center justify-between text-xs font-medium text-text-primary"> <div className="flex items-center gap-1.5 text-xs font-semibold text-text-primary">
<span>场景结构</span> <MapPinned className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
<span className="technical-value text-[10px] text-text-tertiary"> 场景资产库
{document?.objects.length ?? 0} 个对象
</span>
</div> </div>
{document?.objects.length ? ( <p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
<div className="mt-2 max-h-36 space-y-0.5 overflow-auto panel-scroll"> 从地图包、物理几何原语和预设地形拖放到视口;已放置实例统一在上方场景大纲管理。
{document.objects.map((object) => ( </p>
<button </header>
key={object.id} {maps.length > 0 && (
type="button" <div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] text-text-secondary hover:bg-element-hover hover:text-text-primary" <div className="text-xs font-medium text-text-primary">地图包</div>
onClick={() => onSelectObject?.(object.id)} <p className="mt-1 text-[10px] text-text-tertiary">
点击添加或拖到画布定位;实例位姿独立,源内容由同源实例共享。
</p>
<div className="mt-2 space-y-1">
{maps.map((map) => (
<div
key={map.descriptorPath}
draggable={!disabled}
data-project-map={map.descriptorPath}
className={`flex items-center gap-2 rounded border border-border-subtle px-2 py-1.5 ${disabled ? 'opacity-50' : 'cursor-grab hover:border-accent active:cursor-grabbing'}`}
onDragStart={(event) => {
if (disabled) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData(
MAP_LIBRARY_DRAG_MIME,
encodeMapLibraryDragPayload({
kind: 'project',
descriptorPath: map.descriptorPath,
}),
);
event.dataTransfer.setData('text/plain', map.name);
}}
> >
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent" /> <MapPinned className="h-3.5 w-3.5 shrink-0 text-accent" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{object.name}</span> <span className="min-w-0 flex-1 truncate text-[11px] text-text-secondary">
<span className="text-[9px] text-text-tertiary">{object.type}</span> {map.name}
</button> </span>
<Button
disabled={disabled}
aria-label={`放置工程地图 ${map.name}`}
onClick={() => onAddProjectMap?.(map.descriptorPath)}
>
添加
</Button>
</div>
))} ))}
</div> </div>
) : (
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
选择工程地图后,这里显示场景对象层级;可从下方资产库创建场景。
</p>
)}
</div> </div>
)}
<div className="flex items-end justify-between gap-3"> <div className="flex items-end justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary"> <div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
<BadgeCheck className="h-3.5 w-3.5 text-accent" aria-hidden="true" /> <BadgeCheck className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
认证资产 物理几何原语
</div> </div>
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary"> <p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
点击添加,或按住资产拖到画布落位。 点击添加,或按住资产拖到画布落位。
@@ -199,7 +235,7 @@ export function MapAssetLibrary({
className="mt-1 w-full" className="mt-1 w-full"
value={placementMode} value={placementMode}
disabled={disabled} disabled={disabled}
onChange={(event) => setPlacementMode(event.target.value as MapObjectPlacementMode)} onChange={(event) => changePlacementMode(event.target.value as MapObjectPlacementMode)}
> >
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map((mode) => ( {(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map((mode) => (
<option key={mode} value={mode}> <option key={mode} value={mode}>
@@ -222,8 +258,14 @@ export function MapAssetLibrary({
return; return;
} }
event.dataTransfer.effectAllowed = 'copy'; event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData(MAP_ASSET_DRAG_MIME, asset.type); event.dataTransfer.setData(
event.dataTransfer.setData(MAP_ASSET_PLACEMENT_MIME, placementMode); MAP_LIBRARY_DRAG_MIME,
encodeMapLibraryDragPayload({
kind: 'certified',
type: asset.type,
placementMode,
}),
);
event.dataTransfer.setData('text/plain', asset.name); event.dataTransfer.setData('text/plain', asset.name);
}} }}
> >
@@ -254,18 +296,31 @@ export function MapAssetLibrary({
<div className="border-t border-border-subtle pt-3"> <div className="border-t border-border-subtle pt-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary"> <div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
<Mountain className="h-3.5 w-3.5 text-accent" aria-hidden="true" /> <Mountain className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
系统参数化地形 预设地形
</div> </div>
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary"> <p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
本地确定性生成;点击“添加”后在右侧“地图”调整尺寸、难度和随机种子。 点击添加或拖到画布定位。新增先进入轻量预览,可连续放置后一次编译。
</p> </p>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{SYSTEM_TERRAIN_PRESETS.map((preset) => ( {SYSTEM_TERRAIN_PRESETS.map((preset) => (
<article <article
key={preset} key={preset}
draggable={!disabled}
data-system-terrain={preset} data-system-terrain={preset}
className={`group relative overflow-hidden rounded-lg border border-border-subtle bg-panel-muted shadow-sm transition-colors ${disabled ? 'opacity-50' : 'hover:border-accent'}`} className={`group relative overflow-hidden rounded-lg border border-border-subtle bg-panel-muted shadow-sm transition-colors ${disabled ? 'opacity-50' : 'cursor-grab hover:border-accent active:cursor-grabbing'}`}
onDragStart={(event) => {
if (disabled) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData(
MAP_LIBRARY_DRAG_MIME,
encodeMapLibraryDragPayload({ kind: 'terrain', preset }),
);
event.dataTransfer.setData('text/plain', PHYSICAL_MAP_PRESET_LABELS[preset]);
}}
> >
<div className="absolute right-2 top-2 z-10 rounded-full bg-panel-bg/90 p-0.5 text-success"> <div className="absolute right-2 top-2 z-10 rounded-full bg-panel-bg/90 p-0.5 text-success">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" /> <CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
+33
View File
@@ -87,6 +87,39 @@ describe('composeProjectMap', () => {
expect(result.warnings.join(' ')).toContain('入口'); expect(result.warnings.join(' ')).toContain('入口');
}); });
it('对工程地图物理层和出生点应用同一个实例变换', () => {
const result = composeProjectMap(robot, 'robot/scene.xml', manifest(), definition, {
kind: 'project',
descriptorPath: definition.descriptorPath,
positionX: 2,
positionY: -3,
yawDeg: 90,
spawnPointId: 'door',
});
const document = new DOMParser().parseFromString(
decoder.decode(result.data),
'application/xml',
);
const instanceRoot = document.querySelector(
'body[name="__platform_map_warehouse_instance_root"]',
);
expect(instanceRoot?.getAttribute('pos')).toBe('2 -3 0');
expect(instanceRoot?.getAttribute('quat')?.split(/\s+/).map(Number)).toEqual([
expect.closeTo(Math.SQRT1_2),
0,
0,
expect.closeTo(Math.SQRT1_2),
]);
const position = document
.querySelector('body[name="robot"]')!
.getAttribute('pos')!
.split(/\s+/)
.map(Number);
expect(position[0]).toBeCloseTo(-19);
expect(position[1]).toBeCloseTo(7);
expect(position[2]).toBeCloseTo(0.5);
});
it('地图明确提供地面时替换平台基础地面', () => { it('地图明确提供地面时替换平台基础地面', () => {
const floorMap = manifest( const floorMap = manifest(
'<mujoco><worldbody><geom name="floor" type="box" pos="0 0 -.1" size="5 5 .1"/></worldbody></mujoco>', '<mujoco><worldbody><geom name="floor" type="box" pos="0 0 -.1" size="5 5 .1"/></worldbody></mujoco>',
+56 -5
View File
@@ -1,6 +1,12 @@
import type { ProjectManifest } from '../project/types'; import type { ProjectManifest } from '../project/types';
import { resolveProjectAssetPath, relativeAssetPath } from './mapPaths'; import { resolveProjectAssetPath, relativeAssetPath } from './mapPaths';
import type { MapSelection, ResolvedProjectMap, SpawnPoint } from './types'; import {
mapLocalPointToWorld,
mapSelectionTransform,
type MapSelection,
type ResolvedProjectMap,
type SpawnPoint,
} from './types';
const decoder = new TextDecoder('utf-8'); const decoder = new TextDecoder('utf-8');
const encoder = new TextEncoder(); const encoder = new TextEncoder();
@@ -23,6 +29,13 @@ export interface ProjectMapComposition {
summary: string; summary: string;
} }
export interface ProjectMapCompositionOptions {
/** 场景实例命名空间;允许同一 map.json 被重复放置。 */
instanceId?: string;
/** 多地图场景中只允许一个实例移动机器人到出生点。 */
applySpawnPoint?: boolean;
}
function xml(data: Uint8Array, label: string): Document { function xml(data: Uint8Array, label: string): Document {
const document = new DOMParser().parseFromString(decoder.decode(data), 'application/xml'); const document = new DOMParser().parseFromString(decoder.decode(data), 'application/xml');
if (document.querySelector('parsererror')) throw new Error(`${label} XML 无法解析`); if (document.querySelector('parsererror')) throw new Error(`${label} XML 无法解析`);
@@ -46,6 +59,19 @@ function multiplyQuaternion(a: number[], b: number[]): [number, number, number,
]; ];
} }
function transformSpawnPoint(
spawn: SpawnPoint | undefined,
selection: Extract<MapSelection, { kind: 'project' }>,
): SpawnPoint | undefined {
if (!spawn) return undefined;
const transform = mapSelectionTransform(selection);
return {
...spawn,
position: mapLocalPointToWorld(spawn.position, selection),
yawDeg: spawn.yawDeg + transform.yawDeg,
};
}
function applySpawnPoint( function applySpawnPoint(
document: Document, document: Document,
spawn: SpawnPoint | undefined, spawn: SpawnPoint | undefined,
@@ -145,6 +171,22 @@ function mapProvidesGround(document: Document): boolean {
return false; return false;
} }
function applyMapInstanceTransform(
document: Document,
selection: Extract<MapSelection, { kind: 'project' }>,
): void {
const worldbody = document.querySelector('mujoco > worldbody');
if (!worldbody) throw new Error('物理地图缺少 worldbody');
const transform = mapSelectionTransform(selection);
const root = document.createElement('body');
const halfYaw = (transform.yawDeg * Math.PI) / 360;
root.setAttribute('name', 'instance_root');
root.setAttribute('pos', `${transform.positionX} ${transform.positionY} 0`);
root.setAttribute('quat', `${Math.cos(halfYaw)} 0 0 ${Math.sin(halfYaw)}`);
for (const child of Array.from(worldbody.children)) root.append(child);
worldbody.append(root);
}
function namespaceMap(document: Document, mapId: string): void { function namespaceMap(document: Document, mapId: string): void {
const prefix = `${MAP_PREFIX}${mapId.replace(/[^a-zA-Z0-9_-]/g, '_')}_`; const prefix = `${MAP_PREFIX}${mapId.replace(/[^a-zA-Z0-9_-]/g, '_')}_`;
const names = new Map<string, string>(); const names = new Map<string, string>();
@@ -203,19 +245,22 @@ export function composeProjectMap(
manifest: ProjectManifest, manifest: ProjectManifest,
resolvedMap: ResolvedProjectMap, resolvedMap: ResolvedProjectMap,
selection: Extract<MapSelection, { kind: 'project' }>, selection: Extract<MapSelection, { kind: 'project' }>,
options: ProjectMapCompositionOptions = {},
): ProjectMapComposition { ): ProjectMapComposition {
const robot = xml(robotSource, '机器人 MJCF'); const robot = xml(robotSource, '机器人 MJCF');
const robotWorldbody = robot.querySelector('mujoco > worldbody'); const robotWorldbody = robot.querySelector('mujoco > worldbody');
if (!robotWorldbody) throw new Error('机器人 MJCF 缺少 worldbody'); if (!robotWorldbody) throw new Error('机器人 MJCF 缺少 worldbody');
const warnings: string[] = []; const warnings: string[] = [];
const spawn = selection.spawnPointId const shouldApplySpawn = options.applySpawnPoint ?? true;
const spawn =
shouldApplySpawn && selection.spawnPointId
? resolvedMap.definition.spawnPoints.find( ? resolvedMap.definition.spawnPoints.find(
(candidate) => candidate.id === selection.spawnPointId, (candidate) => candidate.id === selection.spawnPointId,
) )
: undefined; : undefined;
if (selection.spawnPointId && !spawn) if (shouldApplySpawn && selection.spawnPointId && !spawn)
throw new Error(`地图中不存在出生点:${selection.spawnPointId}`); throw new Error(`地图中不存在出生点:${selection.spawnPointId}`);
applySpawnPoint(robot, spawn, selection.robotRootBody, warnings); applySpawnPoint(robot, transformSpawnPoint(spawn, selection), selection.robotRootBody, warnings);
let geomCount = 0; let geomCount = 0;
if (resolvedMap.physicsPath) { if (resolvedMap.physicsPath) {
@@ -225,7 +270,13 @@ export function composeProjectMap(
validateMapStructure(map); validateMapStructure(map);
const replacesGround = mapProvidesGround(map); const replacesGround = mapProvidesGround(map);
rebaseAssets(map, robot, manifest, resolvedMap.physicsPath, generatedScenePath); rebaseAssets(map, robot, manifest, resolvedMap.physicsPath, generatedScenePath);
namespaceMap(map, resolvedMap.definition.id); applyMapInstanceTransform(map, selection);
namespaceMap(
map,
options.instanceId
? `${resolvedMap.definition.id}_${options.instanceId}`
: resolvedMap.definition.id,
);
if (selection.frictionOverride !== undefined) { if (selection.frictionOverride !== undefined) {
const friction = Math.min(5, Math.max(0.05, selection.frictionOverride)); const friction = Math.min(5, Math.max(0.05, selection.frictionOverride));
for (const geom of Array.from(map.querySelectorAll('worldbody geom'))) for (const geom of Array.from(map.querySelectorAll('worldbody geom')))
+114 -8
View File
@@ -1,14 +1,15 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { EditableMapDocument } from './editor/types'; import type { EditableMapDocument, MapEditorInteractionCallbacks } from './editor/types';
import { MapEditorPanel } from './MapEditorPanel'; import { MapEditorPanel } from './MapEditorPanel';
const interactionProps = { const interactionProps = {
descriptorPath: 'maps/map/map.json',
onDraftChange: () => {},
onConvert: async () => true, onConvert: async () => true,
canConvert: true, canConvert: true,
onBindInteraction: () => {}, onBindInteraction: () => {},
onSelectPreview: () => {}, onSelectPreview: () => {},
onTransformMode: () => {}, onSessionStateChange: () => {},
onSnapping: () => {},
}; };
const document: EditableMapDocument = { const document: EditableMapDocument = {
schemaVersion: 1, schemaVersion: 1,
@@ -34,34 +35,139 @@ describe('MapEditorPanel', () => {
); );
fireEvent.click(screen.getByRole('button', { name: '新增' })); fireEvent.click(screen.getByRole('button', { name: '新增' }));
fireEvent.change(screen.getByLabelText('对象位置X'), { target: { value: '2' } }); fireEvent.change(screen.getByLabelText('对象位置X'), { target: { value: '2' } });
fireEvent.change(screen.getByLabelText('对象滑动摩擦'), { target: { value: '0.8' } });
fireEvent.change(screen.getByLabelText('对象颜色'), { target: { value: '#ff0000' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' })); fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
await waitFor(() => expect(apply).toHaveBeenCalled()); await waitFor(() => expect(apply).toHaveBeenCalled());
expect(preview).not.toHaveBeenLastCalledWith(null);
expect(apply.mock.calls[0][0]).toEqual( expect(apply.mock.calls[0][0]).toEqual(
expect.objectContaining({ expect.objectContaining({
revision: 1, revision: 1,
objects: [ objects: [
expect.objectContaining({ pose: expect.objectContaining({ position: [2, 0, 0.5] }) }), expect.objectContaining({
pose: expect.objectContaining({ position: [2, 0, 0.5] }),
friction: [0.8, 0.005, 0.0001],
rgba: [1, 0, 0, 1],
}),
], ],
}), }),
); );
}); });
it('锁定放置方式后禁用位姿编辑', () => { it('把认证资产编辑实时上报为场景级草稿', () => {
const onDraftChange = vi.fn();
render( render(
<MapEditorPanel <MapEditorPanel
{...interactionProps} {...interactionProps}
document={document} document={document}
loading={false} loading={false}
onPreview={() => {}} onPreview={() => {}}
onDraftChange={onDraftChange}
onApply={async () => true} onApply={async () => true}
onExport={() => {}} onExport={() => {}}
/>, />,
); );
fireEvent.click(screen.getByRole('button', { name: '新增' })); fireEvent.click(screen.getByRole('button', { name: '新增' }));
fireEvent.change(screen.getByLabelText('对象放置方式'), { target: { value: 'locked' } }); expect(onDraftChange).toHaveBeenLastCalledWith(
'maps/map/map.json',
expect.objectContaining({ objects: [expect.objectContaining({ type: 'box' })] }),
true,
);
});
it('把外部拾取同步到属性表单和视口操纵器', async () => {
const onSelectPreview = vi.fn();
let interaction: MapEditorInteractionCallbacks | null = null;
render(
<MapEditorPanel
{...interactionProps}
document={{
...document,
objects: [
{
id: 'box_1',
name: '认证方盒',
type: 'box',
pose: { position: [0, 0, 0.5], quaternion: [1, 0, 0, 0] },
parameters: { sizeX: 1, sizeY: 1, sizeZ: 1 },
friction: [1, 0.005, 0.0001],
rgba: [0.55, 0.6, 0.68, 1],
placementMode: 'auto_ground',
enabled: true,
},
],
}}
loading={false}
onPreview={() => {}}
onSelectPreview={onSelectPreview}
onBindInteraction={(callbacks) => {
interaction = callbacks;
}}
onApply={async () => true}
onExport={() => {}}
/>,
);
act(() => interaction?.onSelect('box_1'));
await waitFor(() => expect(onSelectPreview).toHaveBeenLastCalledWith('box_1'));
expect(screen.getByLabelText('对象名称')).toHaveValue('认证方盒');
});
it('视口贴地命令通过会话端口锁定位姿编辑', async () => {
let interaction: MapEditorInteractionCallbacks | null = null;
let selectedId: string | null = null;
render(
<MapEditorPanel
{...interactionProps}
document={document}
loading={false}
onPreview={() => {}}
onSelectPreview={(id) => {
selectedId = id;
}}
onBindInteraction={(callbacks) => {
interaction = callbacks;
}}
onApply={async () => true}
onExport={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增' }));
expect(selectedId).toBeTruthy();
act(() =>
(interaction as unknown as MapEditorInteractionCallbacks).onSetPlacementMode(
selectedId!,
'locked',
),
);
await waitFor(() => expect(screen.getByText('锁定位姿')).toBeVisible());
expect(screen.getByLabelText('对象位置X')).toBeDisabled(); expect(screen.getByLabelText('对象位置X')).toBeDisabled();
expect(screen.getByLabelText('对象绕Z旋转')).toBeDisabled(); expect(screen.getByLabelText('对象绕Z旋转')).toBeDisabled();
expect(screen.getByRole('button', { name: '对齐地面' })).toBeDisabled(); });
it('把表面材质预设与三轴摩擦写入同一对象草稿', () => {
const onDraftChange = vi.fn();
render(
<MapEditorPanel
{...interactionProps}
document={document}
loading={false}
onPreview={() => {}}
onDraftChange={onDraftChange}
onApply={async () => true}
onExport={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增' }));
fireEvent.change(screen.getByLabelText('对象表面材质'), { target: { value: 'rubber' } });
expect(screen.getByLabelText('对象滑动摩擦')).toHaveValue(1.5);
expect(onDraftChange).toHaveBeenLastCalledWith(
'maps/map/map.json',
expect.objectContaining({
objects: [expect.objectContaining({ friction: [1.5, 0.008, 0.0002] })],
}),
true,
);
}); });
it('编辑出生点并包含在应用文档中', async () => { it('编辑出生点并包含在应用文档中', async () => {
+201 -286
View File
@@ -1,16 +1,14 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Button, Select } from '../components/ui'; import { Button, ScrubbableNumberInput, Select } from '../components/ui';
import { MapEditSession } from './editor/MapEditSession'; import { MapEditSession } from './editor/MapEditSession';
import { import type {
editableObjectGroundHeight, EditableMapDocument,
MAP_OBJECT_PLACEMENT_LABELS, EditableMapObject,
type EditableMapDocument, EditableMapObjectType,
type EditableMapObject, MapEditorInteractionCallbacks,
type EditableMapObjectType, MapEditorSessionState,
type MapEditorInteractionCallbacks,
type MapEditorTransformMode,
type MapObjectPlacementMode,
} from './editor/types'; } from './editor/types';
import { MapObjectInspector } from './MapObjectInspector';
const labels: Record<EditableMapObjectType, string> = { const labels: Record<EditableMapObjectType, string> = {
box: '方盒', box: '方盒',
@@ -19,10 +17,6 @@ const labels: Record<EditableMapObjectType, string> = {
ramp: '坡道', ramp: '坡道',
stairs: '楼梯', stairs: '楼梯',
}; };
function yawDegrees(object: EditableMapObject): number {
const [w, x, y, z] = object.pose.quaternion;
return (Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)) * 180) / Math.PI;
}
function scaledParameters( function scaledParameters(
object: EditableMapObject, object: EditableMapObject,
scale: [number, number, number], scale: [number, number, number],
@@ -55,80 +49,178 @@ function scaledParameters(
} }
export function MapEditorPanel({ export function MapEditorPanel({
document, document,
draftDocument,
descriptorPath,
loading, loading,
onPreview, onPreview,
onDraftChange,
onApply, onApply,
onExport, onExport,
onConvert, onConvert,
canConvert, canConvert,
selectedObjectId,
onBindInteraction, onBindInteraction,
onSelectPreview, onSelectPreview,
onTransformMode, onSessionStateChange,
onSnapping, onSurfaceHeight,
}: { }: {
document: EditableMapDocument | null; document: EditableMapDocument | null;
draftDocument?: EditableMapDocument;
descriptorPath: string;
loading: boolean; loading: boolean;
onPreview: (document: EditableMapDocument | null) => void; onPreview: (document: EditableMapDocument | null) => void;
onDraftChange: (descriptorPath: string, document: EditableMapDocument, dirty: boolean) => void;
onApply: (document: EditableMapDocument) => Promise<boolean>; onApply: (document: EditableMapDocument) => Promise<boolean>;
onExport: () => void; onExport: () => void;
onConvert: () => Promise<boolean>; onConvert: () => Promise<boolean>;
canConvert: boolean; canConvert: boolean;
selectedObjectId?: string;
onBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void; onBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onSelectPreview: (id: string | null) => void; onSelectPreview: (id: string | null) => void;
onTransformMode: (mode: MapEditorTransformMode) => void; onSessionStateChange?: (descriptorPath: string, state: MapEditorSessionState | null) => void;
onSnapping: (translation: number | null, rotationDegrees: number | null) => void; onSurfaceHeight?: (position: readonly [number, number, number]) => number | null;
}) { }) {
const session = useMemo(() => (document ? new MapEditSession(document) : null), [document]); // draftDocument 仅用于恢复重新挂载的编辑器;会话存活期间由 MapEditSession 持有最新草稿。
const session = useMemo(
() =>
document ? new MapEditSession(document, draftDocument ?? document, onSurfaceHeight) : null,
// eslint-disable-next-line react-hooks/exhaustive-deps
[document, descriptorPath, onSurfaceHeight],
);
const [, render] = useState(0), const [, render] = useState(0),
[selected, setSelected] = useState<string>(), [selected, setSelected] = useState<string | undefined>(selectedObjectId),
[selectedType, setSelectedType] = useState<EditableMapObjectType>('box'), [selectedType, setSelectedType] = useState<EditableMapObjectType>('box'),
[transformMode, setTransformMode] = useState<MapEditorTransformMode>('translate'),
[snapping, setSnapping] = useState(true),
[converting, setConverting] = useState(false); [converting, setConverting] = useState(false);
const selectedId = selectedObjectId ?? selected;
const reportSessionState = () =>
onSessionStateChange?.(
descriptorPath,
session
? {
dirty: session.dirty,
changeCount: session.changeCount,
canUndo: session.canUndo,
canRedo: session.canRedo,
}
: null,
);
const refresh = () => { const refresh = () => {
const next = session?.document ?? null;
render((value) => value + 1); render((value) => value + 1);
onPreview(session?.document ?? null); onPreview(next);
if (session && next) onDraftChange(descriptorPath, next, session.dirty);
reportSessionState();
}; };
useEffect(() => { useEffect(() => {
onPreview(session?.document ?? null); const next = session?.document ?? null;
return () => onPreview(null); onPreview(next);
}, [session, onPreview]); if (session && next) onDraftChange(descriptorPath, next, session.dirty);
onSessionStateChange?.(
descriptorPath,
session
? {
dirty: session.dirty,
changeCount: session.changeCount,
canUndo: session.canUndo,
canRedo: session.canRedo,
}
: null,
);
return () => {
onPreview(null);
onSessionStateChange?.(descriptorPath, null);
};
}, [session, descriptorPath, onPreview, onDraftChange, onSessionStateChange]);
useEffect(() => { useEffect(() => {
if (!session) { if (!session) {
onBindInteraction(null); onBindInteraction(null);
return; return;
} }
const publish = () => {
const next = session.document;
render((value) => value + 1);
onPreview(next);
onDraftChange(descriptorPath, next, session.dirty);
onSessionStateChange?.(descriptorPath, {
dirty: session.dirty,
changeCount: session.changeCount,
canUndo: session.canUndo,
canRedo: session.canRedo,
});
};
const callbacks: MapEditorInteractionCallbacks = { const callbacks: MapEditorInteractionCallbacks = {
onSelect: (id) => setSelected(id ?? undefined), onSelect: (id) => {
session.selectedId = id ?? undefined;
setSelected(id ?? undefined);
},
onTransform: ({ id, position, quaternion, scale }) => { onTransform: ({ id, position, quaternion, scale }) => {
if (loading) return;
const object = session.document.objects.find((item) => item.id === id); const object = session.document.objects.find((item) => item.id === id);
if (!object) return; if (!object) return;
session.update(id, { session.update(id, {
pose: { position, quaternion }, pose: { position, quaternion },
parameters: scaledParameters(object, scale), parameters: scaledParameters(object, scale),
}); });
session.selectedId = id;
setSelected(id); setSelected(id);
render((value) => value + 1); publish();
onPreview(session.document);
}, },
onAddAsset: (type, position, placementMode) => { onAddAsset: (type, position, placementMode, externalSupportTop) => {
const object = session.addAsset(type, position, placementMode); if (loading) return;
const object = session.addAsset(type, position, placementMode, externalSupportTop);
setSelected(object.id); setSelected(object.id);
render((value) => value + 1); publish();
onPreview(session.document);
onSelectPreview(object.id); onSelectPreview(object.id);
}, },
onSetPlacementMode: (id, placementMode) => {
if (loading || !session.document.objects.some((object) => object.id === id)) return;
session.update(id, { placementMode });
session.selectedId = id;
setSelected(id);
publish();
},
onAlignToSurface: (id) => {
if (loading) return;
const object = session.document.objects.find((candidate) => candidate.id === id);
if (!object || object.placementMode === 'locked') return;
session.update(id, { placementMode: object.placementMode });
session.selectedId = id;
setSelected(id);
publish();
},
onDelete: (id) => {
if (loading || !session.document.objects.some((object) => object.id === id)) return;
session.remove(id);
setSelected(undefined);
publish();
onSelectPreview(null);
},
onDiscard: () => {
session.discard();
setSelected(undefined);
publish();
onSelectPreview(null);
},
}; };
onBindInteraction(callbacks); onBindInteraction(callbacks);
return () => onBindInteraction(null); return () => onBindInteraction(null);
}, [session, onBindInteraction, onPreview, onSelectPreview]); }, [
session,
descriptorPath,
loading,
onBindInteraction,
onPreview,
onDraftChange,
onSelectPreview,
onSessionStateChange,
]);
useEffect(() => { useEffect(() => {
onTransformMode(transformMode); if (session && selectedId) onSelectPreview(selectedId);
onSnapping(snapping ? 0.1 : null, snapping ? 5 : null); }, [session, selectedId, onSelectPreview]);
}, [transformMode, snapping, onTransformMode, onSnapping]);
useEffect(() => { useEffect(() => {
if (!session) return; if (!session) return;
const keydown = (event: KeyboardEvent) => { const keydown = (event: KeyboardEvent) => {
if (loading) return;
const target = event.target as HTMLElement | null; const target = event.target as HTMLElement | null;
if (target?.matches('input, textarea, select') || target?.isContentEditable || event.altKey) if (target?.matches('input, textarea, select') || target?.isContentEditable || event.altKey)
return; return;
@@ -137,35 +229,48 @@ export function MapEditorPanel({
event.preventDefault(); event.preventDefault();
if (event.shiftKey) session.redo(); if (event.shiftKey) session.redo();
else session.undo(); else session.undo();
if (selected && !session.document.objects.some((object) => object.id === selected)) { if (selectedId && !session.document.objects.some((object) => object.id === selectedId)) {
setSelected(undefined); setSelected(undefined);
onSelectPreview(null); onSelectPreview(null);
} }
render((value) => value + 1); render((value) => value + 1);
onPreview(session.document); onPreview(session.document);
onDraftChange(descriptorPath, session.document, session.dirty);
onSessionStateChange?.(descriptorPath, {
dirty: session.dirty,
changeCount: session.changeCount,
canUndo: session.canUndo,
canRedo: session.canRedo,
});
} else if ((event.ctrlKey || event.metaKey) && key === 'y') { } else if ((event.ctrlKey || event.metaKey) && key === 'y') {
event.preventDefault(); event.preventDefault();
session.redo(); session.redo();
render((value) => value + 1); render((value) => value + 1);
onPreview(session.document); onPreview(session.document);
} else if (!event.ctrlKey && !event.metaKey && key === 'w') setTransformMode('translate'); onDraftChange(descriptorPath, session.document, session.dirty);
else if (!event.ctrlKey && !event.metaKey && key === 'e') setTransformMode('rotate'); onSessionStateChange?.(descriptorPath, {
else if (!event.ctrlKey && !event.metaKey && key === 's') setTransformMode('scale'); dirty: session.dirty,
else if (event.key === 'Escape') { changeCount: session.changeCount,
canUndo: session.canUndo,
canRedo: session.canRedo,
});
} else if (event.key === 'Escape') {
setSelected(undefined); setSelected(undefined);
onSelectPreview(null); onSelectPreview(null);
} else if ((event.key === 'Delete' || event.key === 'Backspace') && selected) {
event.preventDefault();
session.remove(selected);
setSelected(undefined);
render((value) => value + 1);
onPreview(session.document);
onSelectPreview(null);
} }
}; };
window.addEventListener('keydown', keydown); window.addEventListener('keydown', keydown);
return () => window.removeEventListener('keydown', keydown); return () => window.removeEventListener('keydown', keydown);
}, [session, selected, onPreview, onSelectPreview]); }, [
session,
descriptorPath,
selectedId,
loading,
onPreview,
onDraftChange,
onSelectPreview,
onSessionStateChange,
]);
if (!session) if (!session)
return ( return (
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-tertiary"> <div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-tertiary">
@@ -193,29 +298,14 @@ export function MapEditorPanel({
</div> </div>
); );
const current = session.document; const current = session.document;
const active = current.objects.find((object) => object.id === selected); const active = current.objects.find((object) => object.id === selectedId);
const poseLocked = active?.placementMode === 'locked';
const updatePosition = (index: number, value: number) => {
if (!active || !Number.isFinite(value)) return;
const position = [...active.pose.position] as [number, number, number];
position[index] = value;
session.update(active.id, { pose: { ...active.pose, position } });
refresh();
};
const updateParameter = (key: string, value: number) => {
if (!active || !Number.isFinite(value) || value <= 0) return;
session.update(active.id, {
parameters: { ...active.parameters, [key]: key === 'count' ? Math.round(value) : value },
});
refresh();
};
const updateSpawnPosition = ( const updateSpawnPosition = (
id: string, id: string,
position: [number, number, number], position: [number, number, number],
index: number, index: number,
value: number, value: number,
) => { ) => {
if (!Number.isFinite(value)) return; if (loading || !Number.isFinite(value)) return;
const next = [...position] as [number, number, number]; const next = [...position] as [number, number, number];
next[index] = value; next[index] = value;
session.updateSpawn(id, { position: next }); session.updateSpawn(id, { position: next });
@@ -224,44 +314,32 @@ export function MapEditorPanel({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-3 gap-2 rounded border border-border-subtle p-2"> {active && (
<Button <MapObjectInspector
variant={transformMode === 'translate' ? 'primary' : 'secondary'} object={active}
aria-pressed={transformMode === 'translate'} loading={loading}
disabled={loading} onUpdate={(patch) => {
onClick={() => setTransformMode('translate')} session.update(active.id, patch);
> refresh();
移动工具 W }}
</Button> onDuplicate={() => {
<Button const copy = session.duplicate(active.id);
variant={transformMode === 'rotate' ? 'primary' : 'secondary'} setSelected(copy.id);
aria-pressed={transformMode === 'rotate'} refresh();
disabled={loading} onSelectPreview(copy.id);
onClick={() => setTransformMode('rotate')} }}
> onDelete={() => {
旋转工具 E session.remove(active.id);
</Button> setSelected(undefined);
<Button refresh();
variant={transformMode === 'scale' ? 'primary' : 'secondary'} onSelectPreview(null);
aria-pressed={transformMode === 'scale'} }}
disabled={loading}
onClick={() => setTransformMode('scale')}
>
缩放工具 S
</Button>
<label className="col-span-3 flex items-center justify-between text-[11px] text-text-secondary">
<span>吸附(移动 0.1 m / 旋转 5°)</span>
<input
aria-label="地图编辑吸附"
type="checkbox"
checked={snapping}
disabled={loading}
onChange={(event) => setSnapping(event.target.checked)}
/> />
</label> )}
<p className="col-span-3 text-[10px] text-text-tertiary">
在视口点击对象后拖动操纵轴;只允许绕世界 Z 轴旋转,缩放会写入原语尺寸。 <section className="space-y-2 rounded-lg border border-border-subtle p-2.5">
</p> <div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-text-tertiary">
地图对象
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Select <Select
@@ -294,7 +372,7 @@ export function MapEditorPanel({
<button <button
key={object.id} key={object.id}
type="button" type="button"
className={`w-full rounded border px-2 py-1 text-left text-xs ${selected === object.id ? 'border-accent bg-accent/10' : 'border-border-subtle'}`} className={`w-full rounded border px-2 py-1 text-left text-xs ${selectedId === object.id ? 'border-accent bg-accent/10' : 'border-border-subtle'}`}
onClick={() => { onClick={() => {
setSelected(object.id); setSelected(object.id);
onSelectPreview(object.id); onSelectPreview(object.id);
@@ -304,162 +382,11 @@ export function MapEditorPanel({
{object.placementMode === 'locked' ? ' · 已锁定' : ''} {object.placementMode === 'locked' ? ' · 已锁定' : ''}
</button> </button>
))} ))}
{!current.objects.length && <div className="text-xs text-text-tertiary">暂无对象</div>} {!current.objects.length && (
</div> <div className="text-xs text-text-tertiary">暂无对象,可从左侧资产库拖入视口</div>
{active && (
<div className="space-y-2 rounded border border-border-subtle p-2">
<label className="block text-[11px] text-text-secondary">
名称
<input
aria-label="对象名称"
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
value={active.name}
onChange={(event) => {
if (!event.target.value.trim()) return;
session.update(active.id, { name: event.target.value });
refresh();
}}
/>
</label>
<label className="block text-[11px] text-text-secondary">
放置方式
<Select
aria-label="对象放置方式"
className="mt-1 w-full"
value={active.placementMode}
disabled={loading}
onChange={(event) => {
session.update(active.id, {
placementMode: event.target.value as MapObjectPlacementMode,
});
refresh();
}}
>
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map(
(mode) => (
<option key={mode} value={mode}>
{MAP_OBJECT_PLACEMENT_LABELS[mode]}
</option>
),
)} )}
</Select>
<span className="mt-1 block text-[9px] leading-3.5 text-text-tertiary">
{active.placementMode === 'auto_ground'
? '始终将对象底部对齐 z=0。'
: active.placementMode === 'gravity'
? '沿世界 -Z 落到 XY 范围内最高的静态承载面。'
: '保持当前位姿,并禁用表单和视口变换。'}
</span>
</label>
<div className="grid grid-cols-3 gap-2">
{active.pose.position.map((value, index) => (
<label key={index} className="text-[11px] text-text-secondary">
{['X', 'Y', 'Z'][index]}
<input
aria-label={`对象位置${['X', 'Y', 'Z'][index]}`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="0.1"
value={value}
disabled={loading || poseLocked || index === 2}
onChange={(event) => updatePosition(index, Number(event.target.value))}
/>
</label>
))}
</div> </div>
<label className="block text-[11px] text-text-secondary"> </section>
绕 Z 旋转(°)
<input
aria-label="对象绕Z旋转"
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="1"
value={Number(yawDegrees(active).toFixed(4))}
disabled={loading || poseLocked}
onChange={(event) => {
const value = Number(event.target.value);
if (!Number.isFinite(value)) return;
const half = (value * Math.PI) / 360;
session.update(active.id, {
pose: { ...active.pose, quaternion: [Math.cos(half), 0, 0, Math.sin(half)] },
});
refresh();
}}
/>
</label>
<div className="grid grid-cols-2 gap-2">
{Object.entries(active.parameters).map(([key, value]) => (
<label key={key} className="text-[11px] text-text-secondary">
{key}
<input
aria-label={`对象参数${key}`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
min="0.001"
step={key === 'count' ? 1 : 0.1}
value={value}
onChange={(event) => updateParameter(key, Number(event.target.value))}
/>
</label>
))}
</div>
<label className="flex items-center justify-between text-[11px] text-text-secondary">
<span>启用对象</span>
<input
aria-label="启用地图对象"
type="checkbox"
checked={active.enabled}
onChange={(event) => {
session.update(active.id, { enabled: event.target.checked });
refresh();
}}
/>
</label>
<div className="flex flex-wrap gap-2">
<Button
disabled={loading || poseLocked}
onClick={() => {
session.update(active.id, {
pose: {
...active.pose,
position: [
active.pose.position[0],
active.pose.position[1],
editableObjectGroundHeight(active),
],
},
});
refresh();
}}
>
对齐地面
</Button>
<Button
disabled={loading}
onClick={() => {
const copy = session.duplicate(active.id);
setSelected(copy.id);
refresh();
onSelectPreview(copy.id);
}}
>
复制对象
</Button>
<Button
variant="danger"
disabled={loading}
onClick={() => {
session.remove(active.id);
setSelected(undefined);
refresh();
onSelectPreview(null);
}}
>
删除对象
</Button>
</div>
</div>
)}
<div className="space-y-2 rounded border border-border-subtle p-2"> <div className="space-y-2 rounded border border-border-subtle p-2">
<div className="flex items-center justify-between text-xs font-medium text-text-primary"> <div className="flex items-center justify-between text-xs font-medium text-text-primary">
@@ -480,6 +407,7 @@ export function MapEditorPanel({
aria-label={`出生点名称 ${spawn.id}`} aria-label={`出生点名称 ${spawn.id}`}
className="h-7 w-full rounded border border-border-strong bg-input px-1 text-xs" className="h-7 w-full rounded border border-border-strong bg-input px-1 text-xs"
value={spawn.name} value={spawn.name}
disabled={loading}
onChange={(event) => { onChange={(event) => {
if (!event.target.value.trim()) return; if (!event.target.value.trim()) return;
session.updateSpawn(spawn.id, { name: event.target.value }); session.updateSpawn(spawn.id, { name: event.target.value });
@@ -488,41 +416,29 @@ export function MapEditorPanel({
/> />
<div className="grid grid-cols-4 gap-1"> <div className="grid grid-cols-4 gap-1">
{spawn.position.map((value, index) => ( {spawn.position.map((value, index) => (
<label key={index} className="text-[10px] text-text-secondary"> <ScrubbableNumberInput
{['X', 'Y', 'Z'][index]} key={index}
<input label={['X', 'Y', 'Z'][index]}
aria-label={`出生点${spawn.id}位置${['X', 'Y', 'Z'][index]}`} aria-label={`出生点${spawn.id}位置${['X', 'Y', 'Z'][index]}`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="0.1"
value={value} value={value}
onChange={(event) => step={0.1}
updateSpawnPosition( disabled={loading}
spawn.id, onValueChange={(next) =>
spawn.position, updateSpawnPosition(spawn.id, spawn.position, index, next)
index,
Number(event.target.value),
)
} }
/> />
</label>
))} ))}
<label className="text-[10px] text-text-secondary"> <ScrubbableNumberInput
Yaw° label="Yaw°"
<input
aria-label={`出生点${spawn.id}朝向`} aria-label={`出生点${spawn.id}朝向`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="1"
value={spawn.yawDeg} value={spawn.yawDeg}
onChange={(event) => { step={1}
const value = Number(event.target.value); disabled={loading}
if (!Number.isFinite(value)) return; onValueChange={(yawDeg) => {
session.updateSpawn(spawn.id, { yawDeg: value }); session.updateSpawn(spawn.id, { yawDeg });
refresh(); refresh();
}} }}
/> />
</label>
</div> </div>
<Button <Button
variant="danger" variant="danger"
@@ -581,8 +497,7 @@ export function MapEditorPanel({
onClick={async () => { onClick={async () => {
const latest = session.document; const latest = session.document;
const candidate = { ...latest, revision: latest.revision + 1 }; const candidate = { ...latest, revision: latest.revision + 1 };
if (await onApply(candidate)) onPreview(null); if (!(await onApply(candidate))) onPreview(session.document);
else onPreview(session.document);
}} }}
> >
应用并重新编译 应用并重新编译
+42 -1
View File
@@ -1,4 +1,4 @@
import { discoverMapEntries, resolveProjectMap } from './MapLoader'; import { discoverMapEntries, resolveProjectMap, visualMapAssets } from './MapLoader';
import type { ProjectFile, ProjectManifest } from '../project/types'; import type { ProjectFile, ProjectManifest } from '../project/types';
const encode = (value: string) => new TextEncoder().encode(value); const encode = (value: string) => new TextEncoder().encode(value);
@@ -52,6 +52,47 @@ describe('MapLoader', () => {
}); });
}); });
it('只为已放置实例解析视觉地图,并保留实例 id', () => {
const files = [
file('maps/warehouse/map.json', descriptor),
file('maps/warehouse/physics/world.xml', '<mujoco><worldbody/></mujoco>'),
file('maps/warehouse/visuals/scene.glb', 'glb'),
];
const projectManifest: ProjectManifest = {
id: 'test',
name: 'test',
files,
entries: [],
maps: discoverMapEntries(files),
totalBytes: files.reduce((sum, item) => sum + item.size, 0),
};
expect(visualMapAssets(projectManifest, [])).toEqual([]);
expect(
visualMapAssets(projectManifest, [
{
id: 'instance-a',
name: '仓库副本',
selection: {
kind: 'project',
descriptorPath: 'maps/warehouse/map.json',
positionX: 2,
positionY: -1,
yawDeg: 30,
},
},
]),
).toEqual([
expect.objectContaining({
id: 'instance-a',
name: '仓库副本',
path: 'maps/warehouse/visuals/scene.glb',
positionX: 2,
positionY: -1,
yawDeg: 30,
}),
]);
});
it('发现 schema V2 authoring 创作层', () => { it('发现 schema V2 authoring 创作层', () => {
const editable = JSON.stringify({ const editable = JSON.stringify({
...JSON.parse(descriptor), ...JSON.parse(descriptor),
+20 -1
View File
@@ -1,7 +1,13 @@
import type { MapEntry, ProjectFile, ProjectManifest } from '../project/types'; import type { MapEntry, ProjectFile, ProjectManifest } from '../project/types';
import { decodeMapDefinition } from './mapSchema'; import { decodeMapDefinition } from './mapSchema';
import { resolveProjectAssetPath } from './mapPaths'; import { resolveProjectAssetPath } from './mapPaths';
import type { MapSelection, ResolvedProjectMap, VisualMapAsset } from './types'; import {
mapSelectionTransform,
type MapSelection,
type PlacedMapAsset,
type ResolvedProjectMap,
type VisualMapAsset,
} from './types';
import { decodeEditableMapDocument } from './editor/editorSchema'; import { decodeEditableMapDocument } from './editor/editorSchema';
function projectFile(manifest: ProjectManifest, path: string): ProjectFile { function projectFile(manifest: ProjectManifest, path: string): ProjectFile {
@@ -92,6 +98,7 @@ export function visualMapAsset(
const resolved = resolveProjectMap(manifest, selection.descriptorPath); const resolved = resolveProjectMap(manifest, selection.descriptorPath);
if (!resolved.visualPath || !resolved.definition.visual) return null; if (!resolved.visualPath || !resolved.definition.visual) return null;
const visual = projectFile(manifest, resolved.visualPath); const visual = projectFile(manifest, resolved.visualPath);
const transform = mapSelectionTransform(selection);
return { return {
id: resolved.definition.id, id: resolved.definition.id,
name: resolved.definition.name, name: resolved.definition.name,
@@ -99,5 +106,17 @@ export function visualMapAsset(
data: visual.data, data: visual.data,
castShadow: resolved.definition.visual.castShadow ?? true, castShadow: resolved.definition.visual.castShadow ?? true,
receiveShadow: resolved.definition.visual.receiveShadow ?? true, receiveShadow: resolved.definition.visual.receiveShadow ?? true,
...transform,
}; };
} }
/** 将已放置的 project 地图实例解析为可同时挂载的视觉层。 */
export function visualMapAssets(
manifest: ProjectManifest,
assets: readonly PlacedMapAsset[],
): VisualMapAsset[] {
return assets.flatMap((asset) => {
const visual = visualMapAsset(manifest, asset.selection);
return visual ? [{ ...visual, id: asset.id, name: asset.name }] : [];
});
}
+321
View File
@@ -0,0 +1,321 @@
import { Copy, LockKeyhole, Trash2 } from 'lucide-react';
import { Button, ScrubbableNumberInput, Select } from '../components/ui';
import { MAP_OBJECT_PLACEMENT_LABELS, type EditableMapObject } from './editor/types';
const PARAMETER_LABELS: Record<string, string> = {
sizeX: '尺寸 X(m)',
sizeY: '尺寸 Y(m)',
sizeZ: '尺寸 Z(m)',
radius: '半径(m)',
height: '高度(m)',
length: '长度(m)',
width: '宽度(m)',
rise: '抬升(m)',
thickness: '厚度(m)',
stepDepth: '踏步深度(m)',
stepHeight: '踏步高度(m)',
count: '踏步数量',
};
const SURFACE_PRESETS = {
standard: {
label: '标准防滑材质',
rgb: [0.55, 0.6, 0.68] as const,
friction: [1, 0.005, 0.0001] as const,
},
concrete: {
label: '混凝土',
rgb: [0.55, 0.58, 0.62] as const,
friction: [1, 0.005, 0.0001] as const,
},
rubber: {
label: '橡胶',
rgb: [0.12, 0.14, 0.16] as const,
friction: [1.5, 0.008, 0.0002] as const,
},
metal: {
label: '金属',
rgb: [0.42, 0.48, 0.54] as const,
friction: [0.45, 0.003, 0.0001] as const,
},
ice: {
label: '低摩擦冰面',
rgb: [0.58, 0.82, 0.94] as const,
friction: [0.08, 0.001, 0.00001] as const,
},
} as const;
type SurfacePreset = keyof typeof SURFACE_PRESETS;
function close(a: number, b: number): boolean {
return Math.abs(a - b) < 0.015;
}
function selectedSurfacePreset(object: EditableMapObject): SurfacePreset | 'custom' {
for (const [key, preset] of Object.entries(SURFACE_PRESETS) as [
SurfacePreset,
(typeof SURFACE_PRESETS)[SurfacePreset],
][]) {
if (
preset.rgb.every((channel, index) => close(channel, object.rgba[index])) &&
close(preset.friction[0], object.friction[0])
)
return key;
}
return 'custom';
}
function yawDegrees(object: EditableMapObject): number {
const [w, x, y, z] = object.pose.quaternion;
return (Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)) * 180) / Math.PI;
}
function colorValue(object: EditableMapObject): string {
return `#${object.rgba
.slice(0, 3)
.map((channel) =>
Math.round(Math.min(1, Math.max(0, channel)) * 255)
.toString(16)
.padStart(2, '0'),
)
.join('')}`;
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="engineering-card rounded-lg border p-2.5">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-text-tertiary">
{title}
</h3>
{children}
</section>
);
}
export function MapObjectInspector({
object,
loading,
onUpdate,
onDuplicate,
onDelete,
}: {
object: EditableMapObject;
loading: boolean;
onUpdate: (patch: Partial<Omit<EditableMapObject, 'id' | 'type'>>) => void;
onDuplicate: () => void;
onDelete: () => void;
}) {
const poseLocked = object.placementMode === 'locked';
const updatePosition = (index: number, value: number) => {
if (!Number.isFinite(value)) return;
const position = [...object.pose.position] as [number, number, number];
position[index] = value;
onUpdate({ pose: { ...object.pose, position } });
};
const updateParameter = (key: string, value: number) => {
if (!Number.isFinite(value) || value <= 0) return;
onUpdate({
parameters: {
...object.parameters,
[key]: key === 'count' ? Math.round(value) : value,
},
});
};
const updateFriction = (index: number, value: number) => {
if (!Number.isFinite(value) || value < 0) return;
const friction = [...object.friction] as [number, number, number];
friction[index] = value;
onUpdate({ friction });
};
return (
<div className="space-y-2" aria-label="地图物体检查器">
<Section title="对象">
<div className="space-y-2">
<label className="block text-[11px] text-text-secondary">
名称
<input
aria-label="对象名称"
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-2"
value={object.name}
disabled={loading}
onChange={(event) => {
if (event.target.value.trim()) onUpdate({ name: event.target.value });
}}
/>
</label>
<label className="flex items-center justify-between text-[11px] text-text-secondary">
<span>启用碰撞与渲染</span>
<input
aria-label="启用地图对象"
type="checkbox"
checked={object.enabled}
disabled={loading}
onChange={(event) => onUpdate({ enabled: event.target.checked })}
/>
</label>
</div>
</Section>
<Section title="位姿">
<div className="mb-2 flex items-center justify-between rounded bg-surface/55 px-2 py-1.5 text-[10px] text-text-secondary">
<span>{MAP_OBJECT_PLACEMENT_LABELS[object.placementMode]}</span>
<span className="flex items-center gap-1 text-text-tertiary">
{poseLocked && <LockKeyhole className="h-3 w-3" aria-hidden="true" />}
在视口工具条切换
</span>
</div>
<div className="grid grid-cols-3 gap-2">
{object.pose.position.map((value, index) => (
<ScrubbableNumberInput
key={index}
label={`${['X', 'Y', 'Z'][index]}(m)`}
aria-label={`对象位置${['X', 'Y', 'Z'][index]}`}
value={value}
step={0.1}
disabled={loading || poseLocked || index === 2}
onValueChange={(next) => updatePosition(index, next)}
/>
))}
</div>
<ScrubbableNumberInput
containerClassName="mt-2"
label="绕 Z 旋转(°)"
aria-label="对象绕Z旋转"
value={Number(yawDegrees(object).toFixed(4))}
step={1}
disabled={loading || poseLocked}
onValueChange={(next) => {
const half = (next * Math.PI) / 360;
onUpdate({
pose: {
...object.pose,
quaternion: [Math.cos(half), 0, 0, Math.sin(half)],
},
});
}}
/>
</Section>
<Section title="尺寸">
<div className="grid grid-cols-2 gap-2">
{Object.entries(object.parameters).map(([key, value]) => (
<ScrubbableNumberInput
key={key}
label={PARAMETER_LABELS[key] ?? key}
aria-label={`对象参数${key}`}
value={value}
min={0.001}
step={key === 'count' ? 1 : 0.1}
disabled={loading}
onValueChange={(next) => updateParameter(key, next)}
/>
))}
</div>
</Section>
<Section title="表面材质">
<div className="grid grid-cols-2 gap-2">
<label className="text-[10px] text-text-secondary">
材质预设
<Select
aria-label="对象表面材质"
className="mt-1 w-full"
value={selectedSurfacePreset(object)}
disabled={loading}
onChange={(event) => {
if (event.target.value === 'custom') return;
const preset = SURFACE_PRESETS[event.target.value as SurfacePreset];
onUpdate({
rgba: [...preset.rgb, object.rgba[3]],
friction: [...preset.friction],
});
}}
>
<option value="custom">自定义</option>
{(
Object.entries(SURFACE_PRESETS) as [
SurfacePreset,
(typeof SURFACE_PRESETS)[SurfacePreset],
][]
).map(([value, preset]) => (
<option key={value} value={value}>
{preset.label}
</option>
))}
</Select>
</label>
<label className="text-[10px] text-text-secondary">
表面颜色
<input
aria-label="对象颜色"
className="mt-1 h-7 w-full rounded border border-border-strong bg-input p-0.5"
type="color"
value={colorValue(object)}
disabled={loading}
onChange={(event) => {
const value = event.target.value;
if (!/^#[0-9a-f]{6}$/i.test(value)) return;
onUpdate({
rgba: [
Number.parseInt(value.slice(1, 3), 16) / 255,
Number.parseInt(value.slice(3, 5), 16) / 255,
Number.parseInt(value.slice(5, 7), 16) / 255,
object.rgba[3],
],
});
}}
/>
</label>
</div>
<ScrubbableNumberInput
containerClassName="mt-2"
label="不透明度"
aria-label="对象不透明度"
value={object.rgba[3]}
min={0}
max={1}
step={0.05}
disabled={loading}
onValueChange={(alpha) =>
onUpdate({
rgba: [object.rgba[0], object.rgba[1], object.rgba[2], alpha],
})
}
/>
</Section>
<Section title="摩擦力">
<div className="grid grid-cols-3 gap-2">
{object.friction.map((value, index) => (
<ScrubbableNumberInput
key={index}
label={['滑动', '扭转', '滚动'][index]}
aria-label={`对象${['滑动摩擦', '扭转摩擦', '滚动摩擦'][index]}`}
value={value}
min={0}
max={5}
step={index === 0 ? 0.05 : 0.0001}
disabled={loading}
onValueChange={(next) => updateFriction(index, next)}
/>
))}
</div>
</Section>
<div className="grid grid-cols-2 gap-2">
<Button disabled={loading} icon={<Copy className="h-3 w-3" />} onClick={onDuplicate}>
复制对象
</Button>
<Button
variant="danger"
disabled={loading}
icon={<Trash2 className="h-3 w-3" />}
onClick={onDelete}
>
删除对象
</Button>
</div>
</div>
);
}
@@ -0,0 +1,267 @@
import { composePlacedMapAssets } from './MapStackComposer';
import { DEFAULT_PHYSICAL_MAP_CONFIG, type PlacedMapAsset } from './types';
import type { ProjectFile, ProjectManifest } from '../project/types';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const file = (path: string, text: string): ProjectFile => ({
path,
data: encoder.encode(text),
size: encoder.encode(text).byteLength,
source: 'directory',
mimeType: '',
});
const robot = encoder.encode(`
<mujoco>
<worldbody>
<geom name="__platform_ground__" type="plane" size="2 2 .1"/>
<body name="robot"><freejoint/><geom type="box" size=".1 .1 .1"/></body>
</worldbody>
</mujoco>`);
function manifest(): ProjectManifest {
const files = [
file(
'maps/warehouse/map.json',
JSON.stringify({
schemaVersion: 1,
id: 'warehouse',
name: '仓库',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'physics/world.xml' },
spawnPoints: [],
}),
),
file(
'maps/warehouse/physics/world.xml',
'<mujoco><worldbody><body name="building"><geom name="wall" type="box" size="1 .1 1"/></body></worldbody></mujoco>',
),
file(
'maps/overlay/map.json',
JSON.stringify({
schemaVersion: 2,
id: 'overlay',
name: '体素层',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'physics/world.xml' },
authoring: { source: 'authoring/map.scene.json' },
spawnPoints: [],
}),
),
file(
'maps/overlay/physics/world.xml',
'<mujoco><worldbody><body name="edit_body_box"><geom name="edit_box" type="box" size=".5 .5 .5"/></body></worldbody></mujoco>',
),
file(
'maps/overlay/authoring/map.scene.json',
JSON.stringify({
schemaVersion: 1,
mapId: 'overlay',
revision: 0,
objects: [],
spawnPoints: [],
}),
),
];
return {
id: 'test',
name: 'test',
files,
entries: [{ path: 'robot.xml', format: 'mjcf', label: 'robot' }],
maps: [],
totalBytes: files.reduce((sum, item) => sum + item.size, 0),
};
}
function documentOf(data: Uint8Array): Document {
return new DOMParser().parseFromString(decoder.decode(data), 'application/xml');
}
function project(id: string, descriptorPath = 'maps/warehouse/map.json'): PlacedMapAsset {
return { id, name: `仓库 ${id}`, selection: { kind: 'project', descriptorPath } };
}
describe('composePlacedMapAssets', () => {
it('同时保留工程地图、编辑方盒层和系统参数地形', () => {
const assets: PlacedMapAsset[] = [
project('warehouse-a'),
project('voxel-a', 'maps/overlay/map.json'),
{
id: 'terrain-a',
name: '粗糙地形',
selection: {
kind: 'builtin',
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough', size: 6 },
},
},
];
const result = composePlacedMapAssets(robot, 'scene.xml', manifest(), assets);
const document = documentOf(result.data);
expect(
document.querySelector('[name="__platform_map_warehouse_warehouse-a_wall"]'),
).not.toBeNull();
expect(
document.querySelector('[name="__platform_map_overlay_voxel-a_edit_box"]'),
).not.toBeNull();
expect(
document.querySelector('[name^="__platform_map_terrain-a__"][type="hfield"]'),
).not.toBeNull();
expect(document.querySelector('[name="__platform_ground__"]')).toBeNull();
});
it('无论场景顺序如何,程序地形都会移除覆盖坑洞的工程无限平面', () => {
const projectManifest = manifest();
const physics = projectManifest.files.find(
(candidate) => candidate.path === 'maps/warehouse/physics/world.xml',
)!;
physics.data = encoder.encode(
'<mujoco><worldbody><geom name="floor" type="plane" size="8 8 .1"/></worldbody></mujoco>',
);
physics.size = physics.data.byteLength;
const result = composePlacedMapAssets(robot, 'scene.xml', projectManifest, [
{
id: 'pit-a',
name: '深坑',
selection: {
kind: 'builtin',
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'pit' },
},
},
project('warehouse-a'),
]);
const document = documentOf(result.data);
expect(document.querySelectorAll('geom[type="plane"]')).toHaveLength(0);
expect(document.querySelector('[name^="__platform_map_pit-a__pit-bottom"]')).not.toBeNull();
});
it('非系统内置地图复用已有承载面,不生成重叠地面', () => {
const result = composePlacedMapAssets(robot, 'scene.xml', manifest(), [
{
id: 'stairs-a',
name: '楼梯',
selection: {
kind: 'builtin',
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'stairs', stairCount: 4 },
},
},
{
id: 'obstacles-a',
name: '障碍物',
selection: {
kind: 'builtin',
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'obstacles', obstacleCount: 3 },
},
},
]);
const document = documentOf(result.data);
expect(document.querySelectorAll('geom[type="plane"]')).toHaveLength(1);
expect(document.querySelector('[name$="__ground__"]')).toBeNull();
});
it('同一工程地图重复放置时使用实例命名空间和独立位姿', () => {
const first = project('copy-a');
first.selection = {
kind: 'project',
descriptorPath: 'maps/warehouse/map.json',
positionX: 2,
positionY: -1,
yawDeg: 30,
};
const second = project('copy-b');
second.selection = {
kind: 'project',
descriptorPath: 'maps/warehouse/map.json',
positionX: -3,
positionY: 4,
yawDeg: -45,
};
const result = composePlacedMapAssets(robot, 'scene.xml', manifest(), [first, second]);
const document = documentOf(result.data);
expect(document.querySelector('[name="__platform_map_warehouse_copy-a_wall"]')).not.toBeNull();
expect(document.querySelector('[name="__platform_map_warehouse_copy-b_wall"]')).not.toBeNull();
expect(
document
.querySelector('[name="__platform_map_warehouse_copy-a_instance_root"]')
?.getAttribute('pos'),
).toBe('2 -1 0');
expect(
document
.querySelector('[name="__platform_map_warehouse_copy-b_instance_root"]')
?.getAttribute('pos'),
).toBe('-3 4 0');
const names = Array.from(document.querySelectorAll('[name]'), (element) =>
element.getAttribute('name'),
);
expect(new Set(names).size).toBe(names.length);
});
it('多个实例声明出生点时只确定性应用第一个', () => {
const projectManifest = manifest();
const descriptorFile = projectManifest.files.find(
(candidate) => candidate.path === 'maps/warehouse/map.json',
)!;
const descriptorValue = JSON.parse(decoder.decode(descriptorFile.data));
descriptorValue.spawnPoints = [
{ id: 'first', name: '第一出生点', position: [1, 0, 0], yawDeg: 0 },
{ id: 'second', name: '第二出生点', position: [10, 0, 0], yawDeg: 0 },
];
descriptorFile.data = encoder.encode(JSON.stringify(descriptorValue));
descriptorFile.size = descriptorFile.data.byteLength;
const result = composePlacedMapAssets(robot, 'scene.xml', projectManifest, [
{
...project('copy-a'),
selection: {
kind: 'project',
descriptorPath: 'maps/warehouse/map.json',
spawnPointId: 'first',
},
},
{
...project('copy-b'),
selection: {
kind: 'project',
descriptorPath: 'maps/warehouse/map.json',
spawnPointId: 'second',
},
},
]);
expect(
documentOf(result.data)
.querySelector('body[name="robot"]')
?.getAttribute('pos')
?.split(/\s+/)
.map(Number),
).toEqual([1, 0, 0]);
expect(result.warnings.join(' ')).toContain('第一出生点');
expect(result.warnings.join(' ')).not.toContain('第二出生点');
});
it('多个内置实例同时存在且名称不冲突', () => {
const result = composePlacedMapAssets(robot, 'scene.xml', manifest(), [
{
id: 'stairs-a',
name: '楼梯',
selection: {
kind: 'builtin',
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'stairs', stairCount: 3 },
},
},
{
id: 'obstacles-a',
name: '障碍物',
selection: {
kind: 'builtin',
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'obstacles', obstacleCount: 2 },
},
},
]);
const document = documentOf(result.data);
expect(document.querySelectorAll('[name^="__platform_map_stairs-a__stair_"]')).toHaveLength(3);
expect(
document.querySelectorAll('[name^="__platform_map_obstacles-a__obstacle_"]'),
).toHaveLength(2);
expect(document.querySelector('[name="__platform_map_stairs-a__root__"]')).not.toBeNull();
expect(document.querySelector('[name="__platform_map_obstacles-a__root__"]')).not.toBeNull();
});
});
+68
View File
@@ -0,0 +1,68 @@
import type { ProjectManifest } from '../project/types';
import { resolveProjectMap } from './MapLoader';
import { composeProjectMap } from './MapComposer';
import { composePhysicalMap } from './physicalMap';
import { isSystemTerrainPreset, type PlacedMapAsset } from './types';
export interface MapStackComposition {
data: Uint8Array;
geomCount: number;
warnings: string[];
}
/** 将全部已放置地图实例依次合成到同一 MJCF;源地图条目本身不会自动进入场景。 */
export function composePlacedMapAssets(
source: Uint8Array,
generatedScenePath: string,
manifest: ProjectManifest,
assets: readonly PlacedMapAsset[],
): MapStackComposition {
let data = source;
let geomCount = 0;
const warnings: string[] = [];
const spawnOwnerId = assets.find(
(asset) => asset.selection.kind === 'project' && asset.selection.spawnPointId,
)?.id;
// 程序地形最后合成,确保工程地图或机器人自带的无限 plane 不会覆盖沟壑/深坑。
const orderedAssets = [
...assets.filter(
(asset) =>
asset.selection.kind !== 'builtin' || !isSystemTerrainPreset(asset.selection.config.preset),
),
...assets.filter(
(asset) =>
asset.selection.kind === 'builtin' && isSystemTerrainPreset(asset.selection.config.preset),
),
];
for (const asset of orderedAssets) {
if (asset.selection.kind === 'builtin') {
const composed = composePhysicalMap(data, asset.selection.config, {
instanceId: asset.id,
preserveExistingGround: true,
});
data = composed.data;
geomCount += composed.geomCount;
if (composed.summary) warnings.push(`${asset.name}:${composed.summary}`);
continue;
}
const resolvedMap = resolveProjectMap(manifest, asset.selection.descriptorPath);
const composed = composeProjectMap(
data,
generatedScenePath,
manifest,
resolvedMap,
asset.selection,
{
instanceId: asset.id,
applySpawnPoint: asset.id === spawnOwnerId,
},
);
data = composed.data;
geomCount += composed.geomCount;
warnings.push(...composed.warnings, `${asset.name}:${composed.summary}`);
}
return { data, geomCount, warnings };
}
+110 -13
View File
@@ -11,21 +11,50 @@ const common = {
showMapCollision: false, showMapCollision: false,
editorDocument: null, editorDocument: null,
onEditorPreview: () => {}, onEditorPreview: () => {},
onEditorDraftChange: () => {},
onEditorApply: async () => true, onEditorApply: async () => true,
onEditorExport: () => {}, onEditorExport: () => {},
onEditorConvert: async () => true, onEditorConvert: async () => true,
onEditorBindInteraction: () => {}, onEditorBindInteraction: () => {},
onEditorSelect: () => {}, onEditorSelect: () => {},
onEditorTransformMode: () => {}, onEditorSessionStateChange: () => {},
onEditorSnapping: () => {},
onMapDisplay: () => {}, onMapDisplay: () => {},
onDraft: () => {},
}; };
describe('PhysicalMapPanel', () => { describe('PhysicalMapPanel', () => {
it('无选中实例时显示引导空态', () => {
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={() => {}} />);
expect(screen.getByText('未选择地图实例')).toBeInTheDocument();
expect(screen.getByLabelText('地图来源')).toHaveValue('none');
});
it('已有场景草稿时无需再次改参数也可一次编译', () => {
const onApply = vi.fn();
render(
<PhysicalMapPanel
{...common}
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough' } }}
sceneDirty
onApply={onApply}
/>,
);
expect(screen.getByText(/场景草稿正在轻量预览/)).toBeInTheDocument();
const apply = screen.getByRole('button', { name: '应用并重新编译' });
expect(apply).toBeEnabled();
fireEvent.click(apply);
expect(onApply).toHaveBeenCalledTimes(1);
});
it('选择楼梯参数后请求重新编译', () => { it('选择楼梯参数后请求重新编译', () => {
const onApply = vi.fn(); const onApply = vi.fn();
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />); render(
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } }); <PhysicalMapPanel
{...common}
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'flat' } }}
onApply={onApply}
/>,
);
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'stairs' } }); fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'stairs' } });
fireEvent.change(screen.getByLabelText('台阶数量'), { target: { value: '6' } }); fireEvent.change(screen.getByLabelText('台阶数量'), { target: { value: '6' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' })); fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
@@ -37,8 +66,13 @@ describe('PhysicalMapPanel', () => {
it('在右侧参数面板调整系统地形并提交', () => { it('在右侧参数面板调整系统地形并提交', () => {
const onApply = vi.fn(); const onApply = vi.fn();
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />); render(
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } }); <PhysicalMapPanel
{...common}
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'flat' } }}
onApply={onApply}
/>,
);
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'rough' } }); fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'rough' } });
fireEvent.change(screen.getByLabelText('地形难度(0–1)'), { target: { value: '0.8' } }); fireEvent.change(screen.getByLabelText('地形难度(0–1)'), { target: { value: '0.8' } });
fireEvent.change(screen.getByLabelText('随机种子'), { target: { value: '9' } }); fireEvent.change(screen.getByLabelText('随机种子'), { target: { value: '9' } });
@@ -53,10 +87,64 @@ describe('PhysicalMapPanel', () => {
}); });
}); });
it('参数地形属性不再承载视口变换工具', () => {
render(
<PhysicalMapPanel
{...common}
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough' } }}
onApply={() => {}}
/>,
);
expect(screen.queryByLabelText('地图变换模式')).not.toBeInTheDocument();
expect(screen.getByText(/已统一到视口浮动工具条/)).toBeInTheDocument();
});
it('表单参数与外部视口变换合并到同一份草稿', () => {
const onDraft = vi.fn();
const onApply = vi.fn();
const initial = {
kind: 'builtin' as const,
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough' as const },
};
const view = render(
<PhysicalMapPanel
{...common}
value={initial}
sceneDirty
onDraft={onDraft}
onApply={onApply}
/>,
);
fireEvent.change(screen.getByLabelText('地形边长(m)'), { target: { value: '7' } });
const staged = onDraft.mock.calls.at(-1)?.[0];
expect(staged).toMatchObject({ kind: 'builtin', config: { size: 7 } });
const moved = {
...staged,
config: { ...staged.config, positionX: 2.4, positionY: -1.2 },
};
view.rerender(
<PhysicalMapPanel {...common} value={moved} sceneDirty onDraft={onDraft} onApply={onApply} />,
);
expect(screen.getByLabelText('地形边长(m)')).toHaveValue(7);
expect(screen.getByLabelText('位置 X(m)')).toHaveValue(2.4);
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
expect(onApply).toHaveBeenLastCalledWith(
expect.objectContaining({
kind: 'builtin',
config: expect.objectContaining({ size: 7, positionX: 2.4, positionY: -1.2 }),
}),
);
});
it('可以移动并旋转资产地图', () => { it('可以移动并旋转资产地图', () => {
const onApply = vi.fn(); const onApply = vi.fn();
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />); render(
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } }); <PhysicalMapPanel
{...common}
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'flat' } }}
onApply={onApply}
/>,
);
fireEvent.change(screen.getByLabelText('位置 X(m)'), { target: { value: '2.5' } }); fireEvent.change(screen.getByLabelText('位置 X(m)'), { target: { value: '2.5' } });
fireEvent.change(screen.getByLabelText('位置 Y(m)'), { target: { value: '-1.5' } }); fireEvent.change(screen.getByLabelText('位置 Y(m)'), { target: { value: '-1.5' } });
fireEvent.change(screen.getByLabelText('旋转 Z(°)'), { target: { value: '45' } }); fireEvent.change(screen.getByLabelText('旋转 Z(°)'), { target: { value: '45' } });
@@ -67,12 +155,13 @@ describe('PhysicalMapPanel', () => {
}); });
}); });
it('显示工程地图、出生点和根 Body', () => { it('显示工程地图、实例变换、出生点和根 Body', () => {
const onApply = vi.fn(); const onApply = vi.fn();
const onDraft = vi.fn();
render( render(
<PhysicalMapPanel <PhysicalMapPanel
{...common} {...common}
value={{ kind: 'none' }} value={{ kind: 'project', descriptorPath: 'maps/warehouse/map.json' }}
maps={[ maps={[
{ {
descriptorPath: 'maps/warehouse/map.json', descriptorPath: 'maps/warehouse/map.json',
@@ -83,12 +172,14 @@ describe('PhysicalMapPanel', () => {
}, },
]} ]}
rootBodies={['robot', 'payload']} rootBodies={['robot', 'payload']}
onDraft={onDraft}
onApply={onApply} onApply={onApply}
/>, />,
); );
fireEvent.change(screen.getByLabelText('地图来源'), { fireEvent.change(screen.getByLabelText('位置 X(m)'), { target: { value: '2.5' } });
target: { value: 'project:maps/warehouse/map.json' }, fireEvent.change(screen.getByLabelText('位置 Y(m)'), { target: { value: '-1.5' } });
}); fireEvent.change(screen.getByLabelText('旋转 Z(°)'), { target: { value: '45' } });
fireEvent.change(screen.getByLabelText('地图出生点'), { target: { value: 'door' } });
expect(screen.getByLabelText('地图出生点')).toHaveValue('door'); expect(screen.getByLabelText('地图出生点')).toHaveValue('door');
fireEvent.change(screen.getByLabelText('机器人根 Body'), { target: { value: 'robot' } }); fireEvent.change(screen.getByLabelText('机器人根 Body'), { target: { value: 'robot' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' })); fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
@@ -96,10 +187,16 @@ describe('PhysicalMapPanel', () => {
expect.objectContaining({ expect.objectContaining({
kind: 'project', kind: 'project',
descriptorPath: 'maps/warehouse/map.json', descriptorPath: 'maps/warehouse/map.json',
positionX: 2.5,
positionY: -1.5,
yawDeg: 45,
spawnPointId: 'door', spawnPointId: 'door',
robotRootBody: 'robot', robotRootBody: 'robot',
}), }),
); );
expect(onDraft).toHaveBeenLastCalledWith(
expect.objectContaining({ kind: 'project', robotRootBody: 'robot' }),
);
}); });
it('原生 URDF 模式只允许移除地图', () => { it('原生 URDF 模式只允许移除地图', () => {
+198 -46
View File
@@ -1,19 +1,21 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button, Select } from '../components/ui'; import { Button, ScrubbableNumberInput, Select } from '../components/ui';
import type { MapEntry } from '../project/types'; import type { MapEntry } from '../project/types';
import { import {
DEFAULT_PHYSICAL_MAP_CONFIG, DEFAULT_PHYSICAL_MAP_CONFIG,
PHYSICAL_MAP_PRESET_LABELS, PHYSICAL_MAP_PRESET_LABELS,
isSystemTerrainPreset, isSystemTerrainPreset,
mapSelectionTransform,
type MapSelection, type MapSelection,
type PhysicalMapConfig, type PhysicalMapConfig,
type PhysicalMapPreset, type PhysicalMapPreset,
type PlacedMapSelection,
} from './types'; } from './types';
import { normalizePhysicalMapConfig } from './physicalMap'; import { normalizePhysicalMapConfig } from './physicalMap';
import type { import type {
EditableMapDocument, EditableMapDocument,
MapEditorInteractionCallbacks, MapEditorInteractionCallbacks,
MapEditorTransformMode, MapEditorSessionState,
} from './editor/types'; } from './editor/types';
import { MapEditorPanel } from './MapEditorPanel'; import { MapEditorPanel } from './MapEditorPanel';
@@ -35,19 +37,15 @@ function NumberField({
onChange: (value: number) => void; onChange: (value: number) => void;
}) { }) {
return ( return (
<label className="block text-xs text-text-secondary"> <ScrubbableNumberInput
<span className="mb-1 block">{label}</span> label={label}
<input
type="number"
className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 disabled:cursor-not-allowed disabled:opacity-50"
value={value} value={value}
min={min} min={min}
max={max} max={max}
step={step} step={step}
disabled={disabled} disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))} onValueChange={onChange}
/> />
</label>
); );
} }
@@ -56,6 +54,7 @@ function normalizeSelection(selection: MapSelection): MapSelection {
const friction = selection.frictionOverride; const friction = selection.frictionOverride;
return { return {
...selection, ...selection,
...mapSelectionTransform(selection),
frictionOverride: frictionOverride:
friction === undefined || !Number.isFinite(friction) friction === undefined || !Number.isFinite(friction)
? undefined ? undefined
@@ -76,15 +75,20 @@ export function PhysicalMapPanel({
showVisualMap, showVisualMap,
showMapCollision, showMapCollision,
editorDocument, editorDocument,
editorDraftDocument,
sceneDirty = false,
onEditorPreview, onEditorPreview,
onEditorDraftChange,
onEditorApply, onEditorApply,
onEditorExport, onEditorExport,
onEditorConvert, onEditorConvert,
selectedEditorObjectId,
onEditorBindInteraction, onEditorBindInteraction,
onEditorSelect, onEditorSelect,
onEditorTransformMode, onEditorSessionStateChange,
onEditorSnapping, onEditorSurfaceHeight,
onMapDisplay, onMapDisplay,
onDraft,
onApply, onApply,
}: { }: {
value: MapSelection; value: MapSelection;
@@ -95,20 +99,37 @@ export function PhysicalMapPanel({
showVisualMap: boolean; showVisualMap: boolean;
showMapCollision: boolean; showMapCollision: boolean;
editorDocument: EditableMapDocument | null; editorDocument: EditableMapDocument | null;
editorDraftDocument?: EditableMapDocument;
sceneDirty?: boolean;
onEditorPreview: (document: EditableMapDocument | null) => void; onEditorPreview: (document: EditableMapDocument | null) => void;
onEditorDraftChange: (
descriptorPath: string,
document: EditableMapDocument,
dirty: boolean,
) => void;
onEditorApply: (document: EditableMapDocument) => Promise<boolean>; onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
onEditorExport: () => void; onEditorExport: () => void;
onEditorConvert: () => Promise<boolean>; onEditorConvert: () => Promise<boolean>;
selectedEditorObjectId?: string;
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void; onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onEditorSelect: (id: string | null) => void; onEditorSelect: (id: string | null) => void;
onEditorTransformMode: (mode: MapEditorTransformMode) => void; onEditorSessionStateChange?: (
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void; descriptorPath: string,
state: MapEditorSessionState | null,
) => void;
onEditorSurfaceHeight?: (position: readonly [number, number, number]) => number | null;
onMapDisplay: (visual: boolean, collision: boolean) => void; onMapDisplay: (visual: boolean, collision: boolean) => void;
onDraft: (value: PlacedMapSelection) => void;
onApply: (value: MapSelection) => void; onApply: (value: MapSelection) => void;
}) { }) {
const [draft, setDraft] = useState<MapSelection>(value); const [draft, setDraft] = useState<MapSelection>(value),
[previousValue, setPreviousValue] = useState(value);
if (previousValue !== value) {
setPreviousValue(value);
setDraft(value);
}
const normalized = normalizeSelection(draft); const normalized = normalizeSelection(draft);
const changed = JSON.stringify(normalized) !== JSON.stringify(value); const changed = JSON.stringify(normalized) !== JSON.stringify(normalizeSelection(value));
const compileDisabled = loading || (nativeUrdf && normalized.kind !== 'none'); const compileDisabled = loading || (nativeUrdf && normalized.kind !== 'none');
const sourceValue = const sourceValue =
draft.kind === 'none' draft.kind === 'none'
@@ -120,9 +141,18 @@ export function PhysicalMapPanel({
draft.kind === 'project' draft.kind === 'project'
? maps.find((entry) => entry.descriptorPath === draft.descriptorPath) ? maps.find((entry) => entry.descriptorPath === draft.descriptorPath)
: undefined; : undefined;
const replaceBuiltin = (config: PhysicalMapConfig) => {
const next: Extract<MapSelection, { kind: 'builtin' }> = { kind: 'builtin', config };
setDraft(next);
if (value.kind === 'builtin') onDraft(next);
};
const updateBuiltin = <K extends keyof PhysicalMapConfig>(key: K, next: PhysicalMapConfig[K]) => { const updateBuiltin = <K extends keyof PhysicalMapConfig>(key: K, next: PhysicalMapConfig[K]) => {
if (draft.kind !== 'builtin') return; if (draft.kind !== 'builtin') return;
setDraft({ kind: 'builtin', config: { ...draft.config, [key]: next } }); replaceBuiltin({ ...draft.config, [key]: next });
};
const replaceProject = (next: Extract<MapSelection, { kind: 'project' }>) => {
setDraft(next);
if (value.kind === 'project' && value.descriptorPath === next.descriptorPath) onDraft(next);
}; };
const changeSource = (source: string) => { const changeSource = (source: string) => {
@@ -141,20 +171,104 @@ export function PhysicalMapPanel({
setDraft({ setDraft({
kind: 'project', kind: 'project',
descriptorPath, descriptorPath,
positionX: 0,
positionY: 0,
yawDeg: 0,
spawnPointId: map?.spawnPoints[0]?.id, spawnPointId: map?.spawnPoints[0]?.id,
}); });
} }
}; };
const editorPanel =
draft.kind === 'project' &&
draft.descriptorPath === (value.kind === 'project' ? value.descriptorPath : '') ? (
<div className="space-y-2 rounded border border-border-subtle p-3">
<div className="text-xs font-medium text-text-primary">
{selectedEditorObjectId ? '选中物体属性' : '认证资产与场景对象属性'}
</div>
<p className="text-[10px] leading-relaxed text-text-tertiary">
{selectedEditorObjectId
? '尺寸、位姿、表面材质与摩擦力会实时写入 MapEditSession 草稿。'
: '此处编辑地图源内容;同源实例共享内容,实例位姿彼此独立。'}
</p>
<MapEditorPanel
document={editorDocument}
draftDocument={editorDraftDocument}
descriptorPath={draft.descriptorPath}
loading={loading}
onPreview={onEditorPreview}
onDraftChange={onEditorDraftChange}
onApply={onEditorApply}
onExport={onEditorExport}
onConvert={onEditorConvert}
canConvert={Boolean(projectMap?.physicsPath)}
selectedObjectId={selectedEditorObjectId}
onBindInteraction={onEditorBindInteraction}
onSelectPreview={onEditorSelect}
onSessionStateChange={onEditorSessionStateChange}
onSurfaceHeight={onEditorSurfaceHeight}
/>
</div>
) : null;
if (value.kind === 'none' && draft.kind === 'none')
return (
<div className="space-y-3 p-3">
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-3">
<div className="text-xs font-semibold text-text-primary">未选择地图实例</div>
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
请从左侧“资产”页放置或选择地图。参数修改只会作用于当前选中的实例。
</p>
<label className="mt-3 block text-xs text-text-secondary">
<span className="mb-1 block">快速放置地图</span>
<Select
aria-label="地图来源"
className="w-full"
value="none"
disabled={loading}
onChange={(event) => changeSource(event.target.value)}
>
<option value="none">请选择地图源</option>
<option value="builtin">内置程序地图</option>
{maps.map((map) => (
<option key={map.descriptorPath} value={`project:${map.descriptorPath}`}>
{map.name}
</option>
))}
</Select>
</label>
</div>
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-secondary">
<label className="flex items-center justify-between gap-3">
<span>显示视觉地图</span>
<input
type="checkbox"
checked={showVisualMap}
onChange={(event) => onMapDisplay(event.target.checked, showMapCollision)}
/>
</label>
<label className="flex items-center justify-between gap-3">
<span>显示地图碰撞层</span>
<input
type="checkbox"
checked={showMapCollision}
onChange={(event) => onMapDisplay(showVisualMap, event.target.checked)}
/>
</label>
</div>
</div>
);
return ( return (
<div className="space-y-4 p-3"> <div className="space-y-4 p-3">
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-3"> <div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-3">
<div className="text-xs font-semibold text-text-primary">地图参数</div> <div className="text-xs font-semibold text-text-primary">地图与对象属性</div>
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary"> <p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
从左侧“资产”选择地形或添加场景对象,在这里调整物理参数与编辑属性。 参数地形与认证资产统一在此管理。视口选中对象后可调整变换和专属参数。
</p> </p>
</div> </div>
{editorPanel}
<label className="block text-xs text-text-secondary"> <label className="block text-xs text-text-secondary">
<span className="mb-1 block">地图来源</span> <span className="mb-1 block">地图来源</span>
<Select <Select
@@ -176,6 +290,10 @@ export function PhysicalMapPanel({
{draft.kind === 'builtin' && ( {draft.kind === 'builtin' && (
<> <>
<div className="text-xs font-medium text-text-primary">参数地形属性</div>
<p className="rounded border border-border-subtle bg-panel-muted/50 p-2 text-[10px] leading-relaxed text-text-tertiary">
移动、旋转与网格吸附已统一到视口浮动工具条;尺寸继续通过下方参数精确调整。
</p>
<label className="block text-xs text-text-secondary"> <label className="block text-xs text-text-secondary">
<span className="mb-1 block">地图预设</span> <span className="mb-1 block">地图预设</span>
<Select <Select
@@ -205,10 +323,7 @@ export function PhysicalMapPanel({
draft.config.yawDeg === 0) draft.config.yawDeg === 0)
} }
onClick={() => onClick={() =>
setDraft({ replaceBuiltin({ ...draft.config, positionX: 0, positionY: 0, yawDeg: 0 })
kind: 'builtin',
config: { ...draft.config, positionX: 0, positionY: 0, yawDeg: 0 },
})
} }
> >
复位 复位
@@ -349,6 +464,54 @@ export function PhysicalMapPanel({
<div className="break-all text-[11px] text-text-tertiary"> <div className="break-all text-[11px] text-text-tertiary">
{projectMap.descriptorPath} {projectMap.descriptorPath}
</div> </div>
<div className="rounded-lg border border-border-subtle bg-panel-bg/40 p-2.5">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-text-primary">实例变换</span>
<Button
disabled={
loading ||
((draft.positionX ?? 0) === 0 &&
(draft.positionY ?? 0) === 0 &&
(draft.yawDeg ?? 0) === 0)
}
onClick={() => replaceProject({ ...draft, positionX: 0, positionY: 0, yawDeg: 0 })}
>
复位
</Button>
</div>
<div className="grid grid-cols-3 gap-2">
<NumberField
label="位置 X(m)"
value={draft.positionX ?? 0}
min={-100}
max={100}
step={0.1}
disabled={loading}
onChange={(positionX) => replaceProject({ ...draft, positionX })}
/>
<NumberField
label="位置 Y(m)"
value={draft.positionY ?? 0}
min={-100}
max={100}
step={0.1}
disabled={loading}
onChange={(positionY) => replaceProject({ ...draft, positionY })}
/>
<NumberField
label="旋转 Z(°)"
value={draft.yawDeg ?? 0}
min={-180}
max={180}
step={1}
disabled={loading}
onChange={(yawDeg) => replaceProject({ ...draft, yawDeg })}
/>
</div>
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
位姿属于当前场景实例;编辑下方源内容会同步影响引用同一地图的实例。
</p>
</div>
{projectMap.spawnPoints.length > 0 && ( {projectMap.spawnPoints.length > 0 && (
<label className="block text-xs text-text-secondary"> <label className="block text-xs text-text-secondary">
<span className="mb-1 block">出生点</span> <span className="mb-1 block">出生点</span>
@@ -358,7 +521,7 @@ export function PhysicalMapPanel({
value={draft.spawnPointId ?? ''} value={draft.spawnPointId ?? ''}
disabled={loading} disabled={loading}
onChange={(event) => onChange={(event) =>
setDraft({ ...draft, spawnPointId: event.target.value || undefined }) replaceProject({ ...draft, spawnPointId: event.target.value || undefined })
} }
> >
<option value="">不改变机器人位置</option> <option value="">不改变机器人位置</option>
@@ -379,7 +542,7 @@ export function PhysicalMapPanel({
value={draft.robotRootBody ?? ''} value={draft.robotRootBody ?? ''}
disabled={loading} disabled={loading}
onChange={(event) => onChange={(event) =>
setDraft({ ...draft, robotRootBody: event.target.value || undefined }) replaceProject({ ...draft, robotRootBody: event.target.value || undefined })
} }
> >
<option value="">自动识别</option> <option value="">自动识别</option>
@@ -403,7 +566,7 @@ export function PhysicalMapPanel({
disabled={loading} disabled={loading}
className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary" className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary"
onChange={(event) => onChange={(event) =>
setDraft({ replaceProject({
...draft, ...draft,
frictionOverride: event.target.value ? Number(event.target.value) : undefined, frictionOverride: event.target.value ? Number(event.target.value) : undefined,
}) })
@@ -413,26 +576,6 @@ export function PhysicalMapPanel({
</div> </div>
)} )}
{draft.kind === 'project' &&
draft.descriptorPath === (value.kind === 'project' ? value.descriptorPath : '') && (
<div className="space-y-2 rounded border border-border-subtle p-3">
<div className="text-xs font-medium text-text-primary">V3 地图编辑器</div>
<MapEditorPanel
document={editorDocument}
loading={loading}
onPreview={onEditorPreview}
onApply={onEditorApply}
onExport={onEditorExport}
onConvert={onEditorConvert}
canConvert={Boolean(projectMap?.physicsPath)}
onBindInteraction={onEditorBindInteraction}
onSelectPreview={onEditorSelect}
onTransformMode={onEditorTransformMode}
onSnapping={onEditorSnapping}
/>
</div>
)}
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-secondary"> <div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-secondary">
<label className="flex items-center justify-between gap-3"> <label className="flex items-center justify-between gap-3">
<span>显示视觉地图</span> <span>显示视觉地图</span>
@@ -461,10 +604,19 @@ export function PhysicalMapPanel({
</p> </p>
)} )}
{sceneDirty && (
<p
role="status"
className="rounded border border-accent/30 bg-accent-soft p-2 text-xs text-accent"
>
场景草稿正在轻量预览;工程地图、认证资产与参数地形会在确认后一次性编译。
</p>
)}
<Button <Button
variant="primary" variant="primary"
className="w-full" className="w-full"
disabled={compileDisabled || !changed} disabled={compileDisabled || (!changed && !sceneDirty)}
onClick={() => onApply(normalized)} onClick={() => onApply(normalized)}
> >
应用并重新编译 应用并重新编译
@@ -0,0 +1,92 @@
import type { ProjectFile, ProjectManifest } from '../../project/types';
import { discoverMapEntries } from '../MapLoader';
import { materializeEditableMapDrafts } from './EditableMapDraftCommit';
import { decodeEditableMapDocument } from './editorSchema';
import { createEditableObject, type EditableMapDocument } from './types';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function file(path: string, text: string): ProjectFile {
const data = encoder.encode(text);
return { path, data, size: data.byteLength, source: 'zip', mimeType: '' };
}
function manifest(): ProjectManifest {
const document: EditableMapDocument = {
schemaVersion: 1,
mapId: 'scene',
revision: 2,
objects: [],
spawnPoints: [],
};
const files = [
file(
'maps/scene/map.json',
JSON.stringify({
schemaVersion: 2,
id: 'scene',
name: '场景',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'physics/world.xml' },
authoring: { source: 'authoring/map.scene.json' },
spawnPoints: [],
}),
),
file('maps/scene/physics/world.xml', '<mujoco><worldbody/></mujoco>'),
file('maps/scene/authoring/map.scene.json', JSON.stringify(document)),
file('robot.xml', '<mujoco><worldbody/></mujoco>'),
];
return {
id: 'project',
name: 'project',
files,
entries: [{ path: 'robot.xml', format: 'mjcf', label: 'robot.xml' }],
maps: discoverMapEntries(files),
totalBytes: files.reduce((total, item) => total + item.size, 0),
};
}
describe('认证资产场景草稿提交', () => {
it('在不修改原清单的前提下同步生成 authoring、physics 与出生点描述', () => {
const original = manifest();
const draft: EditableMapDocument = {
schemaVersion: 1,
mapId: 'scene',
revision: 2,
objects: [createEditableObject('box', 'certified_box')],
spawnPoints: [{ id: 'start', name: '起点', position: [1, 2, 0], yawDeg: 90 }],
};
const candidate = materializeEditableMapDrafts(
original,
new Map([['maps/scene/map.json', draft]]),
);
expect(candidate).not.toBe(original);
expect(decoder.decode(original.files[1].data)).not.toContain('certified_box');
expect(decoder.decode(candidate.files[1].data)).toContain('edit_certified_box');
expect(decodeEditableMapDocument(candidate.files[2].data)).toMatchObject({
revision: 3,
objects: [{ id: 'certified_box' }],
});
expect(JSON.parse(decoder.decode(candidate.files[0].data))).toMatchObject({
spawnPoints: [{ id: 'start', name: '起点' }],
});
expect(candidate.maps[0].spawnPoints).toEqual([{ id: 'start', name: '起点' }]);
});
it('拒绝把其他地图的草稿写入当前描述文件', () => {
const original = manifest();
const draft: EditableMapDocument = {
schemaVersion: 1,
mapId: 'other',
revision: 0,
objects: [],
spawnPoints: [],
};
expect(() =>
materializeEditableMapDrafts(original, new Map([['maps/scene/map.json', draft]])),
).toThrow('mapId');
});
});
@@ -0,0 +1,59 @@
import type { ProjectManifest } from '../../project/types';
import { discoverMapEntries, resolveProjectMap } from '../MapLoader';
import { decodeMapDefinition } from '../mapSchema';
import { compileEditableMapDocument } from './MapDocumentCompiler';
import { decodeEditableMapDocument, encodeEditableMapDocument } from './editorSchema';
import type { EditableMapDocument } from './types';
/**
* 将场景编辑草稿写入一个新的工程清单,供后续 MuJoCo 事务式编译使用。
* 原清单不会被修改;只有仿真编译成功后,调用方才应提交返回值。
*/
export function materializeEditableMapDrafts(
manifest: ProjectManifest,
drafts: ReadonlyMap<string, EditableMapDocument>,
): ProjectManifest {
if (!drafts.size) return manifest;
const replacements = new Map<string, Uint8Array>();
for (const [descriptorPath, requestedDocument] of drafts) {
const resolved = resolveProjectMap(manifest, descriptorPath);
if (!resolved.authoringPath || !resolved.physicsPath)
throw new Error(
`可编辑地图 ${descriptorPath} 必须同时声明 authoring.source 和 physics.source`,
);
const authoringFile = manifest.files.find((file) => file.path === resolved.authoringPath);
if (!authoringFile) throw new Error(`地图创作层不存在:${resolved.authoringPath}`);
const savedDocument = decodeEditableMapDocument(authoringFile.data);
if (requestedDocument.mapId !== savedDocument.mapId)
throw new Error(`地图草稿 mapId 必须为 ${savedDocument.mapId}`);
const document: EditableMapDocument = {
...structuredClone(requestedDocument),
revision: Math.max(savedDocument.revision + 1, requestedDocument.revision),
};
const definition = decodeMapDefinition(
manifest.files.find((file) => file.path === resolved.descriptorPath)!.data,
);
definition.spawnPoints = document.spawnPoints;
replacements.set(resolved.authoringPath, encodeEditableMapDocument(document));
replacements.set(resolved.physicsPath, compileEditableMapDocument(document));
replacements.set(
resolved.descriptorPath,
new TextEncoder().encode(`${JSON.stringify(definition, null, 2)}\n`),
);
}
const files = manifest.files.map((file) => {
const data = replacements.get(file.path);
return data ? { ...file, data, size: data.byteLength } : file;
});
return {
...manifest,
files,
maps: discoverMapEntries(files),
totalBytes: files.reduce((total, file) => total + file.size, 0),
};
}
+26 -7
View File
@@ -23,9 +23,17 @@ export class MapEditSession {
private redoStack: HistoryEntry[] = []; private redoStack: HistoryEntry[] = [];
selectedId?: string; selectedId?: string;
constructor(document: EditableMapDocument) { constructor(
this.current = clone(parseEditableMapDocument(document)); document: EditableMapDocument,
this.saved = clone(this.current); draft: EditableMapDocument = document,
private readonly externalSurfaceHeight?: (
position: readonly [number, number, number],
) => number | null,
) {
this.saved = clone(parseEditableMapDocument(document));
this.current = clone(parseEditableMapDocument(draft));
if (this.current.mapId !== this.saved.mapId)
throw new Error(`地图草稿 mapId 必须为 ${this.saved.mapId}`);
} }
get document(): EditableMapDocument { get document(): EditableMapDocument {
return clone(this.current); return clone(this.current);
@@ -33,12 +41,22 @@ export class MapEditSession {
get dirty(): boolean { get dirty(): boolean {
return JSON.stringify(this.current) !== JSON.stringify(this.saved); return JSON.stringify(this.current) !== JSON.stringify(this.saved);
} }
/**
* 会话内可撤销操作数。恢复自持久化草稿时历史栈为空,但仍至少记为一项改动。
*/
get changeCount(): number {
return this.dirty ? Math.max(1, this.undoStack.length) : 0;
}
get canUndo(): boolean { get canUndo(): boolean {
return this.undoStack.length > 0; return this.undoStack.length > 0;
} }
get canRedo(): boolean { get canRedo(): boolean {
return this.redoStack.length > 0; return this.redoStack.length > 0;
} }
private supportTop(object: EditableMapObject, fallback = 0): number {
if (object.placementMode !== 'gravity') return fallback;
return this.externalSurfaceHeight?.(object.pose.position) ?? fallback;
}
private commit(mutator: (draft: EditableMapDocument) => void): void { private commit(mutator: (draft: EditableMapDocument) => void): void {
const before = clone(this.current), const before = clone(this.current),
after = clone(this.current); after = clone(this.current);
@@ -51,7 +69,7 @@ export class MapEditSession {
} }
add(type: EditableMapObjectType): EditableMapObject { add(type: EditableMapObjectType): EditableMapObject {
const object = createEditableObject(type); const object = createEditableObject(type);
applyObjectPlacement(object, this.current.objects); applyObjectPlacement(object, this.current.objects, this.supportTop(object));
this.commit((draft) => draft.objects.push(object)); this.commit((draft) => draft.objects.push(object));
this.selectedId = object.id; this.selectedId = object.id;
return structuredClone(object); return structuredClone(object);
@@ -60,9 +78,10 @@ export class MapEditSession {
type: EditableMapObjectType, type: EditableMapObjectType,
position?: [number, number, number], position?: [number, number, number],
placementMode: MapObjectPlacementMode = 'auto_ground', placementMode: MapObjectPlacementMode = 'auto_ground',
externalSupportTop = 0,
): EditableMapObject { ): EditableMapObject {
const object = createPlacedMapAsset(type, this.current.objects.length, position, placementMode); const object = createPlacedMapAsset(type, this.current.objects.length, position, placementMode);
applyObjectPlacement(object, this.current.objects); applyObjectPlacement(object, this.current.objects, this.supportTop(object, externalSupportTop));
this.commit((draft) => draft.objects.push(object)); this.commit((draft) => draft.objects.push(object));
this.selectedId = object.id; this.selectedId = object.id;
return structuredClone(object); return structuredClone(object);
@@ -81,7 +100,7 @@ export class MapEditSession {
copy.pose.position[1] + 0.2, copy.pose.position[1] + 0.2,
copy.pose.position[2], copy.pose.position[2],
]; ];
applyObjectPlacement(copy, this.current.objects); applyObjectPlacement(copy, this.current.objects, this.supportTop(copy));
this.commit((draft) => draft.objects.push(copy)); this.commit((draft) => draft.objects.push(copy));
this.selectedId = copy.id; this.selectedId = copy.id;
return structuredClone(copy); return structuredClone(copy);
@@ -93,7 +112,7 @@ export class MapEditSession {
const next = structuredClone(patch); const next = structuredClone(patch);
if (object.placementMode === 'locked' && next.placementMode === undefined) delete next.pose; if (object.placementMode === 'locked' && next.placementMode === undefined) delete next.pose;
Object.assign(object, next); Object.assign(object, next);
applyObjectPlacement(object, draft.objects); applyObjectPlacement(object, draft.objects, this.supportTop(object));
}); });
} }
remove(id: string): void { remove(id: string): void {
+45 -2
View File
@@ -1,13 +1,56 @@
import { import {
createEditableObject, createEditableObject,
editableObjectGroundHeight, editableObjectGroundHeight,
isMapObjectPlacementMode,
type EditableMapObject, type EditableMapObject,
type EditableMapObjectType, type EditableMapObjectType,
type MapObjectPlacementMode, type MapObjectPlacementMode,
} from './types'; } from './types';
import { isSystemTerrainPreset, type SystemTerrainPreset } from '../types';
export const MAP_ASSET_DRAG_MIME = 'application/x-mujoco-map-asset'; export const MAP_LIBRARY_DRAG_MIME = 'application/x-mujoco-map-library-item';
export const MAP_ASSET_PLACEMENT_MIME = 'application/x-mujoco-map-placement';
export type MapLibraryDragPayload =
| {
kind: 'certified';
type: EditableMapObjectType;
placementMode: MapObjectPlacementMode;
}
| { kind: 'terrain'; preset: SystemTerrainPreset }
| { kind: 'project'; descriptorPath: string };
export function encodeMapLibraryDragPayload(payload: MapLibraryDragPayload): string {
return JSON.stringify(payload);
}
export function decodeMapLibraryDragPayload(value: string): MapLibraryDragPayload | null {
try {
const payload = JSON.parse(value) as Record<string, unknown>;
if (
payload.kind === 'certified' &&
typeof payload.type === 'string' &&
isEditableMapObjectType(payload.type) &&
typeof payload.placementMode === 'string' &&
isMapObjectPlacementMode(payload.placementMode)
)
return { kind: 'certified', type: payload.type, placementMode: payload.placementMode };
if (
payload.kind === 'terrain' &&
typeof payload.preset === 'string' &&
isSystemTerrainPreset(payload.preset)
)
return { kind: 'terrain', preset: payload.preset };
if (
payload.kind === 'project' &&
typeof payload.descriptorPath === 'string' &&
payload.descriptorPath.trim()
)
return { kind: 'project', descriptorPath: payload.descriptorPath };
} catch {
// 非本平台的拖放数据交给文件导入分支处理。
}
return null;
}
export interface CertifiedMapAsset { export interface CertifiedMapAsset {
type: EditableMapObjectType; type: EditableMapObjectType;
@@ -0,0 +1,106 @@
import type { ProjectFile, ProjectManifest } from '../../project/types';
import { discoverMapEntries } from '../MapLoader';
import type { PlacedMapAsset } from '../types';
import { encodeEditableMapDocument } from './editorSchema';
import { compiledEditableMapBodyName, findCompiledEditableMapPick } from './compiledMapPick';
import { createEditableObject, type EditableMapDocument } from './types';
const encoder = new TextEncoder();
function file(path: string, data: Uint8Array | string): ProjectFile {
const bytes = typeof data === 'string' ? encoder.encode(data) : data;
return { path, data: bytes, size: bytes.byteLength, source: 'zip', mimeType: '' };
}
function fixture(): { manifest: ProjectManifest; assets: PlacedMapAsset[] } {
const document: EditableMapDocument = {
schemaVersion: 1,
mapId: 'scene',
revision: 1,
objects: [createEditableObject('box', 'box_1')],
spawnPoints: [],
};
const files = [
file(
'maps/scene/map.json',
JSON.stringify({
schemaVersion: 2,
id: 'scene',
name: '场景',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'physics/world.xml' },
authoring: { source: 'authoring/map.scene.json' },
spawnPoints: [],
}),
),
file('maps/scene/physics/world.xml', '<mujoco><worldbody/></mujoco>'),
file('maps/scene/authoring/map.scene.json', encodeEditableMapDocument(document)),
file('robot.xml', '<mujoco><worldbody/></mujoco>'),
];
return {
manifest: {
id: 'project',
name: 'project',
files,
entries: [{ path: 'robot.xml', format: 'mjcf', label: 'robot.xml' }],
maps: discoverMapEntries(files),
totalBytes: files.reduce((total, item) => total + item.size, 0),
},
assets: [
{
id: 'map_instance-1',
name: '场景',
selection: { kind: 'project', descriptorPath: 'maps/scene/map.json' },
},
],
};
}
describe('已编译认证资产拾取', () => {
it('按地图实例命名空间反查创作对象', () => {
const { manifest, assets } = fixture();
const bodyName = compiledEditableMapBodyName('scene', 'map_instance-1', 'box_1');
expect(bodyName).toBe('__platform_map_scene_map_instance-1_edit_body_box_1');
expect(findCompiledEditableMapPick(bodyName, manifest, assets)).toEqual({
mapAssetId: 'map_instance-1',
descriptorPath: 'maps/scene/map.json',
objectId: 'box_1',
});
});
it('不接管机器人 Body、其他实例或已禁用对象', () => {
const { manifest, assets } = fixture();
expect(findCompiledEditableMapPick('robot_base', manifest, assets)).toBeNull();
expect(
findCompiledEditableMapPick(
compiledEditableMapBodyName('scene', 'other_instance', 'box_1'),
manifest,
assets,
),
).toBeNull();
const authoring = manifest.files.find(
(item) => item.path === 'maps/scene/authoring/map.scene.json',
)!;
const disabled = fixture().manifest;
const disabledAuthoring = disabled.files.find((item) => item.path === authoring.path)!;
const document: EditableMapDocument = {
schemaVersion: 1,
mapId: 'scene',
revision: 2,
objects: [{ ...createEditableObject('box', 'box_1'), enabled: false }],
spawnPoints: [],
};
const data = encodeEditableMapDocument(document);
disabledAuthoring.data = data;
disabledAuthoring.size = data.byteLength;
expect(
findCompiledEditableMapPick(
compiledEditableMapBodyName('scene', 'map_instance-1', 'box_1'),
disabled,
assets,
),
).toBeNull();
});
});
@@ -0,0 +1,58 @@
import type { ProjectManifest } from '../../project/types';
import { resolveProjectMap } from '../MapLoader';
import type { PlacedMapAsset } from '../types';
import { decodeEditableMapDocument } from './editorSchema';
export interface CompiledEditableMapPick {
mapAssetId: string;
descriptorPath: string;
objectId: string;
}
function namespaceToken(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]/g, '_');
}
/** 与 MapComposer.namespaceMap 保持一致的已编译可编辑对象 Body 名。 */
export function compiledEditableMapBodyName(
mapId: string,
mapAssetId: string,
objectId: string,
): string {
const namespace = namespaceToken(`${mapId}_${mapAssetId}`);
return `__platform_map_${namespace}_edit_body_${objectId}`;
}
/**
* 把 MuJoCo 中命中的静态 Body 反查为认证资产对象。这样当前选中的是参数地形或其他
* 地图实例时,点击已编译对象仍能激活正确的创作地图并重新挂载操纵器。
*/
export function findCompiledEditableMapPick(
bodyName: string,
manifest: ProjectManifest,
assets: readonly PlacedMapAsset[],
): CompiledEditableMapPick | null {
for (const asset of assets) {
if (asset.selection.kind !== 'project') continue;
try {
const resolved = resolveProjectMap(manifest, asset.selection.descriptorPath);
if (!resolved.authoringPath) continue;
const prefix = compiledEditableMapBodyName(resolved.definition.id, asset.id, '');
if (!bodyName.startsWith(prefix)) continue;
const objectId = bodyName.slice(prefix.length);
if (!objectId) continue;
const authoring = manifest.files.find((file) => file.path === resolved.authoringPath);
if (!authoring) continue;
const document = decodeEditableMapDocument(authoring.data);
if (!document.objects.some((object) => object.enabled && object.id === objectId)) continue;
return {
mapAssetId: asset.id,
descriptorPath: asset.selection.descriptorPath,
objectId,
};
} catch {
// 单个损坏或只读地图不应阻断普通视口拾取。
}
}
return null;
}
@@ -73,7 +73,9 @@ describe('地图 V3 编辑核心', () => {
const session = new MapEditSession(document()); const session = new MapEditSession(document());
const object = session.add('box'); const object = session.add('box');
expect(session.dirty).toBe(true); expect(session.dirty).toBe(true);
expect(session.changeCount).toBe(1);
session.update(object.id, { name: '墙' }); session.update(object.id, { name: '墙' });
expect(session.changeCount).toBe(2);
expect(session.document.objects[0].name).toBe('墙'); expect(session.document.objects[0].name).toBe('墙');
const copy = session.duplicate(object.id); const copy = session.duplicate(object.id);
expect(copy).toMatchObject({ name: '墙 副本', pose: { position: [0.2, 0.2, 0.5] } }); expect(copy).toMatchObject({ name: '墙 副本', pose: { position: [0.2, 0.2, 0.5] } });
@@ -95,6 +97,20 @@ describe('地图 V3 编辑核心', () => {
expect(session.document.spawnPoints).toHaveLength(1); expect(session.document.spawnPoints).toHaveLength(1);
session.discard(); session.discard();
expect(session.dirty).toBe(false); expect(session.dirty).toBe(false);
expect(session.changeCount).toBe(0);
});
it('重新挂载时恢复未提交草稿并保持 dirty', () => {
const saved = document();
const draft = document();
draft.objects.push(createEditableObject('box', 'pending_box'));
const session = new MapEditSession(saved, draft);
expect(session.dirty).toBe(true);
expect(session.changeCount).toBe(1);
expect(session.document.objects[0].id).toBe('pending_box');
session.discard();
expect(session.dirty).toBe(false);
expect(session.document.objects).toHaveLength(0);
}); });
it('将认证资产按画布落点加入草稿并自动对齐地面', () => { it('将认证资产按画布落点加入草稿并自动对齐地面', () => {
@@ -107,6 +123,20 @@ describe('地图 V3 编辑核心', () => {
}); });
}); });
it('重力落位可使用程序地图提供的外部承载高度', () => {
const session = new MapEditSession(document());
const settled = session.addAsset('box', [0, 0, 0], 'gravity', -0.8);
expect(settled.pose.position[2]).toBeCloseTo(-0.3);
const terrainSession = new MapEditSession(document(), document(), ([x]) => (x > 1 ? 2 : 1));
const terrainObject = terrainSession.addAsset('box', [0, 0, 0], 'gravity');
expect(terrainObject.pose.position[2]).toBeCloseTo(1.5);
terrainSession.update(terrainObject.id, {
pose: { ...terrainObject.pose, position: [2, 0, terrainObject.pose.position[2]] },
});
expect(terrainSession.document.objects[0].pose.position[2]).toBeCloseTo(2.5);
});
it('支持重力落位到最高承载面并锁定位姿', () => { it('支持重力落位到最高承载面并锁定位姿', () => {
const session = new MapEditSession(document()); const session = new MapEditSession(document());
const support = session.addAsset('box', [0, 0, 0]); const support = session.addAsset('box', [0, 0, 0]);
+6 -3
View File
@@ -54,28 +54,31 @@ function objectTop(object: EditableMapObject): number {
export function gravitySettledPosition( export function gravitySettledPosition(
object: EditableMapObject, object: EditableMapObject,
objects: EditableMapObject[], objects: EditableMapObject[],
externalSupportTop = 0,
): [number, number, number] { ): [number, number, number] {
const target = footprint(object); const target = footprint(object);
let supportTop = 0; let supportTop = externalSupportTop;
for (const candidate of objects) { for (const candidate of objects) {
if (candidate.id === object.id || !candidate.enabled || !overlaps(target, footprint(candidate))) if (candidate.id === object.id || !candidate.enabled || !overlaps(target, footprint(candidate)))
continue; continue;
supportTop = Math.max(supportTop, objectTop(candidate)); supportTop = Math.max(supportTop, objectTop(candidate));
} }
const settledZ = supportTop + editableObjectGroundHeight(object);
return [ return [
object.pose.position[0], object.pose.position[0],
object.pose.position[1], object.pose.position[1],
supportTop + editableObjectGroundHeight(object), Math.round(settledZ * 1_000_000_000) / 1_000_000_000,
]; ];
} }
export function applyObjectPlacement( export function applyObjectPlacement(
object: EditableMapObject, object: EditableMapObject,
objects: EditableMapObject[], objects: EditableMapObject[],
externalSupportTop = 0,
): void { ): void {
if (object.placementMode === 'locked') return; if (object.placementMode === 'locked') return;
object.pose.position = object.pose.position =
object.placementMode === 'gravity' object.placementMode === 'gravity'
? gravitySettledPosition(object, objects) ? gravitySettledPosition(object, objects, externalSupportTop)
: [object.pose.position[0], object.pose.position[1], editableObjectGroundHeight(object)]; : [object.pose.position[0], object.pose.position[1], editableObjectGroundHeight(object)];
} }
+16 -1
View File
@@ -7,7 +7,7 @@ export type MapObjectPlacementMode = 'auto_ground' | 'gravity' | 'locked';
export const MAP_OBJECT_PLACEMENT_LABELS: Record<MapObjectPlacementMode, string> = { export const MAP_OBJECT_PLACEMENT_LABELS: Record<MapObjectPlacementMode, string> = {
auto_ground: '自动贴地', auto_ground: '自动贴地',
gravity: '自动落位(重力)', gravity: '自动落位(重力)',
locked: '锁定', locked: '锁定位姿',
}; };
export function isMapObjectPlacementMode(value: string): value is MapObjectPlacementMode { export function isMapObjectPlacementMode(value: string): value is MapObjectPlacementMode {
@@ -21,6 +21,16 @@ export interface MapEditorTransform {
scale: [number, number, number]; scale: [number, number, number];
} }
export interface MapEditorSessionState {
dirty: boolean;
changeCount: number;
canUndo: boolean;
canRedo: boolean;
}
/**
* 视口与 MapEditSession 之间的命令端口。视口层只发送意图,不直接修改地图文档。
*/
export interface MapEditorInteractionCallbacks { export interface MapEditorInteractionCallbacks {
onSelect(id: string | null): void; onSelect(id: string | null): void;
onTransform(transform: MapEditorTransform): void; onTransform(transform: MapEditorTransform): void;
@@ -28,7 +38,12 @@ export interface MapEditorInteractionCallbacks {
type: EditableMapObjectType, type: EditableMapObjectType,
position?: [number, number, number], position?: [number, number, number],
placementMode?: MapObjectPlacementMode, placementMode?: MapObjectPlacementMode,
externalSupportTop?: number,
): void; ): void;
onSetPlacementMode(id: string, mode: MapObjectPlacementMode): void;
onAlignToSurface(id: string): void;
onDelete(id: string): void;
onDiscard(): void;
} }
export interface EditableMapObject { export interface EditableMapObject {
+124
View File
@@ -0,0 +1,124 @@
import { createEditableObject, type EditableMapDocument } from './editor/types';
import {
mapEditorDraftPreviewInstances,
resolveMapSceneLoadAssets,
restoreAppliedMapScene,
summarizeMapSceneDraft,
transformParametricMapAsset,
} from './mapSceneDraft';
import { DEFAULT_PHYSICAL_MAP_CONFIG, type PlacedMapAsset } from './types';
function terrain(id: string, x = 0): PlacedMapAsset {
return {
id,
name: `地形 ${id}`,
selection: {
kind: 'builtin',
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough', positionX: x },
},
};
}
const project = (id: string): PlacedMapAsset => ({
id,
name: `工程 ${id}`,
selection: { kind: 'project', descriptorPath: `maps/${id}/map.json` },
});
describe('参数地图批量草稿', () => {
it('只统计新增、修改和删除的参数地图,并为新增/修改项生成预览', () => {
const applied = [terrain('same'), terrain('changed'), terrain('removed'), project('room')];
const current = [terrain('same'), terrain('changed', 2), terrain('added', -3), project('room')];
const summary = summarizeMapSceneDraft(current, applied);
expect(summary).toMatchObject({
dirty: true,
changeCount: 3,
changedIds: ['changed', 'added'],
});
expect(summary.previewAssets.map((asset) => asset.id)).toEqual(['changed', 'added']);
});
it('把工程地图增删也纳入同一个场景草稿', () => {
const applied = [project('removed')];
const summary = summarizeMapSceneDraft([project('new')], applied);
expect(summary).toEqual({
dirty: true,
changeCount: 2,
changedIds: ['new'],
previewAssets: [],
});
});
it('放弃草稿时严格恢复完整应用基线', () => {
const applied = [terrain('terrain', 1), project('room')];
const current = [terrain('terrain', 8), project('room'), terrain('new'), project('overlay')];
expect(summarizeMapSceneDraft(current, applied).changeCount).toBe(3);
const restored = restoreAppliedMapScene(applied);
expect(restored.map((asset) => asset.id)).toEqual(['terrain', 'room']);
expect(restored[0].selection).toEqual(terrain('terrain', 1).selection);
expect(restored[0]).not.toBe(applied[0]);
});
it('普通重载保持应用基线,显式提交才使用候选场景', () => {
const applied = [terrain('applied', 1)];
const draft = [terrain('draft', 9)];
expect(resolveMapSceneLoadAssets(applied).map((asset) => asset.id)).toEqual(['applied']);
expect(resolveMapSceneLoadAssets(applied, draft).map((asset) => asset.id)).toEqual(['draft']);
expect(resolveMapSceneLoadAssets(applied)[0]).not.toBe(applied[0]);
});
it('切换到参数地形后仍保留待提交认证资产的只读预览', () => {
const certified: PlacedMapAsset = {
id: 'certified',
name: '工程 certified',
selection: {
kind: 'project',
descriptorPath: 'maps/certified/map.json',
positionX: 2,
positionY: -1,
yawDeg: 45,
},
};
const activeTerrain = terrain('terrain');
const document: EditableMapDocument = {
schemaVersion: 1,
mapId: 'certified',
revision: 1,
objects: [createEditableObject('box', 'box_1')],
spawnPoints: [],
};
const documents = new Map([['maps/certified/map.json', document]]);
expect(
mapEditorDraftPreviewInstances(
[certified, activeTerrain],
['certified', 'terrain'],
documents,
'terrain',
),
).toEqual([
{
id: 'certified',
document,
transform: { positionX: 2, positionY: -1, yawDeg: 45 },
},
]);
expect(
mapEditorDraftPreviewInstances([certified], ['certified'], documents, 'certified'),
).toEqual([]);
});
it('视口移动只更新目标参数地形并保留操纵器结果', () => {
const terrainAsset = terrain('terrain');
if (terrainAsset.selection.kind === 'builtin') terrainAsset.selection.config.size = 9;
const assets = [terrainAsset, project('room')];
const transformed = transformParametricMapAsset(assets, 'terrain', [1.26, -2.34], 35);
expect(transformed).not.toBe(assets);
expect(transformed[0].selection).toMatchObject({
kind: 'builtin',
config: { size: 9, positionX: 1.26, positionY: -2.34, yawDeg: 35 },
});
expect(transformed[1]).toBe(assets[1]);
expect(transformParametricMapAsset(assets, 'room', [5, 5], 0)).toBe(assets);
});
});
+121
View File
@@ -0,0 +1,121 @@
import type { EditableMapDocument } from './editor/types';
import { normalizePhysicalMapConfig } from './physicalMap';
import {
mapSelectionTransform,
updatePlacedMapAsset,
type MapInstanceTransform,
type PlacedMapAsset,
} from './types';
function sameAsset(left: PlacedMapAsset | undefined, right: PlacedMapAsset | undefined): boolean {
return Boolean(
left &&
right &&
left.id === right.id &&
left.name === right.name &&
JSON.stringify(left.selection) === JSON.stringify(right.selection),
);
}
function cloneAsset(asset: PlacedMapAsset): PlacedMapAsset {
return { ...asset, selection: structuredClone(asset.selection) };
}
export interface MapSceneDraftSummary {
dirty: boolean;
changeCount: number;
/** 当前仍存在且相对应用基线新增或发生修改的实例 ID。 */
changedIds: string[];
/** 可直接在 Three.js 中预览的新增或已修改程序地图。 */
previewAssets: PlacedMapAsset[];
}
export interface MapEditorDraftPreviewInstance {
id: string;
document: EditableMapDocument;
transform: MapInstanceTransform;
}
/**
* 当前激活的认证场景由交互编辑层绘制;其余待提交工程地图必须保留只读预览,
* 否则切换到参数地形时会因为清空交互文档而从画布消失。
*/
export function mapEditorDraftPreviewInstances(
assets: readonly PlacedMapAsset[],
pendingIds: readonly string[],
documents: ReadonlyMap<string, EditableMapDocument>,
activeId?: string,
): MapEditorDraftPreviewInstance[] {
const pending = new Set(pendingIds);
return assets.flatMap((asset) => {
if (asset.id === activeId || !pending.has(asset.id) || asset.selection.kind !== 'project')
return [];
const document = documents.get(asset.selection.descriptorPath);
return document
? [{ id: asset.id, document, transform: mapSelectionTransform(asset.selection) }]
: [];
});
}
/**
* 所有地图实例共享同一个场景草稿边界。新增、删除、替换和实例参数修改只更新
* `current`,显式应用成功后才推进 `applied` 基线。
*/
export function summarizeMapSceneDraft(
current: readonly PlacedMapAsset[],
applied: readonly PlacedMapAsset[],
): MapSceneDraftSummary {
const currentById = new Map(current.map((asset) => [asset.id, asset]));
const appliedById = new Map(applied.map((asset) => [asset.id, asset]));
const changed = current.filter((asset) => !sameAsset(asset, appliedById.get(asset.id)));
const removed = applied.filter((asset) => !currentById.has(asset.id));
return {
dirty: changed.length + removed.length > 0,
changeCount: changed.length + removed.length,
changedIds: changed.map((asset) => asset.id),
previewAssets: changed.filter((asset) => asset.selection.kind === 'builtin').map(cloneAsset),
};
}
/** 放弃完整场景草稿,严格恢复上一次成功编译的实例顺序和状态。 */
export function restoreAppliedMapScene(applied: readonly PlacedMapAsset[]): PlacedMapAsset[] {
return applied.map(cloneAsset);
}
export function clonePlacedMapAssets(assets: readonly PlacedMapAsset[]): PlacedMapAsset[] {
return assets.map(cloneAsset);
}
/** 普通模型重载使用应用基线;只有显式场景提交才传入候选实例。 */
export function resolveMapSceneLoadAssets(
applied: readonly PlacedMapAsset[],
requested?: readonly PlacedMapAsset[],
): PlacedMapAsset[] {
return clonePlacedMapAssets(requested ?? applied);
}
/** 视口操纵器只更新参数地形草稿,不触发 MuJoCo 编译。 */
export function transformParametricMapAsset(
assets: readonly PlacedMapAsset[],
id: string,
position: readonly [number, number],
yawDeg: number,
): PlacedMapAsset[] | readonly PlacedMapAsset[] {
const target = assets.find((asset) => asset.id === id);
if (!target || target.selection.kind !== 'builtin' || target.selection.config.preset === 'none')
return assets;
const clean = (value: number) => Math.round(value * 1_000_000) / 1_000_000;
const config = normalizePhysicalMapConfig({
...target.selection.config,
positionX: clean(position[0]),
positionY: clean(position[1]),
yawDeg: clean(yawDeg),
});
if (
config.positionX === target.selection.config.positionX &&
config.positionY === target.selection.config.positionY &&
config.yawDeg === target.selection.config.yawDeg
)
return assets;
return updatePlacedMapAsset(assets, id, { kind: 'builtin', config });
}
+30
View File
@@ -76,6 +76,36 @@ describe('composePhysicalMap', () => {
} }
}); });
it('程序地形在实例叠加模式移除所有无限平面,避免填平坑洞和负高度', () => {
const source = encoder.encode(
'<mujoco><worldbody><geom name="__platform_ground__" type="plane"/><geom name="external_map_floor" type="plane"/></worldbody></mujoco>',
);
const document = documentOf(
composePhysicalMap(source, config({ preset: 'rough', size: 6 }), {
instanceId: 'terrain-a',
preserveExistingGround: true,
}).data,
);
expect(document.querySelector('[name="__platform_ground__"]')).toBeNull();
expect(document.querySelector('[name="external_map_floor"]')).toBeNull();
expect(document.querySelector('[name="__platform_map_terrain-a__root__"]')).not.toBeNull();
});
it('平地实例在已有地面上仍生成可移动的有限地块', () => {
const source = encoder.encode(
'<mujoco><worldbody><geom name="floor" type="plane"/></worldbody></mujoco>',
);
const document = documentOf(
composePhysicalMap(source, config({ preset: 'flat', positionX: 3 }), {
instanceId: 'flat-a',
preserveExistingGround: true,
}).data,
);
const root = document.querySelector('[name="__platform_map_flat-a__root__"]');
expect(root?.getAttribute('pos')).toBe('3 0 0');
expect(root?.querySelector('[name="__platform_map_flat-a__ground__"]')).not.toBeNull();
});
it('系统粗糙地形生成内联高度场并替换原平面', () => { it('系统粗糙地形生成内联高度场并替换原平面', () => {
const source = encoder.encode( const source = encoder.encode(
'<mujoco><worldbody><geom name="__platform_ground__" type="plane"/></worldbody></mujoco>', '<mujoco><worldbody><geom name="__platform_ground__" type="plane"/></worldbody></mujoco>',
+139 -67
View File
@@ -17,6 +17,18 @@ export interface PhysicalMapComposition {
summary?: string; summary?: string;
} }
export interface PhysicalMapCompositionOptions {
/** 场景实例命名空间;省略时保留旧的单地图命名。 */
instanceId?: string;
/** 叠加模式保留机器人或其他地图已经提供的地面。 */
preserveExistingGround?: boolean;
}
function instancePrefix(instanceId?: string): string {
if (!instanceId) return MAP_NAME_PREFIX;
return `${MAP_NAME_PREFIX}${instanceId.replace(/[^a-zA-Z0-9_-]/g, '_')}__`;
}
function clamp(value: number, minimum: number, maximum: number, fallback: number): number { function clamp(value: number, minimum: number, maximum: number, fallback: number): number {
return Number.isFinite(value) ? Math.min(maximum, Math.max(minimum, value)) : fallback; return Number.isFinite(value) ? Math.min(maximum, Math.max(minimum, value)) : fallback;
} }
@@ -68,13 +80,21 @@ function isGroundPlane(element: Element): boolean {
return Math.abs(position[2] ?? 0) < 1e-6; return Math.abs(position[2] ?? 0) < 1e-6;
} }
function hasSupportingGround(worldbody: Element): boolean {
return Array.from(worldbody.querySelectorAll('geom')).some((geom) => {
if (isGroundPlane(geom) || geom.getAttribute('type') === 'hfield') return true;
return /(?:^|[_-])(?:floor|ground)(?:__|[_-]|$)/i.test(geom.getAttribute('name') ?? '');
});
}
function setCommonGeomAttributes( function setCommonGeomAttributes(
geom: Element, geom: Element,
name: string, name: string,
config: PhysicalMapConfig, config: PhysicalMapConfig,
rgba: string, rgba: string,
prefix = MAP_NAME_PREFIX,
): void { ): void {
geom.setAttribute('name', `${MAP_NAME_PREFIX}${name}__`); geom.setAttribute('name', `${prefix}${name}__`);
geom.setAttribute('friction', `${config.friction} 0.005 0.0001`); geom.setAttribute('friction', `${config.friction} 0.005 0.0001`);
geom.setAttribute('group', '2'); geom.setAttribute('group', '2');
geom.setAttribute('rgba', rgba); geom.setAttribute('rgba', rgba);
@@ -90,9 +110,10 @@ function addBox(
halfSize: [number, number, number], halfSize: [number, number, number],
rgba: string, rgba: string,
quaternion?: [number, number, number, number], quaternion?: [number, number, number, number],
prefix = MAP_NAME_PREFIX,
): void { ): void {
const geom = document.createElement('geom'); const geom = document.createElement('geom');
setCommonGeomAttributes(geom, name, config, rgba); setCommonGeomAttributes(geom, name, config, rgba, prefix);
geom.setAttribute('type', 'box'); geom.setAttribute('type', 'box');
geom.setAttribute('pos', position.join(' ')); geom.setAttribute('pos', position.join(' '));
geom.setAttribute('size', halfSize.join(' ')); geom.setAttribute('size', halfSize.join(' '));
@@ -105,6 +126,7 @@ function addHeightfield(
worldbody: Element, worldbody: Element,
config: PhysicalMapConfig, config: PhysicalMapConfig,
heightfield: GeneratedTerrainHeightfield, heightfield: GeneratedTerrainHeightfield,
prefix = MAP_NAME_PREFIX,
): void { ): void {
let asset = document.querySelector('mujoco > asset'); let asset = document.querySelector('mujoco > asset');
if (!asset) { if (!asset) {
@@ -116,7 +138,7 @@ function addHeightfield(
const minimum = Math.min(...heightfield.heights), const minimum = Math.min(...heightfield.heights),
maximum = Math.max(...heightfield.heights), maximum = Math.max(...heightfield.heights),
range = Math.max(maximum - minimum, 1e-6), range = Math.max(maximum - minimum, 1e-6),
name = `${MAP_NAME_PREFIX}${heightfield.name.replace(/[^a-zA-Z0-9_-]/g, '_')}__`; name = `${prefix}${heightfield.name.replace(/[^a-zA-Z0-9_-]/g, '_')}__`;
const source = document.createElement('hfield'); const source = document.createElement('hfield');
source.setAttribute('name', name); source.setAttribute('name', name);
source.setAttribute('nrow', String(heightfield.rowSegments + 1)); source.setAttribute('nrow', String(heightfield.rowSegments + 1));
@@ -129,17 +151,22 @@ function addHeightfield(
asset.append(source); asset.append(source);
const geom = document.createElement('geom'); const geom = document.createElement('geom');
setCommonGeomAttributes(geom, heightfield.name, config, '0.32 0.42 0.28 1'); setCommonGeomAttributes(geom, heightfield.name, config, '0.32 0.42 0.28 1', prefix);
geom.setAttribute('type', 'hfield'); geom.setAttribute('type', 'hfield');
geom.setAttribute('hfield', name); geom.setAttribute('hfield', name);
geom.setAttribute('pos', `0 0 ${minimum}`); geom.setAttribute('pos', `0 0 ${minimum}`);
worldbody.append(geom); worldbody.append(geom);
} }
function createMapRoot(document: Document, worldbody: Element, config: PhysicalMapConfig): Element { function createMapRoot(
document: Document,
worldbody: Element,
config: PhysicalMapConfig,
prefix = MAP_NAME_PREFIX,
): Element {
const root = document.createElement('body'); const root = document.createElement('body');
const halfYaw = (config.yawDeg * Math.PI) / 360; const halfYaw = (config.yawDeg * Math.PI) / 360;
root.setAttribute('name', `${MAP_NAME_PREFIX}root__`); root.setAttribute('name', `${prefix}root__`);
root.setAttribute('pos', `${config.positionX} ${config.positionY} 0`); root.setAttribute('pos', `${config.positionX} ${config.positionY} 0`);
root.setAttribute('quat', `${Math.cos(halfYaw)} 0 0 ${Math.sin(halfYaw)}`); root.setAttribute('quat', `${Math.cos(halfYaw)} 0 0 ${Math.sin(halfYaw)}`);
worldbody.append(root); worldbody.append(root);
@@ -155,6 +182,83 @@ function seededRandom(seed: number): () => number {
}; };
} }
export interface GeneratedPhysicalMapBox {
name: string;
position: [number, number, number];
/** 完整边长,与 Three.js BoxGeometry 约定一致。 */
size: [number, number, number];
rgba: string;
quaternion?: [number, number, number, number];
}
/** 单一确定性几何源同时供 MuJoCo 合成和 Three.js 草稿预览使用。 */
export function generateBuiltinMapBoxes(
requestedConfig: PhysicalMapConfig,
): GeneratedPhysicalMapBox[] {
const config = normalizePhysicalMapConfig(requestedConfig);
if (config.preset === 'none' || isSystemTerrainPreset(config.preset)) return [];
if (config.preset === 'flat')
return [
{
name: 'ground',
position: [0, 0, -0.05],
size: [config.size * 2, config.size * 2, 0.1],
rgba: '0.18 0.24 0.2 1',
},
];
if (config.preset === 'slope') {
const angleRadians = (config.slopeAngle * Math.PI) / 180;
const rampLength = Math.min(config.size * 0.42, 6);
const thickness = 0.12;
const startX = 1.25;
return [
{
name: 'slope',
position: [
startX + (rampLength * Math.cos(angleRadians)) / 2,
0,
thickness / 2 + (rampLength * Math.sin(angleRadians)) / 2,
],
size: [rampLength, Math.min(config.size * 0.44, 4), thickness],
rgba: '0.3 0.42 0.58 1',
quaternion: [Math.cos(angleRadians / 2), 0, -Math.sin(angleRadians / 2), 0],
},
];
}
if (config.preset === 'stairs') {
const run = Math.min(0.42, Math.max(0.25, config.size / (config.stairCount * 3)));
const rise = Math.min(0.2, run * 0.5);
const width = Math.min(config.size * 0.22, 2);
return Array.from({ length: config.stairCount }, (_, offset) => {
const index = offset + 1;
const height = index * rise;
return {
name: `stair_${index}`,
position: [1 + (index - 0.5) * run, 0, height / 2],
size: [run, width * 2, height],
rgba: index % 2 ? '0.42 0.45 0.5 1' : '0.35 0.38 0.44 1',
};
});
}
const random = seededRandom(config.seed);
const radius = Math.max(2.2, config.size * 0.38);
return Array.from({ length: config.obstacleCount }, (_, index) => {
const angle = (index / config.obstacleCount) * Math.PI * 2 + (random() - 0.5) * 0.4;
const distance = 1.8 + random() * Math.max(0.4, radius - 1.8);
const width = 0.18 + random() * 0.42;
const depth = 0.18 + random() * 0.42;
const height = 0.25 + random() * 0.9;
const yaw = random() * Math.PI;
return {
name: `obstacle_${index + 1}`,
position: [Math.cos(angle) * distance, Math.sin(angle) * distance, height / 2],
size: [width * 2, depth * 2, height],
rgba: '0.52 0.33 0.2 1',
quaternion: [Math.cos(yaw / 2), 0, 0, Math.sin(yaw / 2)],
};
});
}
function ensureGround(document: Document, worldbody: Element, config: PhysicalMapConfig): number { function ensureGround(document: Document, worldbody: Element, config: PhysicalMapConfig): number {
const existing = Array.from(worldbody.children).find(isGroundPlane); const existing = Array.from(worldbody.children).find(isGroundPlane);
if (existing) { if (existing) {
@@ -177,6 +281,7 @@ function ensureGround(document: Document, worldbody: Element, config: PhysicalMa
export function composePhysicalMap( export function composePhysicalMap(
source: Uint8Array, source: Uint8Array,
requestedConfig: PhysicalMapConfig, requestedConfig: PhysicalMapConfig,
options: PhysicalMapCompositionOptions = {},
): PhysicalMapComposition { ): PhysicalMapComposition {
const config = normalizePhysicalMapConfig(requestedConfig); const config = normalizePhysicalMapConfig(requestedConfig);
if (config.preset === 'none') return { data: source, config, geomCount: 0 }; if (config.preset === 'none') return { data: source, config, geomCount: 0 };
@@ -185,16 +290,20 @@ export function composePhysicalMap(
if (document.querySelector('parsererror')) throw new Error('地图合成失败:MJCF XML 无法解析'); if (document.querySelector('parsererror')) throw new Error('地图合成失败:MJCF XML 无法解析');
const worldbody = document.querySelector('mujoco > worldbody'); const worldbody = document.querySelector('mujoco > worldbody');
if (!worldbody) throw new Error('地图合成失败:MJCF 缺少 worldbody'); if (!worldbody) throw new Error('地图合成失败:MJCF 缺少 worldbody');
const prefix = instancePrefix(options.instanceId);
for (const generated of Array.from(document.querySelectorAll(`[name^="${MAP_NAME_PREFIX}"]`))) for (const generated of Array.from(document.querySelectorAll(`[name^="${prefix}"]`)))
generated.remove(); generated.remove();
for (const emptyAsset of Array.from(document.querySelectorAll('mujoco > asset:empty'))) for (const emptyAsset of Array.from(document.querySelectorAll('mujoco > asset:empty')))
emptyAsset.remove(); emptyAsset.remove();
let geomCount: number; let geomCount = 0;
if (isSystemTerrainPreset(config.preset)) { if (isSystemTerrainPreset(config.preset)) {
for (const child of Array.from(worldbody.children)) if (isGroundPlane(child)) child.remove(); // 无限平面会把深坑、沟壑及高度场的负高度全部填平。程序地形是明确的地面
const mapRoot = createMapRoot(document, worldbody, config); // 提供者,因此不论平面来自机器人还是先前工程地图,都必须统一移除。
for (const geom of Array.from(worldbody.querySelectorAll('geom')))
if (isGroundPlane(geom)) geom.remove();
const mapRoot = createMapRoot(document, worldbody, config, prefix);
const terrain = generateSystemTerrain(config.preset, config); const terrain = generateSystemTerrain(config.preset, config);
const colors: Record<string, string> = { const colors: Record<string, string> = {
obstacle: '0.48 0.34 0.2 1', obstacle: '0.48 0.34 0.2 1',
@@ -215,75 +324,38 @@ export function composePhysicalMap(
item.position, item.position,
[item.size[0] / 2, item.size[1] / 2, item.size[2] / 2], [item.size[0] / 2, item.size[1] / 2, item.size[2] / 2],
colors[role], colors[role],
undefined,
prefix,
); );
} }
if (terrain.heightfield) addHeightfield(document, mapRoot, config, terrain.heightfield); if (terrain.heightfield) addHeightfield(document, mapRoot, config, terrain.heightfield, prefix);
geomCount = terrain.boxes.length + (terrain.heightfield ? 1 : 0); geomCount = terrain.boxes.length + (terrain.heightfield ? 1 : 0);
} else {
let boxes = generateBuiltinMapBoxes(config);
if (options.preserveExistingGround) {
// 场景实例中的平地必须是可移动的有限地块;其他旧版预设则复用已有承载面。
if (config.preset !== 'flat' && !hasSupportingGround(worldbody))
boxes = generateBuiltinMapBoxes({ ...config, preset: 'flat' }).concat(boxes);
} else { } else {
geomCount = ensureGround(document, worldbody, config); geomCount = ensureGround(document, worldbody, config);
boxes = boxes.filter((item) => item.name !== 'ground');
} }
if (config.preset === 'slope') { if (boxes.length) {
const mapRoot = createMapRoot(document, worldbody, config); const mapRoot = createMapRoot(document, worldbody, config, prefix);
const angleRadians = (config.slopeAngle * Math.PI) / 180; for (const item of boxes)
const rampLength = Math.min(config.size * 0.42, 6);
const thickness = 0.12;
const startX = 1.25;
addBox( addBox(
document, document,
mapRoot, mapRoot,
config, config,
'slope', item.name,
[ item.position,
startX + (rampLength * Math.cos(angleRadians)) / 2, [item.size[0] / 2, item.size[1] / 2, item.size[2] / 2],
0, item.rgba,
thickness / 2 + (rampLength * Math.sin(angleRadians)) / 2, item.quaternion,
], prefix,
[rampLength / 2, Math.min(config.size * 0.22, 2), thickness / 2],
'0.3 0.42 0.58 1',
[Math.cos(angleRadians / 2), 0, -Math.sin(angleRadians / 2), 0],
);
geomCount += 1;
} else if (config.preset === 'stairs') {
const mapRoot = createMapRoot(document, worldbody, config);
const run = Math.min(0.42, Math.max(0.25, config.size / (config.stairCount * 3)));
const rise = Math.min(0.2, run * 0.5);
const width = Math.min(config.size * 0.22, 2);
for (let index = 1; index <= config.stairCount; index += 1) {
const height = index * rise;
addBox(
document,
mapRoot,
config,
`stair_${index}`,
[1 + (index - 0.5) * run, 0, height / 2],
[run / 2, width, height / 2],
index % 2 ? '0.42 0.45 0.5 1' : '0.35 0.38 0.44 1',
); );
geomCount += boxes.length;
} }
geomCount += config.stairCount;
} else if (config.preset === 'obstacles') {
const mapRoot = createMapRoot(document, worldbody, config);
const random = seededRandom(config.seed);
const radius = Math.max(2.2, config.size * 0.38);
for (let index = 0; index < config.obstacleCount; index += 1) {
const angle = (index / config.obstacleCount) * Math.PI * 2 + (random() - 0.5) * 0.4;
const distance = 1.8 + random() * Math.max(0.4, radius - 1.8);
const width = 0.18 + random() * 0.42;
const depth = 0.18 + random() * 0.42;
const height = 0.25 + random() * 0.9;
const yaw = random() * Math.PI;
addBox(
document,
mapRoot,
config,
`obstacle_${index + 1}`,
[Math.cos(angle) * distance, Math.sin(angle) * distance, height / 2],
[width, depth, height / 2],
'0.52 0.33 0.2 1',
[Math.cos(yaw / 2), 0, 0, Math.sin(yaw / 2)],
);
}
geomCount += config.obstacleCount;
} }
return { return {
+56
View File
@@ -0,0 +1,56 @@
import { generateSystemTerrain } from './terrainGenerator';
import { mapSceneSurfaceHeightAt } from './sceneSurface';
import { DEFAULT_PHYSICAL_MAP_CONFIG, type PhysicalMapPreset, type PlacedMapAsset } from './types';
function terrain(
id: string,
preset: PhysicalMapPreset,
overrides: Partial<typeof DEFAULT_PHYSICAL_MAP_CONFIG> = {},
): PlacedMapAsset {
return {
id,
name: id,
selection: {
kind: 'builtin',
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset, ...overrides },
},
};
}
describe('统一地图表面查询', () => {
it('应用实例平移和旋转后查询有限平地', () => {
const asset = terrain('flat', 'flat', { size: 4, positionX: 3, positionY: -2, yawDeg: 90 });
expect(mapSceneSurfaceHeightAt([asset], 3, -2)).toBeCloseTo(0);
expect(mapSceneSurfaceHeightAt([asset], 20, 20)).toBeNull();
});
it('深坑中心返回负高度而不是被默认地面填平', () => {
const asset = terrain('pit', 'pit', { size: 6, terrainDifficulty: 1 });
expect(mapSceneSurfaceHeightAt([asset], 0, 0)).toBeLessThan(-0.5);
expect(mapSceneSurfaceHeightAt([asset], 2.5, 0)).toBeCloseTo(0);
});
it('高度场使用与生成器一致的采样高度', () => {
const config = {
...DEFAULT_PHYSICAL_MAP_CONFIG,
preset: 'wave' as const,
size: 4,
seed: 7,
terrainHorizontalScale: 0.5,
};
const generated = generateSystemTerrain('wave', config).heightfield!;
const center =
generated.heights[
(generated.rowSegments / 2) * (generated.columnSegments + 1) + generated.columnSegments / 2
];
expect(mapSceneSurfaceHeightAt([terrain('wave', 'wave', config)], 0, 0)).toBeCloseTo(center);
});
it('重叠地图选择最高承载面并支持倾斜坡道 OBB', () => {
const flat = terrain('flat', 'flat', { size: 5 });
const slope = terrain('slope', 'slope', { size: 5, slopeAngle: 20 });
const height = mapSceneSurfaceHeightAt([flat, slope], 2.2, 0);
expect(height).not.toBeNull();
expect(height!).toBeGreaterThan(0.1);
});
});
+133
View File
@@ -0,0 +1,133 @@
import {
generateBuiltinMapBoxes,
normalizePhysicalMapConfig,
type GeneratedPhysicalMapBox,
} from './physicalMap';
import { generateSystemTerrain, type GeneratedTerrainBox } from './terrainGenerator';
import { isSystemTerrainPreset, mapSelectionTransform, type PlacedMapAsset } from './types';
type SurfaceBox = GeneratedTerrainBox | GeneratedPhysicalMapBox;
function mapLocalPoint(asset: PlacedMapAsset, worldX: number, worldY: number): [number, number] {
const transform = mapSelectionTransform(asset.selection);
const yaw = (-transform.yawDeg * Math.PI) / 180;
const cosine = Math.cos(yaw);
const sine = Math.sin(yaw);
const dx = worldX - transform.positionX;
const dy = worldY - transform.positionY;
return [cosine * dx - sine * dy, sine * dx + cosine * dy];
}
function rotateByInverseQuaternion(
vector: readonly [number, number, number],
quaternion: readonly [number, number, number, number],
): [number, number, number] {
const [w, x, y, z] = quaternion;
const length = Math.hypot(w, x, y, z) || 1;
const qw = w / length;
const qx = -x / length;
const qy = -y / length;
const qz = -z / length;
const [vx, vy, vz] = vector;
const ix = qw * vx + qy * vz - qz * vy;
const iy = qw * vy + qz * vx - qx * vz;
const iz = qw * vz + qx * vy - qy * vx;
const iw = -qx * vx - qy * vy - qz * vz;
return [
ix * qw + iw * -qx + iy * -qz - iz * -qy,
iy * qw + iw * -qy + iz * -qx - ix * -qz,
iz * qw + iw * -qz + ix * -qy - iy * -qx,
];
}
/** 从世界 +Z 沿 -Z 与一个地图局部 OBB 求最高交点。 */
function boxSurfaceHeight(box: SurfaceBox, x: number, y: number): number | null {
const originZ = 10_000;
const quaternion =
'quaternion' in box && box.quaternion ? box.quaternion : ([1, 0, 0, 0] as const);
const origin = rotateByInverseQuaternion(
[x - box.position[0], y - box.position[1], originZ - box.position[2]],
quaternion,
);
const direction = rotateByInverseQuaternion([0, 0, -1], quaternion);
const half = [box.size[0] / 2, box.size[1] / 2, box.size[2] / 2];
let near = Number.NEGATIVE_INFINITY;
let far = Number.POSITIVE_INFINITY;
for (let axis = 0; axis < 3; axis += 1) {
if (Math.abs(direction[axis]) < 1e-10) {
if (origin[axis] < -half[axis] || origin[axis] > half[axis]) return null;
continue;
}
let first = (-half[axis] - origin[axis]) / direction[axis];
let second = (half[axis] - origin[axis]) / direction[axis];
if (first > second) [first, second] = [second, first];
near = Math.max(near, first);
far = Math.min(far, second);
if (near > far) return null;
}
const distance = near >= 0 ? near : far;
return distance >= 0 && Number.isFinite(distance)
? Math.round((originZ - distance) * 1_000_000_000) / 1_000_000_000
: null;
}
function heightfieldSurfaceHeight(
x: number,
y: number,
width: number,
length: number,
rowSegments: number,
columnSegments: number,
heights: readonly number[],
): number | null {
const column = ((x + width / 2) / width) * columnSegments;
const row = ((y + length / 2) / length) * rowSegments;
if (column < 0 || column > columnSegments || row < 0 || row > rowSegments) return null;
const c0 = Math.min(columnSegments, Math.floor(column));
const c1 = Math.min(columnSegments, c0 + 1);
const r0 = Math.min(rowSegments, Math.floor(row));
const r1 = Math.min(rowSegments, r0 + 1);
const tx = column - c0;
const ty = row - r0;
const at = (r: number, c: number) => heights[r * (columnSegments + 1) + c] ?? 0;
const lower = at(r0, c0) * (1 - tx) + at(r0, c1) * tx;
const upper = at(r1, c0) * (1 - tx) + at(r1, c1) * tx;
return lower * (1 - ty) + upper * ty;
}
/**
* 查询当前程序地图草稿在世界 XY 下的最高静态表面。工程地图任意 MJCF 不在浏览器
* 侧猜测;它们仍可通过可编辑原语文档参与 MapEditSession 的局部重力落位。
*/
export function mapSceneSurfaceHeightAt(
assets: readonly PlacedMapAsset[],
worldX: number,
worldY: number,
): number | null {
let highest: number | null = null;
for (const asset of assets) {
if (asset.selection.kind !== 'builtin' || asset.selection.config.preset === 'none') continue;
const config = normalizePhysicalMapConfig(asset.selection.config);
const [x, y] = mapLocalPoint(asset, worldX, worldY);
const generated = isSystemTerrainPreset(config.preset)
? generateSystemTerrain(config.preset, config)
: { boxes: generateBuiltinMapBoxes(config) };
for (const box of generated.boxes) {
const height = boxSurfaceHeight(box, x, y);
if (height !== null && (highest === null || height > highest)) highest = height;
}
if (generated.heightfield) {
const height = heightfieldSurfaceHeight(
x,
y,
generated.heightfield.width,
generated.heightfield.length,
generated.heightfield.rowSegments,
generated.heightfield.columnSegments,
generated.heightfield.heights,
);
if (height !== null && (highest === null || height > highest)) highest = height;
}
}
return highest;
}
+78
View File
@@ -0,0 +1,78 @@
import {
DEFAULT_PHYSICAL_MAP_CONFIG,
createPlacedMapAsset,
mapLocalPointToWorld,
mapSelectionTransform,
mapWorldPointToLocal,
updatePlacedMapAsset,
} from './types';
describe('地图场景实例', () => {
it('实例 id 独立于源资产并且只更新目标实例参数', () => {
const first = createPlacedMapAsset(
{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'stairs' } },
'楼梯',
'map-a',
);
const second = createPlacedMapAsset(
{ kind: 'project', descriptorPath: 'maps/warehouse/map.json' },
'仓库',
'map-b',
);
const next = updatePlacedMapAsset(
[first, second],
first.id,
{
kind: 'builtin',
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'stairs', stairCount: 12 },
},
'楼梯',
);
expect(next[0].selection).toMatchObject({ kind: 'builtin', config: { stairCount: 12 } });
expect(next[1]).toBe(second);
expect(next[1].selection).toEqual({
kind: 'project',
descriptorPath: 'maps/warehouse/map.json',
});
});
it('程序地图和工程地图实例共享同一平面变换约定', () => {
expect(
mapSelectionTransform({
kind: 'builtin',
config: {
...DEFAULT_PHYSICAL_MAP_CONFIG,
preset: 'rough',
positionX: 2,
positionY: -3,
yawDeg: 45,
},
}),
).toEqual({ positionX: 2, positionY: -3, yawDeg: 45 });
expect(
mapSelectionTransform({
kind: 'project',
descriptorPath: 'maps/warehouse/map.json',
positionX: 200,
positionY: Number.NaN,
yawDeg: -999,
}),
).toEqual({ positionX: 100, positionY: 0, yawDeg: -180 });
const selection = {
kind: 'project' as const,
descriptorPath: 'maps/warehouse/map.json',
positionX: 2,
positionY: -3,
yawDeg: 90,
};
const world = mapLocalPointToWorld([1, 2, 0.5], selection);
expect(world[0]).toBeCloseTo(0);
expect(world[1]).toBeCloseTo(-2);
expect(mapWorldPointToLocal(world, selection)).toEqual([
expect.closeTo(1),
expect.closeTo(2),
0.5,
]);
});
});
+96 -1
View File
@@ -14,7 +14,7 @@ export type SystemTerrainPreset = (typeof SYSTEM_TERRAIN_PRESETS)[number];
export type PhysicalMapPreset = export type PhysicalMapPreset =
'none' | 'flat' | 'slope' | 'stairs' | 'obstacles' | SystemTerrainPreset; 'none' | 'flat' | 'slope' | 'stairs' | 'obstacles' | SystemTerrainPreset;
export function isSystemTerrainPreset(value: PhysicalMapPreset): value is SystemTerrainPreset { export function isSystemTerrainPreset(value: string): value is SystemTerrainPreset {
return (SYSTEM_TERRAIN_PRESETS as readonly string[]).includes(value); return (SYSTEM_TERRAIN_PRESETS as readonly string[]).includes(value);
} }
@@ -82,11 +82,45 @@ export type MapSelection =
| { | {
kind: 'project'; kind: 'project';
descriptorPath: string; descriptorPath: string;
/** 工程地图实例位姿;descriptor 内容始终保持源坐标不变。 */
positionX?: number;
positionY?: number;
yawDeg?: number;
spawnPointId?: string; spawnPointId?: string;
robotRootBody?: string; robotRootBody?: string;
frictionOverride?: number; frictionOverride?: number;
}; };
export type PlacedMapSelection = Exclude<MapSelection, { kind: 'none' }>;
/** 场景中的地图实例;源地图与实例状态分离,同一源可重复放置。 */
export interface PlacedMapAsset {
id: string;
name: string;
selection: PlacedMapSelection;
}
export function createPlacedMapAsset(
selection: PlacedMapSelection,
name: string,
id = `map_${crypto.randomUUID().slice(0, 8)}`,
): PlacedMapAsset {
return { id, name, selection: structuredClone(selection) };
}
export function updatePlacedMapAsset(
assets: readonly PlacedMapAsset[],
id: string,
selection: PlacedMapSelection,
name?: string,
): PlacedMapAsset[] {
return assets.map((asset) =>
asset.id === id
? { ...asset, ...(name ? { name } : {}), selection: structuredClone(selection) }
: asset,
);
}
export interface ResolvedProjectMap { export interface ResolvedProjectMap {
definition: MapDefinition; definition: MapDefinition;
descriptorPath: string; descriptorPath: string;
@@ -102,6 +136,67 @@ export interface VisualMapAsset {
data: Uint8Array; data: Uint8Array;
castShadow: boolean; castShadow: boolean;
receiveShadow: boolean; receiveShadow: boolean;
positionX: number;
positionY: number;
yawDeg: number;
}
export interface MapInstanceTransform {
positionX: number;
positionY: number;
yawDeg: number;
}
export const DEFAULT_MAP_INSTANCE_TRANSFORM: MapInstanceTransform = {
positionX: 0,
positionY: 0,
yawDeg: 0,
};
export function mapSelectionTransform(selection: PlacedMapSelection): MapInstanceTransform {
if (selection.kind === 'builtin')
return {
positionX: selection.config.positionX,
positionY: selection.config.positionY,
yawDeg: selection.config.yawDeg,
};
const finite = (value: number | undefined, minimum: number, maximum: number) =>
typeof value === 'number' && Number.isFinite(value)
? Math.min(maximum, Math.max(minimum, value))
: 0;
return {
positionX: finite(selection.positionX, -100, 100),
positionY: finite(selection.positionY, -100, 100),
yawDeg: finite(selection.yawDeg, -180, 180),
};
}
export function mapLocalPointToWorld(
point: readonly [number, number, number],
selection: PlacedMapSelection,
): [number, number, number] {
const transform = mapSelectionTransform(selection);
const yaw = (transform.yawDeg * Math.PI) / 180;
const cosine = Math.cos(yaw);
const sine = Math.sin(yaw);
return [
transform.positionX + cosine * point[0] - sine * point[1],
transform.positionY + sine * point[0] + cosine * point[1],
point[2],
];
}
export function mapWorldPointToLocal(
point: readonly [number, number, number],
selection: PlacedMapSelection,
): [number, number, number] {
const transform = mapSelectionTransform(selection);
const yaw = (-transform.yawDeg * Math.PI) / 180;
const cosine = Math.cos(yaw);
const sine = Math.sin(yaw);
const dx = point[0] - transform.positionX;
const dy = point[1] - transform.positionY;
return [cosine * dx - sine * dy, sine * dx + cosine * dy, point[2]];
} }
export const DEFAULT_PHYSICAL_MAP_CONFIG: PhysicalMapConfig = { export const DEFAULT_PHYSICAL_MAP_CONFIG: PhysicalMapConfig = {
+57 -11
View File
@@ -36,12 +36,20 @@ function BodyBranch({
node, node,
depth, depth,
onJointHover, onJointHover,
onSelectBody,
onSelectJoint,
selectedBodyId,
selectedJointId,
searching, searching,
query, query,
}: { }: {
node: BodyNode; node: BodyNode;
depth: number; depth: number;
onJointHover: (jointId: number | null) => void; onJointHover: (jointId: number | null) => void;
onSelectBody?: (bodyId: number) => void;
onSelectJoint?: (joint: JointInfo) => void;
selectedBodyId?: number;
selectedJointId?: number;
searching: boolean; searching: boolean;
query: string; query: string;
}) { }) {
@@ -60,11 +68,15 @@ function BodyBranch({
<summary <summary
role="treeitem" role="treeitem"
aria-expanded={shownOpen} aria-expanded={shownOpen}
aria-selected={selectedBodyId === node.id}
tabIndex={0} tabIndex={0}
onClick={(event) => { onClick={(event) => {
onSelectBody?.(node.id);
if (searching) event.preventDefault(); if (searching) event.preventDefault();
}} }}
className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30" className={`flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30 ${
selectedBodyId === node.id ? 'bg-accent-soft text-accent' : 'text-text-secondary'
}`}
> >
<Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" /> <Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="truncate"> <span className="truncate">
@@ -74,10 +86,14 @@ function BodyBranch({
<ul role="group" className="ml-3 border-l border-border pl-1"> <ul role="group" className="ml-3 border-l border-border pl-1">
{node.joints.map((joint) => ( {node.joints.map((joint) => (
<li role="none" key={joint.id}> <li role="none" key={joint.id}>
<span <button
type="button"
role="treeitem" role="treeitem"
tabIndex={0} aria-selected={selectedJointId === joint.id}
className="flex cursor-default items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-warning hover:bg-warning-soft focus:bg-warning-soft focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30" className={`flex w-full items-center gap-1.5 truncate rounded px-1.5 py-1 text-left text-xs text-warning hover:bg-warning-soft focus:bg-warning-soft focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 ${
selectedJointId === joint.id ? 'bg-warning-soft' : ''
}`}
onClick={() => onSelectJoint?.(joint)}
onMouseEnter={() => onJointHover(joint.id)} onMouseEnter={() => onJointHover(joint.id)}
onMouseLeave={() => onJointHover(null)} onMouseLeave={() => onJointHover(null)}
onFocus={() => onJointHover(joint.id)} onFocus={() => onJointHover(joint.id)}
@@ -86,7 +102,7 @@ function BodyBranch({
> >
<Disc3 aria-hidden="true" className="h-3.5 w-3.5 shrink-0" /> <Disc3 aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
<SearchHighlight text={joint.name} query={query} /> <SearchHighlight text={joint.name} query={query} />
</span> </button>
</li> </li>
))} ))}
{node.children.map((child) => ( {node.children.map((child) => (
@@ -95,6 +111,10 @@ function BodyBranch({
node={child} node={child}
depth={depth + 1} depth={depth + 1}
onJointHover={onJointHover} onJointHover={onJointHover}
onSelectBody={onSelectBody}
onSelectJoint={onSelectJoint}
selectedBodyId={selectedBodyId}
selectedJointId={selectedJointId}
searching={searching} searching={searching}
query={query} query={query}
/> />
@@ -102,14 +122,20 @@ function BodyBranch({
</ul> </ul>
</details> </details>
) : ( ) : (
<div <button
type="button"
role="treeitem" role="treeitem"
tabIndex={0} aria-selected={selectedBodyId === node.id}
className="flex items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary focus-visible:ring-2 focus-visible:ring-accent/30" className={`flex w-full items-center gap-1.5 truncate rounded px-1.5 py-1 text-left text-xs focus-visible:ring-2 focus-visible:ring-accent/30 ${
selectedBodyId === node.id
? 'bg-accent-soft text-accent'
: 'text-text-secondary hover:bg-element-hover'
}`}
onClick={() => onSelectBody?.(node.id)}
> >
<Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" /> <Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
<SearchHighlight text={node.name} query={query} /> <SearchHighlight text={node.name} query={query} />
</div> </button>
)} )}
</li> </li>
); );
@@ -169,11 +195,19 @@ export function ModelStructureTree({
bodies, bodies,
joints, joints,
onJointHover, onJointHover,
onSelectBody,
onSelectJoint,
selectedBodyId,
selectedJointId,
query = '', query = '',
}: { }: {
bodies: BodyInfo[]; bodies: BodyInfo[];
joints: JointInfo[]; joints: JointInfo[];
onJointHover: (jointId: number | null) => void; onJointHover: (jointId: number | null) => void;
onSelectBody?: (bodyId: number) => void;
onSelectJoint?: (joint: JointInfo) => void;
selectedBodyId?: number;
selectedJointId?: number;
query?: string; query?: string;
}) { }) {
const normalized = query.trim().toLocaleLowerCase(), const normalized = query.trim().toLocaleLowerCase(),
@@ -211,8 +245,12 @@ export function ModelStructureTree({
renderRow={(item) => renderRow={(item) =>
item.kind === 'body' ? ( item.kind === 'body' ? (
<div <div
aria-selected={selectedBodyId === item.body.id}
onClick={() => onSelectBody?.(item.body.id)}
onDoubleClick={() => toggle(item)} onDoubleClick={() => toggle(item)}
className="flex h-full items-center gap-1.5 px-1.5 text-xs text-text-secondary" className={`flex h-full items-center gap-1.5 px-1.5 text-xs ${
selectedBodyId === item.body.id ? 'text-accent' : 'text-text-secondary'
}`}
style={{ paddingLeft: item.depth * 12 + 6 }} style={{ paddingLeft: item.depth * 12 + 6 }}
> >
<Box className="h-3.5 w-3.5 text-accent" /> <Box className="h-3.5 w-3.5 text-accent" />
@@ -220,8 +258,12 @@ export function ModelStructureTree({
</div> </div>
) : ( ) : (
<div <div
className="flex h-full items-center gap-1.5 px-1.5 text-xs text-warning" aria-selected={selectedJointId === item.joint.id}
className={`flex h-full items-center gap-1.5 px-1.5 text-xs text-warning ${
selectedJointId === item.joint.id ? 'bg-warning-soft' : ''
}`}
style={{ paddingLeft: item.depth * 12 + 6 }} style={{ paddingLeft: item.depth * 12 + 6 }}
onClick={() => onSelectJoint?.(item.joint)}
onMouseEnter={() => onJointHover(item.joint.id)} onMouseEnter={() => onJointHover(item.joint.id)}
onMouseLeave={() => onJointHover(null)} onMouseLeave={() => onJointHover(null)}
> >
@@ -244,6 +286,10 @@ export function ModelStructureTree({
node={root} node={root}
depth={0} depth={0}
onJointHover={onJointHover} onJointHover={onJointHover}
onSelectBody={onSelectBody}
onSelectJoint={onSelectJoint}
selectedBodyId={selectedBodyId}
selectedJointId={selectedJointId}
searching={Boolean(normalized)} searching={Boolean(normalized)}
query={query} query={query}
/> />
+25
View File
@@ -2,6 +2,7 @@ import { zipSync } from 'fflate';
import { import {
choosePreferredEntry, choosePreferredEntry,
discoverEntries, discoverEntries,
filesFromDrop,
importBrowserFiles, importBrowserFiles,
normalizeProjectPath, normalizeProjectPath,
prepareProjectForMujoco, prepareProjectForMujoco,
@@ -35,6 +36,30 @@ describe('project importer', () => {
expect(entries).toHaveLength(3); expect(entries).toHaveLength(3);
expect(choosePreferredEntry(entries)).toBe('model.xml'); expect(choosePreferredEntry(entries)).toBe('model.xml');
}); });
it('通过现代文件系统句柄递归读取拖入的文件夹', async () => {
const model = new File(['<mujoco/>'], 'model.xml', { type: 'text/xml' });
const fileHandle = {
kind: 'file',
name: 'model.xml',
getFile: async () => model,
};
const directoryHandle = {
kind: 'directory',
name: 'robot',
async *values() {
yield fileHandle;
},
};
const items = [
{
kind: 'file',
getAsFileSystemHandle: async () => directoryHandle,
},
] as unknown as DataTransferItemList;
const files = await filesFromDrop(items, [] as unknown as FileList);
expect(files).toHaveLength(1);
expect(files[0].webkitRelativePath).toBe('robot/model.xml');
});
it('异步解压 ZIP、保留二进制数据并报告阶段进度', async () => { it('异步解压 ZIP、保留二进制数据并报告阶段进度', async () => {
const zipped = zipSync({ const zipped = zipSync({
'robot/model.urdf': encode('<robot name="r"/>'), 'robot/model.urdf': encode('<robot name="r"/>'),
+44 -1
View File
@@ -427,6 +427,29 @@ export async function importBrowserFiles(
return manifest(files[0].path.split('/')[0] || '工程', files); return manifest(files[0].path.split('/')[0] || '工程', files);
} }
interface DroppedFileSystemHandle {
kind: 'file' | 'directory';
name: string;
getFile?: () => Promise<File>;
values?: () => AsyncIterableIterator<DroppedFileSystemHandle>;
}
async function readFileSystemHandle(handle: DroppedFileSystemHandle, prefix = ''): Promise<File[]> {
if (handle.kind === 'file' && handle.getFile) {
const file = await handle.getFile();
Object.defineProperty(file, 'webkitRelativePath', {
configurable: true,
value: `${prefix}${file.name}`,
});
return [file];
}
if (handle.kind !== 'directory' || !handle.values) return [];
const files: File[] = [];
for await (const child of handle.values())
files.push(...(await readFileSystemHandle(child, `${prefix}${handle.name}/`)));
return files;
}
interface LegacyEntry { interface LegacyEntry {
isFile: boolean; isFile: boolean;
isDirectory: boolean; isDirectory: boolean;
@@ -464,7 +487,27 @@ export async function filesFromDrop(
items: DataTransferItemList, items: DataTransferItemList,
fallback: FileList, fallback: FileList,
): Promise<File[]> { ): Promise<File[]> {
const entries = Array.from(items) const fileItems = Array.from(items).filter((item) => item.kind === 'file');
const handleRequests = fileItems
.map((item) => {
const getter = (
item as unknown as {
getAsFileSystemHandle?: () => Promise<DroppedFileSystemHandle | null>;
}
).getAsFileSystemHandle;
return getter?.call(item);
})
.filter((request): request is Promise<DroppedFileSystemHandle | null> => Boolean(request));
if (handleRequests.length > 0 && handleRequests.length === fileItems.length) {
const settled = await Promise.allSettled(handleRequests);
const handles = settled.flatMap((result) =>
result.status === 'fulfilled' && result.value ? [result.value] : [],
);
if (handles.length === fileItems.length)
return (await Promise.all(handles.map((handle) => readFileSystemHandle(handle)))).flat();
}
const entries = fileItems
.map( .map(
(item) => (item) =>
(item as unknown as { webkitGetAsEntry?: () => LegacyEntry | null }).webkitGetAsEntry?.() ?? (item as unknown as { webkitGetAsEntry?: () => LegacyEntry | null }).webkitGetAsEntry?.() ??
@@ -1,5 +1,5 @@
import type { ActuatorInfo, ActuatorParameters } from './SimulationSession'; import type { ActuatorInfo, ActuatorParameters } from './SimulationSession';
import { Badge } from '../components/ui'; import { Badge, ScrubbableNumberInput } from '../components/ui';
export function ActuatorControl({ export function ActuatorControl({
actuator, actuator,
@@ -187,25 +187,15 @@ function ParameterInput({
disabled?: boolean; disabled?: boolean;
}) { }) {
return ( return (
<label className="block text-[10px] text-text-tertiary"> <ScrubbableNumberInput
<span className="mb-1 block truncate">{label}</span> label={label}
<input value={Number.isFinite(value) ? value : 0}
key={value} step={Math.max(0.001, Math.abs(value) * 0.01)}
type="number"
step="any"
defaultValue={Number.isFinite(value) ? value : 0}
disabled={disabled} disabled={disabled}
className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40" onValueChange={(next) => {
onBlur={(event) => { if (next !== value) onCommit(next);
const next = Number(event.currentTarget.value);
if (Number.isFinite(next) && next !== value) onCommit(next);
else event.currentTarget.value = String(value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
}} }}
/> />
</label>
); );
} }
function ParameterToggle({ function ParameterToggle({
@@ -0,0 +1,83 @@
import { MainThreadPhysicsAdapter } from './PhysicsAdapter';
import type { ProjectFile } from '../project/types';
interface Disposable {
dispose(): void;
}
interface AdapterLifecycleState {
session: Disposable | null;
workspace: Disposable | null;
supportFiles: ProjectFile[];
retiredSession: Disposable | null;
retiredWorkspace: Disposable | null;
retiredSupportFiles: ProjectFile[];
releaseRetired(): void;
rollbackRetired(): void;
}
const lifecycle = (adapter: MainThreadPhysicsAdapter) =>
adapter as unknown as AdapterLifecycleState;
const file = (path: string): ProjectFile => ({
path,
data: new Uint8Array(),
size: 0,
source: 'file',
mimeType: '',
});
describe('MainThreadPhysicsAdapter 会话事务清理', () => {
it('先清空 retired 引用,旧资源释放失败也不会破坏当前会话', () => {
const adapter = new MainThreadPhysicsAdapter();
const state = lifecycle(adapter);
const activeSession = { dispose: vi.fn() };
const retiredSession = {
dispose: vi.fn(() => {
throw new Error('controller cleanup failed');
}),
};
const retiredWorkspace = { dispose: vi.fn() };
state.session = activeSession;
state.retiredSession = retiredSession;
state.retiredWorkspace = retiredWorkspace;
state.retiredSupportFiles = [file('old.xml')];
expect(() => state.releaseRetired()).not.toThrow();
expect(state.session).toBe(activeSession);
expect(state.retiredSession).toBeNull();
expect(state.retiredWorkspace).toBeNull();
expect(state.retiredSupportFiles).toEqual([]);
expect(retiredSession.dispose).toHaveBeenCalledOnce();
expect(retiredWorkspace.dispose).toHaveBeenCalledOnce();
});
it('先恢复旧引用,候选资源释放失败也不会阻止回滚', () => {
const adapter = new MainThreadPhysicsAdapter();
const state = lifecycle(adapter);
const failedSession = {
dispose: vi.fn(() => {
throw new Error('candidate cleanup failed');
}),
};
const failedWorkspace = { dispose: vi.fn() };
const retiredSession = { dispose: vi.fn() };
const retiredWorkspace = { dispose: vi.fn() };
const retiredFiles = [file('old.xml')];
state.session = failedSession;
state.workspace = failedWorkspace;
state.supportFiles = [file('new.xml')];
state.retiredSession = retiredSession;
state.retiredWorkspace = retiredWorkspace;
state.retiredSupportFiles = retiredFiles;
expect(() => state.rollbackRetired()).not.toThrow();
expect(state.session).toBe(retiredSession);
expect(state.workspace).toBe(retiredWorkspace);
expect(state.supportFiles).toBe(retiredFiles);
expect(state.retiredSession).toBeNull();
expect(state.retiredWorkspace).toBeNull();
expect(failedSession.dispose).toHaveBeenCalledOnce();
expect(failedWorkspace.dispose).toHaveBeenCalledOnce();
});
});
+44 -12
View File
@@ -24,7 +24,8 @@ import type {
import { composePhysicalMap } from '../map/physicalMap'; import { composePhysicalMap } from '../map/physicalMap';
import { composeProjectMap } from '../map/MapComposer'; import { composeProjectMap } from '../map/MapComposer';
import { resolveProjectMap } from '../map/MapLoader'; import { resolveProjectMap } from '../map/MapLoader';
import { DEFAULT_MAP_SELECTION, type MapSelection } from '../map/types'; import { composePlacedMapAssets } from '../map/MapStackComposer';
import { DEFAULT_MAP_SELECTION, type MapSelection, type PlacedMapAsset } from '../map/types';
export type UrdfLoadMode = 'mjcf' | 'native'; export type UrdfLoadMode = 'mjcf' | 'native';
export type { UrdfBaseMode, UrdfEnhancementOptions }; export type { UrdfBaseMode, UrdfEnhancementOptions };
@@ -38,7 +39,9 @@ export interface PhysicsLoadOptions {
urdfMode?: UrdfLoadMode; urdfMode?: UrdfLoadMode;
baseMode?: UrdfBaseMode; baseMode?: UrdfBaseMode;
enhancements?: UrdfEnhancementOptions; enhancements?: UrdfEnhancementOptions;
/** @deprecated 单地图兼容入口;新场景应使用 mapAssets。 */
map?: MapSelection; map?: MapSelection;
mapAssets?: readonly PlacedMapAsset[];
onProgress?: (progress: PhysicsLoadProgress) => void; onProgress?: (progress: PhysicsLoadProgress) => void;
} }
@@ -126,6 +129,8 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
sensorType: 'camera', sensorType: 'camera',
}; };
const mapSelection = options.map ?? DEFAULT_MAP_SELECTION; const mapSelection = options.map ?? DEFAULT_MAP_SELECTION;
const placedMapAssets = options.mapAssets;
const hasMaps = placedMapAssets ? placedMapAssets.length > 0 : mapSelection.kind !== 'none';
const report = (value: number, label: string) => { const report = (value: number, label: string) => {
if (!this.disposed && generation === this.loadGeneration) if (!this.disposed && generation === this.loadGeneration)
options.onProgress?.({ value, label }); options.onProgress?.({ value, label });
@@ -191,24 +196,31 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
intermediate.dispose(); intermediate.dispose();
} }
} }
if (mapSelection.kind !== 'none') { if (hasMaps) {
report(0.72, '组合机器人与物理地图'); report(0.72, '组合机器人与物理地图');
if (entry?.format === 'urdf' && urdfMode === 'native') if (entry?.format === 'urdf' && urdfMode === 'native')
throw new Error('原生 URDF 模式暂不支持地图,请切换为转换模式'); throw new Error('原生 URDF 模式暂不支持地图,请切换为转换模式');
const slash = modelRelativePath.lastIndexOf('/'); const slash = modelRelativePath.lastIndexOf('/');
const directory = slash >= 0 ? modelRelativePath.slice(0, slash + 1) : ''; const directory = slash >= 0 ? modelRelativePath.slice(0, slash + 1) : '';
const mapPath = `${directory}.__mujoco_map_scene_${manifest.id.replace(/[^a-zA-Z0-9_-]/g, '_')}.xml`; const mapPath = `${directory}.__mujoco_map_scene_${manifest.id.replace(/[^a-zA-Z0-9_-]/g, '_')}.xml`;
if (mapSelection.kind === 'builtin') { const source = new TextEncoder().encode(workspace.readText(modelRelativePath));
const composed = composePhysicalMap( if (placedMapAssets) {
new TextEncoder().encode(workspace.readText(modelRelativePath)), const composed = composePlacedMapAssets(
mapSelection.config, source,
mapPath,
prepared.manifest,
placedMapAssets,
); );
workspace.writeGenerated(mapPath, composed.data); workspace.writeGenerated(mapPath, composed.data);
warnings.push(...composed.warnings);
} else if (mapSelection.kind === 'builtin') {
const composed = composePhysicalMap(source, mapSelection.config);
workspace.writeGenerated(mapPath, composed.data);
if (composed.summary) warnings.push(composed.summary); if (composed.summary) warnings.push(composed.summary);
} else { } else if (mapSelection.kind === 'project') {
const resolvedMap = resolveProjectMap(prepared.manifest, mapSelection.descriptorPath); const resolvedMap = resolveProjectMap(prepared.manifest, mapSelection.descriptorPath);
const composed = composeProjectMap( const composed = composeProjectMap(
new TextEncoder().encode(workspace.readText(modelRelativePath)), source,
mapPath, mapPath,
prepared.manifest, prepared.manifest,
resolvedMap, resolvedMap,
@@ -339,21 +351,41 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
return this.supportFiles.map((file) => ({ ...file, data: file.data.slice() })); return this.supportFiles.map((file) => ({ ...file, data: file.data.slice() }));
} }
releaseRetired(): void { releaseRetired(): void {
this.retiredSession?.dispose(); const retiredSession = this.retiredSession;
const retiredWorkspace = this.retiredWorkspace;
this.retiredSession = null; this.retiredSession = null;
this.retiredWorkspace?.dispose();
this.retiredWorkspace = null; this.retiredWorkspace = null;
this.retiredSupportFiles = []; this.retiredSupportFiles = [];
try {
retiredSession?.dispose();
} catch (error) {
console.warn('[MuJoCo] 释放旧仿真会话失败', error);
}
try {
retiredWorkspace?.dispose();
} catch (error) {
console.warn('[MuJoCo] 释放旧内存工作区失败', error);
}
} }
rollbackRetired(): void { rollbackRetired(): void {
this.session?.dispose(); const failedSession = this.session;
this.workspace?.dispose(); const failedWorkspace = this.workspace;
this.session = this.retiredSession; this.session = this.retiredSession;
this.workspace = this.retiredWorkspace; this.workspace = this.retiredWorkspace;
this.supportFiles = this.retiredSupportFiles; this.supportFiles = this.retiredSupportFiles;
this.retiredSession = null; this.retiredSession = null;
this.retiredWorkspace = null; this.retiredWorkspace = null;
this.retiredSupportFiles = []; this.retiredSupportFiles = [];
try {
failedSession?.dispose();
} catch (error) {
console.warn('[MuJoCo] 释放失败的候选仿真会话失败', error);
}
try {
failedWorkspace?.dispose();
} catch (error) {
console.warn('[MuJoCo] 释放失败的候选内存工作区失败', error);
}
} }
exportMjcf(): Uint8Array { exportMjcf(): Uint8Array {
if (!this.session || !this.workspace) throw new Error('尚未加载可导出的模型'); if (!this.session || !this.workspace) throw new Error('尚未加载可导出的模型');
@@ -0,0 +1,327 @@
import type { ReactNode } from 'react';
import { Box, Disc3, Info } from 'lucide-react';
import type { ModelEntry } from '../project/types';
import type { UrdfBaseMode, UrdfLoadMode } from './PhysicsAdapter';
import type { ViewerSelection } from '../viewer/MuJoCoViewer';
import {
Badge,
Button,
CollapsibleSection,
CopyButton,
PropertyRow,
Select,
} from '../components/ui';
import { ActuatorControl, Check, ControlSlider } from './ActuatorControl';
import type {
ActuatorParameters,
BodyInfo,
JointInfo,
SimulationSnapshot,
} from './SimulationSession';
const JOINT_TYPE_LABELS: Record<number, string> = {
0: 'Free',
1: 'Ball',
2: 'Slide',
3: 'Hinge',
};
export function InspectorIdentity({
icon,
eyebrow,
name,
meta,
}: {
icon: ReactNode;
eyebrow: string;
name: string;
meta?: string;
}) {
return (
<header className="flex items-center gap-3 border-b border-border bg-surface/70 px-3 py-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg border border-border-subtle bg-panel-muted text-accent [&>svg]:h-4 [&>svg]:w-4">
{icon}
</span>
<div className="min-w-0 flex-1">
<div className="text-[10px] font-medium uppercase tracking-wider text-text-tertiary">
{eyebrow}
</div>
<div className="truncate text-sm font-semibold text-text-primary" title={name}>
{name}
</div>
{meta && <div className="mt-0.5 truncate text-[10px] text-text-tertiary">{meta}</div>}
</div>
</header>
);
}
export function ModelSummaryInspector({
snapshot,
selectedFormat,
loading,
urdfMode,
baseMode,
showCollision,
onUrdfMode,
onBaseMode,
onShowCollision,
}: {
snapshot: SimulationSnapshot;
selectedFormat?: ModelEntry['format'];
loading: boolean;
urdfMode: UrdfLoadMode;
baseMode: UrdfBaseMode;
showCollision: boolean;
onUrdfMode: (value: UrdfLoadMode) => void;
onBaseMode: (value: UrdfBaseMode) => void;
onShowCollision: (value: boolean) => void;
}) {
return (
<>
<InspectorIdentity
icon={<Info />}
eyebrow="场景"
name="未选择对象"
meta="从场景大纲或视口选择 Body、Joint 或地图物体"
/>
<CollapsibleSection
title="模型摘要"
defaultOpen
badge={<Badge>{snapshot.model.nbody} Body</Badge>}
>
<PropertyRow label="Body" value={snapshot.model.nbody} />
<PropertyRow label="Joint" value={snapshot.model.njnt} />
<PropertyRow label="Geom" value={snapshot.model.ngeom} />
<PropertyRow label="Actuator" value={snapshot.model.nactuator} />
<PropertyRow label="qpos / qvel" value={`${snapshot.model.nq} / ${snapshot.model.nv}`} />
</CollapsibleSection>
{selectedFormat === 'urdf' && (
<CollapsibleSection title="URDF 导入设置" defaultOpen>
<Select
aria-label="URDF 处理方式"
className="w-full"
value={urdfMode}
disabled={loading}
onChange={(event) => onUrdfMode(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-text-secondary">
<span className="mb-1 block">基座类型</span>
<Select
aria-label="URDF 基座类型"
className="w-full"
value={baseMode}
disabled={loading || urdfMode === 'native'}
onChange={(event) => onBaseMode(event.target.value as UrdfBaseMode)}
>
<option value="floating">浮动基座(Free Joint)</option>
<option value="fixed">固定基座(连接世界)</option>
</Select>
</label>
<Check label="显示碰撞几何" checked={showCollision} onChange={onShowCollision} />
</CollapsibleSection>
)}
</>
);
}
export function BodyInspector({
body,
snapshot,
viewerSelection,
showCollision,
onShowCollision,
onSelectJoint,
}: {
body: BodyInfo;
snapshot: SimulationSnapshot;
viewerSelection: ViewerSelection | null;
showCollision: boolean;
onShowCollision: (value: boolean) => void;
onSelectJoint: (jointId: number, bodyId: number) => void;
}) {
const parent = snapshot.bodies.find((candidate) => candidate.id === body.parentId);
const joints = snapshot.joints.filter((joint) => joint.bodyId === body.id);
const children = snapshot.bodies.filter((candidate) => candidate.parentId === body.id);
const hit = viewerSelection?.bodyId === body.id ? viewerSelection : null;
return (
<>
<InspectorIdentity
icon={<Box />}
eyebrow="Robot / Body"
name={body.name}
meta={`Body #${body.id}`}
/>
<CollapsibleSection title="标识与层级" defaultOpen>
<PropertyRow
label="名称"
value={body.name}
action={<CopyButton value={body.name} label="复制 Body 名称" />}
/>
<PropertyRow label="Body ID" value={body.id} />
<PropertyRow label="父级" value={parent?.name ?? 'world'} />
<PropertyRow label="子 Body" value={children.length} />
<PropertyRow label="Joint" value={joints.length} />
</CollapsibleSection>
{hit && (
<CollapsibleSection title="视口命中" defaultOpen>
<PropertyRow label="Geom ID" value={hit.geomId} />
<PropertyRow label="Geom Type" value={hit.geomType} />
<PropertyRow
label="世界位置"
value={hit.position.map((value) => value.toFixed(3)).join(', ')}
action={<CopyButton value={hit.position.join(', ')} label="复制世界位置" />}
/>
</CollapsibleSection>
)}
<CollapsibleSection title="显示" defaultOpen>
<Check label="显示碰撞几何" checked={showCollision} onChange={onShowCollision} />
</CollapsibleSection>
{joints.length > 0 && (
<CollapsibleSection title="所属关节" defaultOpen badge={<Badge>{joints.length}</Badge>}>
<div className="space-y-1">
{joints.map((joint) => (
<button
key={joint.id}
type="button"
className="flex w-full items-center gap-2 rounded border border-border-subtle px-2 py-1.5 text-left text-xs text-text-secondary hover:border-accent hover:bg-accent-soft"
onClick={() => onSelectJoint(joint.id, joint.bodyId)}
>
<Disc3 className="h-3.5 w-3.5 text-warning" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{joint.name}</span>
<span className="text-[9px] text-text-tertiary">
{JOINT_TYPE_LABELS[joint.type] ?? joint.type}
</span>
</button>
))}
</div>
</CollapsibleSection>
)}
{!hit && (
<p className="flex items-center gap-2 p-3 text-[10px] leading-relaxed text-text-tertiary">
<Info className="h-3.5 w-3.5 shrink-0" />
Body 运行时变换为只读;若要显示命中位置,请在视口中点击该 Body。
</p>
)}
</>
);
}
function jointScale(joint: JointInfo, angleUnit: 'rad' | 'deg'): number {
return joint.type === 3 && angleUnit === 'deg' ? 180 / Math.PI : 1;
}
export function JointInspector({
joint,
snapshot,
loading,
ignoreJointLimits,
jointAdvanced,
angleUnit,
onResetJoints,
onToggleJointLimits,
onToggleAdvanced,
onToggleAngleUnit,
onJoint,
onActuator,
onActuatorParameters,
}: {
joint: JointInfo;
snapshot: SimulationSnapshot;
loading: boolean;
ignoreJointLimits: boolean;
jointAdvanced: boolean;
angleUnit: 'rad' | 'deg';
onResetJoints: () => void;
onToggleJointLimits: () => void;
onToggleAdvanced: () => void;
onToggleAngleUnit: () => void;
onJoint: (id: number, value: number) => void;
onActuator: (id: number, value: number) => void;
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
}) {
const body = snapshot.bodies.find((candidate) => candidate.id === joint.bodyId);
const actuators = snapshot.actuators.filter((actuator) => actuator.jointId === joint.id);
const scale = jointScale(joint, angleUnit);
const unit =
joint.type === 3 ? (angleUnit === 'deg' ? '°' : ' rad') : joint.type === 2 ? ' m' : '';
return (
<>
<InspectorIdentity
icon={<Disc3 />}
eyebrow="Robot / Joint"
name={joint.name}
meta={`${JOINT_TYPE_LABELS[joint.type] ?? `Type ${joint.type}`} · ${body?.name ?? `Body ${joint.bodyId}`}`}
/>
<CollapsibleSection title="关节属性" defaultOpen>
<PropertyRow label="Joint ID" value={joint.id} />
<PropertyRow label="所属 Body" value={body?.name ?? joint.bodyId} />
<PropertyRow label="轴向" value={joint.axis.map((value) => value.toFixed(3)).join(', ')} />
<PropertyRow label="限位" value={joint.limited ? '启用' : '未启用'} />
<div className="mt-3">
<ControlSlider
label={`${joint.name}${joint.editable ? '' : '(只读)'}`}
value={joint.value * scale}
min={joint.min * scale}
max={joint.max * scale}
unit={unit}
advanced={jointAdvanced}
limited={joint.limited}
limitsIgnored={joint.limitsIgnored}
limitMin={joint.limitMin * scale}
limitMax={joint.limitMax * scale}
disabled={loading || !joint.editable}
onChange={(value) => onJoint(joint.id, value / scale)}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<Button disabled={loading} onClick={onResetJoints}>
重置关节
</Button>
<Button
disabled={loading}
variant={ignoreJointLimits ? 'primary' : 'secondary'}
aria-pressed={ignoreJointLimits}
onClick={onToggleJointLimits}
>
忽略关节限位
</Button>
<Button
variant={jointAdvanced ? 'primary' : 'secondary'}
aria-pressed={jointAdvanced}
onClick={onToggleAdvanced}
>
高级
</Button>
<Button
variant={angleUnit === 'deg' ? 'primary' : 'secondary'}
aria-pressed={angleUnit === 'deg'}
onClick={onToggleAngleUnit}
>
{angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
</Button>
</div>
</CollapsibleSection>
<CollapsibleSection
title="关联 Actuator"
defaultOpen
badge={<Badge>{actuators.length}</Badge>}
>
{actuators.length ? (
actuators.map((actuator) => (
<ActuatorControl
key={actuator.id}
actuator={actuator}
onControl={(value) => onActuator(actuator.id, value)}
onParameters={(parameters) => onActuatorParameters(actuator.id, parameters)}
/>
))
) : (
<p className="text-xs text-text-tertiary">该关节没有关联驱动器</p>
)}
</CollapsibleSection>
</>
);
}
@@ -928,11 +928,18 @@ export class SimulationSession {
dispose(): void { dispose(): void {
if (this.disposed) return; if (this.disposed) return;
this.disposed = true; this.disposed = true;
this.removeController(); const cleanup = (label: string, action: () => void) => {
this.removeRLPolicy(); try {
this.clearExternalForce(); action();
this.perturb.delete(); } catch (error) {
this.data.delete(); console.warn(`[MuJoCo] 释放${label}失败`, error);
this.model.delete(); }
};
cleanup('Python 控制器', () => this.removeController());
cleanup('RL 策略', () => this.removeRLPolicy());
cleanup('外力状态', () => this.clearExternalForce());
cleanup('扰动对象', () => this.perturb.delete());
cleanup('仿真数据', () => this.data.delete());
cleanup('仿真模型', () => this.model.delete());
} }
} }
+104 -3
View File
@@ -29,6 +29,9 @@
--ui-success-border: #a7ddca; --ui-success-border: #a7ddca;
--ui-scrollbar: #a9b5c4; --ui-scrollbar: #a9b5c4;
--ui-scrollbar-hover: #7f8ea1; --ui-scrollbar-hover: #7f8ea1;
--ui-glass: rgb(255 255 255 / 78%);
--ui-glass-highlight: rgb(255 255 255 / 72%);
--ui-shadow: rgb(15 23 42 / 18%);
color-scheme: light; color-scheme: light;
} }
.theme-dark { .theme-dark {
@@ -58,6 +61,9 @@
--ui-success-border: #285f50; --ui-success-border: #285f50;
--ui-scrollbar: #46566c; --ui-scrollbar: #46566c;
--ui-scrollbar-hover: #61728a; --ui-scrollbar-hover: #61728a;
--ui-glass: rgb(14 25 39 / 78%);
--ui-glass-highlight: rgb(255 255 255 / 7%);
--ui-shadow: rgb(0 0 0 / 48%);
color-scheme: dark; color-scheme: dark;
} }
@layer base { @layer base {
@@ -106,15 +112,91 @@
.technical-value { .technical-value {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.workbench-header {
background: linear-gradient(
90deg,
color-mix(in srgb, var(--ui-panel) 94%, var(--ui-surface)),
var(--ui-panel) 42%,
color-mix(in srgb, var(--ui-panel) 88%, var(--ui-accent) 12%)
);
}
.viewport-shell { .viewport-shell {
isolation: isolate;
background: background:
radial-gradient( radial-gradient(
circle at 50% 18%, circle at 50% 14%,
color-mix(in srgb, var(--ui-accent) 8%, transparent), color-mix(in srgb, var(--ui-accent) 10%, transparent),
transparent 34% transparent 35%
), ),
linear-gradient(145deg, var(--ui-surface), var(--ui-bg)); linear-gradient(145deg, var(--ui-surface), var(--ui-bg));
} }
.viewport-shell::before {
content: '';
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: linear-gradient(180deg, rgb(255 255 255 / 2%), transparent 22%, rgb(0 0 0 / 7%));
}
.engineering-glass {
border-color: color-mix(in srgb, var(--ui-border-strong) 72%, transparent);
background:
linear-gradient(135deg, var(--ui-glass-highlight), transparent 42%), var(--ui-glass);
box-shadow:
0 16px 44px var(--ui-shadow),
inset 0 1px 0 color-mix(in srgb, white 10%, transparent),
inset 0 -1px 0 color-mix(in srgb, black 10%, transparent);
backdrop-filter: blur(16px) saturate(135%);
}
.engineering-card {
border-color: color-mix(in srgb, var(--ui-border) 88%, transparent);
background: linear-gradient(
145deg,
color-mix(in srgb, var(--ui-surface) 88%, transparent),
color-mix(in srgb, var(--ui-panel) 72%, transparent)
);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
}
.tool-active-glow {
box-shadow: 0 0 14px color-mix(in srgb, var(--ui-accent) 14%, transparent);
}
.engineering-badge {
background-image: linear-gradient(180deg, rgb(255 255 255 / 5%), transparent);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 5%),
0 3px 10px rgb(0 0 0 / 9%);
letter-spacing: 0.01em;
}
.map-tool-enter {
animation: map-tool-enter 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.draft-dirty-dot {
animation: draft-dirty-pulse 1.8s ease-in-out infinite;
}
.scrubbable-number {
@apply min-w-0 text-[10px] text-text-secondary;
}
.scrubbable-number-label {
@apply mb-1 flex h-4 max-w-full cursor-ew-resize select-none items-center gap-1 border-0 bg-transparent p-0 text-left text-inherit outline-none transition-colors hover:text-accent disabled:cursor-not-allowed disabled:opacity-40;
}
.scrubbable-number-input {
@apply h-7 w-full rounded-md border border-border-strong bg-input px-2 text-xs text-text-primary outline-none transition-[border-color,box-shadow,background-color] hover:border-accent focus:border-accent focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:opacity-40;
font-variant-numeric: tabular-nums;
}
.scrubbable-number-active .scrubbable-number-label {
@apply text-accent;
}
.scrubbable-number-active .scrubbable-number-input {
border-color: var(--ui-accent);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--ui-accent) 20%, transparent),
0 0 18px color-mix(in srgb, var(--ui-accent) 12%, transparent);
}
body.is-scrubbing-number,
body.is-scrubbing-number * {
cursor: ew-resize !important;
user-select: none !important;
}
.workspace-welcome { .workspace-welcome {
animation: workspace-enter 360ms cubic-bezier(0.22, 1, 0.36, 1) both; animation: workspace-enter 360ms cubic-bezier(0.22, 1, 0.36, 1) both;
box-shadow: box-shadow:
@@ -134,6 +216,25 @@
transform: translateX(-50%); transform: translateX(-50%);
} }
} }
@keyframes map-tool-enter {
from {
opacity: 0;
transform: translate(-50%, -6px) scale(0.985);
}
to {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
}
@keyframes draft-dirty-pulse {
0%,
100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--ui-warning) 28%, transparent);
}
50% {
box-shadow: 0 0 0 5px transparent;
}
}
@keyframes workspace-enter { @keyframes workspace-enter {
from { from {
opacity: 0; opacity: 0;
+21
View File
@@ -1 +1,22 @@
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest';
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}),
});
if (!globalThis.ResizeObserver)
globalThis.ResizeObserver = class ResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
};

Some files were not shown because too many files have changed in this diff Show More