Cen #7
+3
-1
@@ -16,9 +16,10 @@ __pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
|
||||
# Local build and cache directories
|
||||
# Local build, cache, and planning directories
|
||||
build/
|
||||
.cache/
|
||||
/plans/
|
||||
|
||||
# Editors and operating systems
|
||||
.vscode/
|
||||
@@ -31,3 +32,4 @@ MUJOCO_LOG.TXT
|
||||
training_server/rl/logs/
|
||||
training_server/rl/wandb/
|
||||
training_server/rl/outputs/
|
||||
training_server/tuning-data/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Please always speak chinese.
|
||||
python 虚拟环境路径在/home/cen/Embodied_Workspace/Mujoco_Projects/mujoco/.venv/bin/activate
|
||||
系统是Ubuntu 24.04 LTS
|
||||
@@ -10,12 +10,12 @@
|
||||
- ROS `package://`、常见 URDF 兼容转换及 DAE 降级处理
|
||||
- Three.js 模型、碰撞体、坐标系、关节轴、质心和惯量可视化
|
||||
- 播放、暂停、单步、重置、变速、关节拖动与外力交互
|
||||
- 内置平地、坡道、楼梯、随机障碍物及 9 类系统参数化地形(粗糙/波浪、金字塔阶梯、深坑、沟壑等)
|
||||
- 工程地图包:静态 MJCF/OBJ/STL/高度场碰撞层、GLB 视觉层和机器人出生点
|
||||
- V3 地图创作层:认证资产库支持点击添加、拖到画布落位和首个资产自动创建场景,并可通过表单与视口操纵器继续编辑、事务式应用及导出地图 ZIP
|
||||
- 统一地图资产库:工程地图、认证资产、内置地图及 9 类系统参数化地形共用点击/拖放、场景树、轻量预览、实例变换、放弃和一次编译事务
|
||||
- 工程地图包:静态 MJCF/OBJ/STL/高度场碰撞层、GLB 视觉层和机器人出生点;同源实例拥有独立位姿,物理/视觉/出生点同步变换
|
||||
- V3 地图创作层:认证资产可在程序地形上自动重力落位,共用右侧属性和视口操纵器,支持源内容编辑、空白取消选择及导出地图 ZIP
|
||||
- 浏览器内 Python 控制器(Pyodide)
|
||||
- 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
@@ -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 外未改动项目。"
|
||||
}
|
||||
```
|
||||
Generated
+9
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mujoco-web-platform",
|
||||
"version": "0.7.3",
|
||||
"version": "0.8.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mujoco-web-platform",
|
||||
"version": "0.7.3",
|
||||
"version": "0.8.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
@@ -18,6 +18,7 @@
|
||||
"pyodide": "^0.29.4",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"uplot": "^1.6.32",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -5091,6 +5092,12 @@
|
||||
"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": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mujoco-web-platform",
|
||||
"version": "0.7.3",
|
||||
"version": "0.8.3",
|
||||
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -61,6 +61,7 @@
|
||||
"pyodide": "^0.29.4",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"uplot": "^1.6.32",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
-r training_server/requirements.txt
|
||||
ruff==0.16.5
|
||||
|
||||
@@ -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 等大型运行依赖仍需安装在本机训练环境中。
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
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
|
||||
```
|
||||
|
||||
启用云端自调参 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` 提供,可显式添加来源:
|
||||
|
||||
```bash
|
||||
@@ -37,7 +49,17 @@ python training_server/server.py \
|
||||
--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`:发起训练;
|
||||
- `GET /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
|
||||
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 test:training-server
|
||||
```
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Script to train RL agent with RSL-RL."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
@@ -10,8 +11,10 @@ from typing import Literal, cast
|
||||
|
||||
# 训练器作为仓库内置子集直接从 scripts/ 启动,不要求额外执行 pip install -e。
|
||||
TRAINER_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(TRAINER_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(TRAINER_ROOT))
|
||||
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 tyro
|
||||
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.wrappers import VideoRecorder
|
||||
|
||||
from tuning.schema import apply_reward_configuration, validate_configuration
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrainConfig:
|
||||
@@ -44,6 +49,10 @@ class TrainConfig:
|
||||
enable_nan_guard: bool = False
|
||||
torchrunx_log_dir: str | None = None
|
||||
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
|
||||
def from_task(task_id: str) -> "TrainConfig":
|
||||
@@ -52,7 +61,27 @@ class TrainConfig:
|
||||
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:
|
||||
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", "")
|
||||
if cuda_visible == "":
|
||||
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.
|
||||
|
||||
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.
|
||||
resume_path = get_checkpoint_path(
|
||||
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__)
|
||||
if resume_path is not None:
|
||||
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.
|
||||
if rank == 0:
|
||||
dump_yaml(log_dir / "params" / "env.yaml", env_cfg)
|
||||
dump_yaml(log_dir / "params" / "agent.yaml", agent_cfg)
|
||||
|
||||
runner.learn(
|
||||
num_learning_iterations=cfg.agent.max_iterations, init_at_random_ep_len=True
|
||||
if reward_config is not None:
|
||||
reward_snapshot = log_dir / "params" / "reward_config.json"
|
||||
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()
|
||||
|
||||
|
||||
@@ -159,12 +207,15 @@ def launch_training(task_id: str, args: TrainConfig | None = None):
|
||||
args = args or TrainConfig.from_task(task_id)
|
||||
|
||||
# Create log directory once before launching workers.
|
||||
log_root_path = Path("logs") / "rsl_rl" / args.agent.experiment_name
|
||||
log_root_path.resolve()
|
||||
if args.output_dir:
|
||||
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")
|
||||
if args.agent.run_name:
|
||||
log_dir_name += f"_{args.agent.run_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.
|
||||
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["foot_clearance"].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(
|
||||
func=mdp.illegal_contact,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from mjlab.envs.mdp import * # noqa: F401, F403
|
||||
|
||||
from .curriculums import * # noqa: F403
|
||||
from .metrics import * # noqa: F403
|
||||
from .observations import * # noqa: F403
|
||||
from .rewards 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 = {
|
||||
"mean_action_acc": MetricsTermCfg(
|
||||
func=mdp.mean_action_acc,
|
||||
"linear_velocity_rmse": MetricsTermCfg(
|
||||
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"},
|
||||
),
|
||||
"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_pos_limits": RewardTermCfg(func=mdp.joint_pos_limits, weight=-10.0),
|
||||
"action_rate_l2": RewardTermCfg(func=mdp.action_rate_l2, weight=-0.05),
|
||||
|
||||
+198
-21
@@ -23,9 +23,14 @@ from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
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 部署契约;其他任务须由服务启动参数显式放行。
|
||||
DEFAULT_TASKS = ("Unitree-Go2-Flat",)
|
||||
ACTIVE_STATES = {"queued", "running"}
|
||||
@@ -65,6 +70,7 @@ class TrainingConfig:
|
||||
device: str
|
||||
gpu_ids: list[int]
|
||||
wandb_mode: str
|
||||
reward_config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -110,6 +116,7 @@ class TrainingManager:
|
||||
python: str,
|
||||
tasks: tuple[str, ...],
|
||||
check_environment: bool = True,
|
||||
lease: GpuLease | None = None,
|
||||
):
|
||||
self.trainer_root = trainer_root.expanduser().resolve()
|
||||
self.python = str(Path(python).expanduser()) if os.sep in python else python
|
||||
@@ -118,6 +125,8 @@ class TrainingManager:
|
||||
self.lock = threading.RLock()
|
||||
self.check_environment = check_environment
|
||||
self._environment_error: str | None | bool = False
|
||||
self.lease = lease or GpuLease()
|
||||
self.preset_resolver: Any = None
|
||||
|
||||
def readiness_error(self) -> str | None:
|
||||
if not self.trainer_root.is_dir():
|
||||
@@ -204,6 +213,17 @@ class TrainingManager:
|
||||
wandb_mode = payload.get("wandbMode", "offline")
|
||||
if wandb_mode not in ("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(
|
||||
task_id=task_id,
|
||||
num_envs=integer("numEnvs", 1, 16384),
|
||||
@@ -213,6 +233,7 @@ class TrainingManager:
|
||||
device=device,
|
||||
gpu_ids=raw_gpu_ids,
|
||||
wandb_mode=wandb_mode,
|
||||
reward_config=reward_config,
|
||||
)
|
||||
|
||||
def start(self, payload: Any) -> dict[str, Any]:
|
||||
@@ -232,10 +253,20 @@ class TrainingManager:
|
||||
raise ApiError(HTTPStatus.CONFLICT, "训练任务历史已满,请稍后重试")
|
||||
del self.jobs[completed]
|
||||
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
|
||||
try:
|
||||
threading.Thread(
|
||||
target=self._run, args=(job,), name=f"training-{job.id[:8]}", daemon=True
|
||||
).start()
|
||||
except Exception:
|
||||
self.jobs.pop(job.id, None)
|
||||
self.lease.release(owner)
|
||||
raise
|
||||
return job.public()
|
||||
|
||||
def get(self, job_id: str) -> dict[str, Any]:
|
||||
@@ -305,6 +336,13 @@ class TrainingManager:
|
||||
f"--agent.seed={config.seed}",
|
||||
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":
|
||||
command.extend(("--gpu-ids", "None"))
|
||||
else:
|
||||
@@ -408,13 +446,16 @@ class TrainingManager:
|
||||
job.state = "cancelled" if job.cancel_requested else "failed"
|
||||
job.message = f"启动训练失败:{error}"
|
||||
job.logs.append(job.message)
|
||||
finally:
|
||||
self.lease.release(f"training:{job.id}")
|
||||
|
||||
|
||||
class TrainingRequestHandler(BaseHTTPRequestHandler):
|
||||
manager: TrainingManager
|
||||
tuning_manager: TuningManager
|
||||
allowed_origins: tuple[str, ...] = ()
|
||||
access_token = ""
|
||||
server_version = "MuJoCoLocalTraining/0.3"
|
||||
server_version = "MuJoCoLocalTraining/0.4"
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
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:
|
||||
if isinstance(error, ApiError):
|
||||
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:
|
||||
self._json(
|
||||
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)
|
||||
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:
|
||||
try:
|
||||
self._ensure_origin()
|
||||
self.send_response(HTTPStatus.NO_CONTENT)
|
||||
self._cors()
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
self.send_header("Access-Control-Max-Age", "600")
|
||||
self.end_headers()
|
||||
@@ -505,25 +564,61 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
try:
|
||||
self._ensure_request()
|
||||
path = urlsplit(self.path).path
|
||||
parsed = urlsplit(self.path)
|
||||
path = parsed.path
|
||||
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
|
||||
job_id, artifact = self._route(path)
|
||||
if not job_id:
|
||||
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
|
||||
if artifact:
|
||||
file_path = self.manager.artifact(job_id)
|
||||
size = file_path.stat().st_size
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self._cors()
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
self.send_header("Content-Disposition", 'attachment; filename="policy.onnx"')
|
||||
self.send_header("Content-Length", str(size))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
with file_path.open("rb") as source:
|
||||
shutil.copyfileobj(source, self.wfile)
|
||||
self._send_file(self.manager.artifact(job_id), "policy.onnx")
|
||||
else:
|
||||
self._json(HTTPStatus.OK, self.manager.get(job_id))
|
||||
except Exception as error:
|
||||
@@ -532,16 +627,83 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
|
||||
def do_POST(self) -> None:
|
||||
try:
|
||||
self._ensure_request()
|
||||
if urlsplit(self.path).path != "/api/training/jobs":
|
||||
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
|
||||
path = urlsplit(self.path).path
|
||||
if path == "/api/training/jobs":
|
||||
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:
|
||||
self._error(error)
|
||||
|
||||
def do_DELETE(self) -> None:
|
||||
try:
|
||||
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:
|
||||
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
|
||||
self._json(HTTPStatus.ACCEPTED, self.manager.cancel(job_id))
|
||||
@@ -574,6 +736,12 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument(
|
||||
"--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(
|
||||
"--task", action="append", dest="tasks", help="允许前端启动的任务 ID;可重复"
|
||||
)
|
||||
@@ -593,10 +761,18 @@ def main() -> None:
|
||||
token = args.token or secrets.token_urlsafe(24)
|
||||
if len(token) < 16:
|
||||
raise SystemExit("训练服务访问令牌至少需要 16 个字符")
|
||||
lease = GpuLease()
|
||||
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.tuning_manager = tuning_manager
|
||||
TrainingRequestHandler.allowed_origins = tuple(args.allow_origin)
|
||||
TrainingRequestHandler.access_token = token
|
||||
server = ThreadingHTTPServer((args.host, args.port), TrainingRequestHandler)
|
||||
@@ -612,6 +788,7 @@ def main() -> None:
|
||||
except KeyboardInterrupt:
|
||||
print("\n正在停止本地训练服务…")
|
||||
finally:
|
||||
tuning_manager.shutdown()
|
||||
manager.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -64,13 +65,7 @@ out.write_bytes(b'onnx')
|
||||
self.assertTrue((trainer_root / "scripts" / "train.py").is_file())
|
||||
self.assertTrue(
|
||||
(
|
||||
trainer_root
|
||||
/ "src"
|
||||
/ "assets"
|
||||
/ "robots"
|
||||
/ "unitree_go2"
|
||||
/ "xmls"
|
||||
/ "go2.xml"
|
||||
trainer_root / "src" / "assets" / "robots" / "unitree_go2" / "xmls" / "go2.xml"
|
||||
).is_file()
|
||||
)
|
||||
|
||||
@@ -92,6 +87,15 @@ out.write_bytes(b'onnx')
|
||||
)
|
||||
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):
|
||||
handler = object.__new__(TrainingRequestHandler)
|
||||
handler.access_token = "secret-token-1234"
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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__}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -14,7 +14,8 @@ src/
|
||||
├── rl/ ONNX 策略运行时、任务绑定、类型和面板
|
||||
├── simulation/ MuJoCo 会话、物理适配器和仿真控制组件
|
||||
├── telemetry/ 数据源抽象、记录器、导出和数据面板
|
||||
├── training/ 本地训练客户端、类型和面板
|
||||
├── training/ 本地训练/调参客户端、类型、共享连接和面板
|
||||
├── tuning/ 独立 tuning.html 的 Agent dashboard 与 scalar 图表
|
||||
├── viewer/ Three.js 场景、渲染和交互
|
||||
├── stores/ 跨域应用状态
|
||||
└── test/ 全局测试初始化
|
||||
@@ -38,4 +39,6 @@ src/
|
||||
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/`。
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
- 内置平地、坡道、楼梯、可复现随机障碍物及 9 类系统参数化地形,可配置尺寸、摩擦、难度、种子与高度场采样精度
|
||||
- 导入单文件 `.py` 控制器,通过本地 Pyodide 在 `mj_step` 前按仿真时间同步执行
|
||||
- 导入 mjlab 导出的 `policy.onnx`,在浏览器本地执行 Go2-W 平衡/速度策略推理
|
||||
- 从图形界面向本机训练桥接服务发起 mjlab 强化学习训练、查看进度/日志、停止任务并导入训练生成的 ONNX
|
||||
- 从图形界面向本机训练桥接服务发起 mjlab 强化学习训练、查看进度/日志、停止任务并导入训练生成的 ONNX;可在独立 TensorBoard 风格页面运行 DeepSeek 奖励函数自调参
|
||||
- 可配置仿真遥测记录,实时查看速度、机身姿态、位置、驱动力等指标并导出 CSV/JSON
|
||||
- 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 视觉层组成:
|
||||
|
||||
@@ -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 前向坐标系。
|
||||
|
||||
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”。
|
||||
|
||||
@@ -119,7 +119,9 @@ npm run training-server -- \
|
||||
--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)。
|
||||
|
||||
|
||||
+337
-46
@@ -77,6 +77,14 @@ const LARGE_MODEL = `
|
||||
</worldbody>
|
||||
</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 }) => {
|
||||
page.on('console', (message) => console.log(`[browser:${message.type()}] ${message.text()}`));
|
||||
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.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: '显示设置' }).click();
|
||||
const displayDialog = page.getByRole('dialog', { name: '视图显示设置' });
|
||||
await expect(displayDialog).toBeVisible();
|
||||
@@ -154,16 +163,22 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
|
||||
await page.getByRole('button', { name: /FPS .*物理/ }).click();
|
||||
await expect(page.getByRole('dialog', { name: '性能详情' })).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('tab', { name: '控制' }).click();
|
||||
await page.getByRole('button', { name: 'Actuator' }).click();
|
||||
await expect(page.getByText('motor', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('tabpanel', { name: '控制' }).getByText('slide', { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('tab', { name: '模型结构' }).click();
|
||||
await page.getByRole('tab', { name: '控制台' }).click();
|
||||
const tools = page.getByRole('tabpanel', { name: '控制台' });
|
||||
await expect(page.getByRole('dialog', { name: '工作区工具' })).toHaveCount(0);
|
||||
await expect(page.locator('main canvas')).toBeVisible();
|
||||
await tools.getByRole('button', { name: /执行器实时控制/ }).click();
|
||||
await expect(tools.getByText('motor', { exact: true })).toBeVisible();
|
||||
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: '模型结构树' });
|
||||
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('button', { name: '重置关节' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '高级' }).click();
|
||||
@@ -187,10 +202,10 @@ test('窄视口默认保留完整视口并可按需打开侧栏', async ({ page
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('main')).toBeInViewport();
|
||||
await expect(page.getByRole('button', { name: '显示工程面板' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '显示属性面板' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '显示属性面板' }).click();
|
||||
await expect(page.getByRole('button', { name: '显示右侧面板' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '显示右侧面板' }).click();
|
||||
await expect(
|
||||
page.getByRole('complementary').filter({ hasText: '导入模型后显示属性' }),
|
||||
page.getByRole('complementary').filter({ hasText: '导入模型后显示检查器' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -288,7 +303,6 @@ test('加载引用 OBJ 的 URDF 工程', async ({ page }) => {
|
||||
await options.getByRole('button', { name: '转换并加载' }).click();
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText('2 个文件')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'URDF 处理方式' }).click();
|
||||
await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf');
|
||||
await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating');
|
||||
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('已将 640×480 摄像头固连到 arm');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('tab', { name: '控制' }).click();
|
||||
await page.getByRole('button', { name: 'Actuator' }).click();
|
||||
await page.getByRole('tab', { name: '控制台' }).click();
|
||||
await page
|
||||
.getByRole('tabpanel', { name: '控制台' })
|
||||
.getByRole('button', { name: /执行器实时控制/ })
|
||||
.click();
|
||||
await expect(page.getByText('shoulder_motor')).toBeVisible();
|
||||
await expect(page.getByText('关节:shoulder')).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.move(x + 70, y, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await page.getByRole('tab', { name: '控制' }).click();
|
||||
const jointSection = page.getByRole('button', { name: '关节 1' });
|
||||
await page.getByRole('tab', { name: '控制台' }).click();
|
||||
const jointSection = page
|
||||
.getByRole('tabpanel', { name: '控制台' })
|
||||
.getByRole('button', { name: /关节姿态调试/ });
|
||||
if ((await jointSection.getAttribute('aria-expanded')) === 'false') await jointSection.click();
|
||||
const output = page.getByText('screen_x').locator('..').locator('output');
|
||||
await expect
|
||||
@@ -421,7 +440,11 @@ test('可导入并启用 Python 控制器', async ({ page }) => {
|
||||
.first()
|
||||
.setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) });
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '控制' }).click();
|
||||
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`;
|
||||
await page
|
||||
.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('Python / Pyodide')).toBeVisible();
|
||||
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 }) => {
|
||||
@@ -471,16 +496,15 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page })
|
||||
buffer: Buffer.from(SIMPLE_MODEL),
|
||||
});
|
||||
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
|
||||
const library = page.getByLabel('认证资产');
|
||||
await expect(library.getByText('点击添加,或按住资产拖到画布落位。')).toBeVisible();
|
||||
const library = page.getByLabel('地图资产库');
|
||||
await expect(library.getByText('物理几何原语', { exact: true })).toBeVisible();
|
||||
await library.getByRole('button', { name: '添加基础方盒' }).click();
|
||||
await expect(page.getByText('正在加载 MuJoCo 与模型…')).toHaveCount(0);
|
||||
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json', {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText('V3 地图编辑器')).toBeVisible();
|
||||
await expect(page.getByLabel('地图物体检查器')).toBeVisible();
|
||||
await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
|
||||
|
||||
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 page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒').click();
|
||||
await page.getByLabel('对象放置方式').selectOption('locked');
|
||||
await page.getByLabel('贴地检测模式').selectOption('locked');
|
||||
await expect(page.getByLabel('对象位置X')).toBeDisabled();
|
||||
await page.getByLabel('对象放置方式').selectOption('auto_ground');
|
||||
await page.getByLabel('贴地检测模式').selectOption('auto_ground');
|
||||
await expect(page.getByLabel('对象位置X')).toBeEnabled();
|
||||
|
||||
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.up({ button: 'left' });
|
||||
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 }) => {
|
||||
@@ -516,7 +770,6 @@ test('应用内置 MJCF 楼梯物理地图', async ({ page }) => {
|
||||
buffer: Buffer.from(SIMPLE_MODEL),
|
||||
});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption('builtin');
|
||||
await page.getByLabel('物理地图预设').selectOption('stairs');
|
||||
await page.getByLabel('台阶数量').fill('6');
|
||||
@@ -548,7 +801,6 @@ test('依次应用全部系统参数化地形', async ({ page }) => {
|
||||
buffer: Buffer.from(SIMPLE_MODEL),
|
||||
});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption('builtin');
|
||||
for (const [preset, label] of terrains) {
|
||||
await page.getByLabel('物理地图预设').selectOption(preset);
|
||||
@@ -592,7 +844,6 @@ test('导入并应用分层工程地图包', async ({ page }) => {
|
||||
buffer: Buffer.from(project),
|
||||
});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption({ label: '测试场景' });
|
||||
await expect(page.getByLabel('地图出生点')).toHaveValue('start');
|
||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
@@ -627,14 +878,15 @@ test('将受支持的只读物理地图转换为可编辑副本', async ({ page
|
||||
buffer: Buffer.from(project),
|
||||
});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption({ label: '旧版基础场景' });
|
||||
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 editor.getByRole('button', { name: '创建可编辑副本' }).click();
|
||||
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: /wall · 方盒/ })).toBeVisible();
|
||||
});
|
||||
@@ -673,30 +925,56 @@ test('编辑 V3 地图对象并事务式应用', async ({ page }) => {
|
||||
buffer: Buffer.from(project),
|
||||
});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption({ label: '可编辑场景' });
|
||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
await expect(page.getByText('V3 地图编辑器')).toBeVisible({ timeout: 30_000 });
|
||||
const editor = page.getByText('V3 地图编辑器').locator('..');
|
||||
await expect(editor.getByRole('button', { name: '移动工具 W' })).toHaveAttribute(
|
||||
await expect(page.getByText('认证资产与场景对象属性')).toBeVisible({ timeout: 30_000 });
|
||||
const mapTools = page.getByLabel('地图视口工具');
|
||||
await expect(mapTools.getByRole('button', { name: '移动工具 W' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
await page.keyboard.press('e');
|
||||
await expect(editor.getByRole('button', { name: '旋转工具 E' })).toHaveAttribute(
|
||||
await expect(mapTools.getByRole('button', { name: '旋转工具 E' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
await page.keyboard.press('s');
|
||||
await expect(editor.getByRole('button', { name: '缩放工具 S' })).toHaveAttribute(
|
||||
await page.keyboard.press('r');
|
||||
await expect(mapTools.getByRole('button', { name: '缩放工具 R' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
await editor.getByRole('button', { name: '新增', exact: true }).click();
|
||||
await editor.getByLabel('对象位置X').fill('2');
|
||||
await editor.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
await expect(editor.getByRole('button', { name: /box · 方盒/ })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(editor.getByText('地图草稿尚未应用')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '新增', exact: true }).click();
|
||||
await page.getByLabel('对象位置X').fill('2');
|
||||
await page.getByLabel('对象位置X').blur();
|
||||
await page.keyboard.press('f');
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -727,15 +1005,28 @@ test('工程地图编译失败时保留上一仿真会话', async ({ page }) =>
|
||||
buffer: Buffer.from(project),
|
||||
});
|
||||
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.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
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.waitForTimeout(300);
|
||||
await expect(page.locator('footer')).not.toContainText('时间 0.000 s');
|
||||
await expect
|
||||
.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 }) => {
|
||||
|
||||
+1053
-185
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ export function LayoutSettingsDialog({
|
||||
onClick={() => onRightOpen(!rightOpen)}
|
||||
icon={<PanelRight className="h-3.5 w-3.5" />}
|
||||
>
|
||||
属性面板
|
||||
右侧面板
|
||||
</Button>
|
||||
</div>
|
||||
<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 { Database, Info, Map as MapIcon, SlidersHorizontal } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Box, Database, MapPinned, PanelRight, SlidersHorizontal } from 'lucide-react';
|
||||
import type { MapEntry, ModelEntry } from '../../project/types';
|
||||
import type { ActuatorParameters, SimulationSnapshot } from '../../simulation/SimulationSession';
|
||||
import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter';
|
||||
import type { DataRecorderConfig } from '../../telemetry/DataRecorder';
|
||||
import type { ViewerSelection } from '../../viewer/MuJoCoViewer';
|
||||
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
|
||||
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
CollapsibleSection,
|
||||
CopyButton,
|
||||
PropertyRow,
|
||||
Select,
|
||||
Tabs,
|
||||
} from '../../components/ui';
|
||||
import type { MapSelection } from '../../map/types';
|
||||
import type { MapSelection, PlacedMapSelection } from '../../map/types';
|
||||
import type {
|
||||
EditableMapDocument,
|
||||
MapEditorInteractionCallbacks,
|
||||
MapEditorTransformMode,
|
||||
MapEditorSessionState,
|
||||
} 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 { PythonControllerPanel } from '../../controller/PythonControllerPanel';
|
||||
import { RLPolicyPanel } from '../../rl/RLPolicyPanel';
|
||||
import {
|
||||
BodyInspector,
|
||||
InspectorIdentity,
|
||||
JointInspector,
|
||||
ModelSummaryInspector,
|
||||
} from '../../simulation/RobotInspector';
|
||||
import type { EditorSelection } from '../editorSelection';
|
||||
import { SidebarPanel } from './SidebarPanel';
|
||||
import type { WorkspaceTool } from './WorkspaceToolsPanel';
|
||||
import { RightSidebarTabs } from './RightSidebarTabs';
|
||||
|
||||
interface ModelControlsProps {
|
||||
snapshot?: SimulationSnapshot;
|
||||
selection: ViewerSelection | null;
|
||||
selection: EditorSelection | null;
|
||||
viewerSelection: ViewerSelection | null;
|
||||
selectedFormat?: ModelEntry['format'];
|
||||
loading: boolean;
|
||||
visible?: boolean;
|
||||
@@ -42,18 +35,19 @@ interface ModelControlsProps {
|
||||
ignoreJointLimits: boolean;
|
||||
jointAdvanced: boolean;
|
||||
angleUnit: 'rad' | 'deg';
|
||||
forceScale: number;
|
||||
controllerPaths: string[];
|
||||
selectedControllerPath?: string;
|
||||
controllerStatus?: ControllerStatus;
|
||||
policyPaths: string[];
|
||||
selectedPolicyPath?: string;
|
||||
policyStatus?: RLPolicyStatus;
|
||||
mapSelection: MapSelection;
|
||||
activeMapAssetId?: string;
|
||||
activeMapAssetName?: string;
|
||||
mapSceneDirty: boolean;
|
||||
maps: MapEntry[];
|
||||
showVisualMap: boolean;
|
||||
showMapCollision: boolean;
|
||||
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;
|
||||
onBaseMode: (value: UrdfBaseMode) => void;
|
||||
onShowCollision: (value: boolean) => void;
|
||||
@@ -64,304 +58,89 @@ interface ModelControlsProps {
|
||||
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;
|
||||
onApplyMap: (value: MapSelection) => void;
|
||||
onMapDraft: (value: PlacedMapSelection) => void;
|
||||
onEditorPreview: (document: EditableMapDocument | null) => void;
|
||||
onEditorDraftChange: (
|
||||
descriptorPath: string,
|
||||
document: EditableMapDocument,
|
||||
dirty: boolean,
|
||||
) => void;
|
||||
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
|
||||
onEditorExport: () => void;
|
||||
onEditorConvert: () => Promise<boolean>;
|
||||
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
|
||||
onEditorSelect: (id: string | null) => void;
|
||||
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
|
||||
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
|
||||
onEditorSessionStateChange?: (
|
||||
descriptorPath: string,
|
||||
state: MapEditorSessionState | null,
|
||||
) => void;
|
||||
onEditorSurfaceHeight?: (position: readonly [number, number, number]) => number | null;
|
||||
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) {
|
||||
const [tab, setTab] = useState<'properties' | 'controls' | 'data' | 'map'>('properties'),
|
||||
s = props.snapshot;
|
||||
if (!s)
|
||||
const snapshot = props.snapshot;
|
||||
const activeView = props.workspaceTool ?? 'inspector';
|
||||
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 (
|
||||
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
|
||||
<div className="p-4 text-sm text-text-tertiary">导入模型后显示属性</div>
|
||||
<SidebarPanel title={title} side="right" visible={props.visible} icon={icon}>
|
||||
{tabs}
|
||||
{props.workspaceTool ? (
|
||||
props.workspaceTools
|
||||
) : (
|
||||
<div className="p-4 text-sm text-text-tertiary">导入模型后显示检查器</div>
|
||||
)}
|
||||
</SidebarPanel>
|
||||
);
|
||||
const properties = (
|
||||
<>
|
||||
<CollapsibleSection title="模型信息" defaultOpen badge={<Badge>{s.model.nbody} Body</Badge>}>
|
||||
<div>
|
||||
<PropertyRow label="Body" value={s.model.nbody} />
|
||||
<PropertyRow label="Joint" value={s.model.njnt} />
|
||||
<PropertyRow label="Geom" value={s.model.ngeom} />
|
||||
<PropertyRow label="Actuator" value={s.model.nactuator} />
|
||||
<PropertyRow label="qpos / qvel" value={`${s.model.nq} / ${s.model.nv}`} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
{props.selectedFormat === 'urdf' && (
|
||||
<CollapsibleSection title="URDF 处理方式" defaultOpen={false}>
|
||||
<Select
|
||||
aria-label="URDF 处理方式"
|
||||
className="w-full"
|
||||
value={props.urdfMode}
|
||||
disabled={props.loading}
|
||||
onChange={(event) => props.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={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: (
|
||||
|
||||
const selection = props.selection;
|
||||
const selectedBody =
|
||||
selection?.kind === 'body'
|
||||
? snapshot.bodies.find((body) => body.id === selection.bodyId)
|
||||
: undefined;
|
||||
const selectedJoint =
|
||||
selection?.kind === 'joint'
|
||||
? snapshot.joints.find((joint) => joint.id === selection.jointId)
|
||||
: undefined;
|
||||
const mapSelected =
|
||||
(selection?.kind === 'map' || selection?.kind === 'map-object') &&
|
||||
selection.mapAssetId === props.activeMapAssetId;
|
||||
const selectedMapObject =
|
||||
selection?.kind === 'map-object' && mapSelected
|
||||
? (props.editorDraftDocument ?? props.editorDocument)?.objects.find(
|
||||
(object) => object.id === selection.objectId,
|
||||
)
|
||||
: undefined;
|
||||
const mapPanel = (
|
||||
<PhysicalMapPanel
|
||||
key={JSON.stringify(props.mapSelection)}
|
||||
key={`${props.activeMapAssetId ?? 'none'}:${
|
||||
props.mapSelection.kind === 'project'
|
||||
? props.mapSelection.descriptorPath
|
||||
: props.mapSelection.kind
|
||||
}`}
|
||||
value={props.mapSelection}
|
||||
maps={props.maps}
|
||||
rootBodies={s.bodies
|
||||
rootBodies={snapshot.bodies
|
||||
.filter(
|
||||
(body) =>
|
||||
body.id !== 0 &&
|
||||
body.parentId === 0 &&
|
||||
!body.name.startsWith('__platform_map_'),
|
||||
body.id !== 0 && body.parentId === 0 && !body.name.startsWith('__platform_map_'),
|
||||
)
|
||||
.map((body) => body.name)}
|
||||
loading={props.loading}
|
||||
@@ -369,21 +148,106 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
|
||||
showVisualMap={props.showVisualMap}
|
||||
showMapCollision={props.showMapCollision}
|
||||
editorDocument={props.editorDocument}
|
||||
editorDraftDocument={props.editorDraftDocument}
|
||||
sceneDirty={props.mapSceneDirty}
|
||||
onEditorPreview={props.onEditorPreview}
|
||||
onEditorDraftChange={props.onEditorDraftChange}
|
||||
onEditorApply={props.onEditorApply}
|
||||
onEditorExport={props.onEditorExport}
|
||||
onEditorConvert={props.onEditorConvert}
|
||||
selectedEditorObjectId={
|
||||
selection?.kind === 'map-object' && mapSelected ? selection.objectId : undefined
|
||||
}
|
||||
onEditorBindInteraction={props.onEditorBindInteraction}
|
||||
onEditorSelect={props.onEditorSelect}
|
||||
onEditorTransformMode={props.onEditorTransformMode}
|
||||
onEditorSnapping={props.onEditorSnapping}
|
||||
onEditorSessionStateChange={props.onEditorSessionStateChange}
|
||||
onEditorSurfaceHeight={props.onEditorSurfaceHeight}
|
||||
onMapDisplay={props.onMapDisplay}
|
||||
onDraft={props.onMapDraft}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, FolderTree, Map as MapIcon } from 'lucide-react';
|
||||
import type { ModelEntry } from '../../project/types';
|
||||
import { FolderTree, Library, Search } from 'lucide-react';
|
||||
import type { MapEntry, ModelEntry } from '../../project/types';
|
||||
import {
|
||||
countProjectSearchResults,
|
||||
ProjectTree,
|
||||
type ProjectTreeFile,
|
||||
} from '../../project/ProjectTree';
|
||||
import {
|
||||
countModelStructureSearchResults,
|
||||
ModelStructureTree,
|
||||
} from '../../project/ModelStructureTree';
|
||||
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
|
||||
import { Button, Tabs } from '../../components/ui';
|
||||
import { Button, Tabs, VerticalSplitPane } from '../../components/ui';
|
||||
import {
|
||||
DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||
type MapSelection,
|
||||
type PlacedMapAsset,
|
||||
type SystemTerrainPreset,
|
||||
} from '../../map/types';
|
||||
import type {
|
||||
@@ -23,28 +20,46 @@ import type {
|
||||
MapObjectPlacementMode,
|
||||
} from '../../map/editor/types';
|
||||
import { MapAssetLibrary } from '../../map/MapAssetLibrary';
|
||||
import type { EditorSelection } from '../editorSelection';
|
||||
import { ProjectBreadcrumb } from './ProjectBreadcrumb';
|
||||
import { SceneOutliner, countSceneSearchResults } from './SceneOutliner';
|
||||
import { SidebarPanel } from './SidebarPanel';
|
||||
import { TreeSearchField } from './TreeSearchField';
|
||||
|
||||
export type ProjectResourceTab = 'assets' | 'files';
|
||||
|
||||
export function ProjectSidebar({
|
||||
projectName,
|
||||
files,
|
||||
entries,
|
||||
selectedEntry,
|
||||
snapshot,
|
||||
selection,
|
||||
loading,
|
||||
visible = true,
|
||||
nativeUrdf,
|
||||
mapSelection,
|
||||
editorDocument,
|
||||
maps,
|
||||
placedMaps,
|
||||
pendingSceneChangeCount,
|
||||
pendingSceneIds,
|
||||
editorDocuments,
|
||||
assetPlacementMode,
|
||||
activeTab,
|
||||
onActiveTabChange,
|
||||
onRemove,
|
||||
onSelectEntry,
|
||||
onSelectBody,
|
||||
onSelectJoint,
|
||||
onJointHover,
|
||||
onAddMapAsset,
|
||||
onAddProjectMap,
|
||||
onSelectTerrain,
|
||||
onAssetPlacementModeChange,
|
||||
onApplyScene,
|
||||
onDiscardScene,
|
||||
onSelectMap,
|
||||
onRemoveMap,
|
||||
onSelectMapObject,
|
||||
}: {
|
||||
projectName?: string;
|
||||
@@ -52,33 +67,46 @@ export function ProjectSidebar({
|
||||
entries: ModelEntry[];
|
||||
selectedEntry?: string;
|
||||
snapshot?: SimulationSnapshot;
|
||||
selection: EditorSelection | null;
|
||||
loading: boolean;
|
||||
visible?: boolean;
|
||||
nativeUrdf: boolean;
|
||||
mapSelection: MapSelection;
|
||||
editorDocument: EditableMapDocument | null;
|
||||
activeTab?: 'project' | 'structure' | 'assets';
|
||||
onActiveTabChange?: (value: 'project' | 'structure' | 'assets') => void;
|
||||
maps: MapEntry[];
|
||||
placedMaps: PlacedMapAsset[];
|
||||
pendingSceneChangeCount: number;
|
||||
pendingSceneIds: string[];
|
||||
editorDocuments: ReadonlyMap<string, EditableMapDocument>;
|
||||
assetPlacementMode?: MapObjectPlacementMode;
|
||||
activeTab?: ProjectResourceTab;
|
||||
onActiveTabChange?: (value: ProjectResourceTab) => void;
|
||||
onRemove: () => void;
|
||||
onSelectEntry: (path: string) => void;
|
||||
onSelectBody: (bodyId: number) => void;
|
||||
onSelectJoint: (jointId: number, bodyId: number) => void;
|
||||
onJointHover: (jointId: number | null) => void;
|
||||
onAddMapAsset: (
|
||||
type: EditableMapObjectType,
|
||||
placementMode: MapObjectPlacementMode,
|
||||
) => void | Promise<void>;
|
||||
onAddProjectMap: (descriptorPath: string) => 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'),
|
||||
[fileQuery, setFileQuery] = useState(''),
|
||||
[structureQuery, setStructureQuery] = useState('');
|
||||
const tab = activeTab ?? internalTab,
|
||||
fileMatches = countProjectSearchResults(files, fileQuery),
|
||||
structureMatches = snapshot
|
||||
? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery)
|
||||
: 0;
|
||||
const [internalTab, setInternalTab] = useState<ProjectResourceTab>('assets');
|
||||
const [fileQuery, setFileQuery] = useState('');
|
||||
const [sceneQuery, setSceneQuery] = useState('');
|
||||
const tab = activeTab ?? internalTab;
|
||||
const fileMatches = countProjectSearchResults(files, fileQuery);
|
||||
const sceneMatches = countSceneSearchResults(snapshot, placedMaps, editorDocuments, sceneQuery);
|
||||
|
||||
return (
|
||||
<SidebarPanel title="资产与结构" side="left" visible={visible}>
|
||||
<SidebarPanel title="场景大纲与资产中心" side="left" visible={visible} icon={<Library />}>
|
||||
{projectName ? (
|
||||
<>
|
||||
<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}>
|
||||
{projectName}
|
||||
</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>
|
||||
<Button variant="danger" onClick={onRemove} disabled={loading}>
|
||||
移除
|
||||
</Button>
|
||||
</div>
|
||||
<ProjectBreadcrumb
|
||||
projectName={projectName}
|
||||
entries={entries}
|
||||
selectedEntry={selectedEntry}
|
||||
loading={loading}
|
||||
onSelect={onSelectEntry}
|
||||
|
||||
<VerticalSplitPane
|
||||
storageKey="mujoco-project-sidebar-scene-ratio"
|
||||
separatorLabel="调整场景大纲与资源面板高度"
|
||||
defaultRatio={0.42}
|
||||
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
|
||||
label="工程侧栏"
|
||||
label="资产与工程文件"
|
||||
value={tab}
|
||||
onValueChange={(value) => {
|
||||
setInternalTab(value);
|
||||
@@ -108,11 +177,40 @@ export function ProjectSidebar({
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
value: 'project',
|
||||
label: '工程',
|
||||
value: 'assets',
|
||||
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" />,
|
||||
content: (
|
||||
<>
|
||||
<ProjectBreadcrumb
|
||||
projectName={projectName}
|
||||
entries={entries}
|
||||
selectedEntry={selectedEntry}
|
||||
loading={loading}
|
||||
onSelect={onSelectEntry}
|
||||
/>
|
||||
<TreeSearchField
|
||||
value={fileQuery}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
const shortcuts = [
|
||||
['Space', '播放 / 暂停'],
|
||||
['R', '重置仿真'],
|
||||
['R', '重置仿真(非地图编辑)'],
|
||||
['1', '选择模式'],
|
||||
['2', '关节拖动'],
|
||||
['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 }) {
|
||||
return (
|
||||
@@ -29,7 +37,8 @@ export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose:
|
||||
<li>左键拖动:旋转相机或执行当前交互工具</li>
|
||||
<li>右键拖动:平移相机</li>
|
||||
<li>滚轮:缩放视口</li>
|
||||
<li>选择物体后可在右侧“属性”中查看信息</li>
|
||||
<li>选择物体后可在右侧 Inspector 中编辑尺寸、位姿、材质和摩擦</li>
|
||||
<li>从左侧资产库拖动几何障碍物到视口地面即可放置</li>
|
||||
</ul>
|
||||
</section>
|
||||
</Dialog>
|
||||
|
||||
@@ -7,20 +7,22 @@ export function SidebarPanel({
|
||||
side,
|
||||
children,
|
||||
visible = true,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
side: 'left' | 'right';
|
||||
children: ReactNode;
|
||||
visible?: boolean;
|
||||
icon?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}>
|
||||
<aside
|
||||
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">
|
||||
<span className="grid h-6 w-6 place-items-center rounded-md bg-accent-soft text-accent">
|
||||
<Settings2 aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
<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 [&>svg]:h-3.5 [&>svg]:w-3.5">
|
||||
{icon ?? <Settings2 aria-hidden="true" className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
{title}
|
||||
</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 { ViewerDisplayOptions } from '../../viewer/displayOptions';
|
||||
import { IconButton, ToolbarToggleGroup, type ToolbarItem } from '../../components/ui';
|
||||
@@ -11,12 +11,18 @@ const tools: ToolbarItem<InteractionMode>[] = [
|
||||
export function ViewerToolDock({
|
||||
mode,
|
||||
display,
|
||||
mapEditContext,
|
||||
onModeChange,
|
||||
onDisplayChange,
|
||||
onResetCamera,
|
||||
}: {
|
||||
mode: InteractionMode;
|
||||
display: ViewerDisplayOptions;
|
||||
mapEditContext?: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
onActivate: () => void;
|
||||
};
|
||||
onModeChange: (mode: InteractionMode) => void;
|
||||
onDisplayChange: (next: ViewerDisplayOptions) => void;
|
||||
onResetCamera: () => void;
|
||||
@@ -24,6 +30,18 @@ export function ViewerToolDock({
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<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} />
|
||||
<IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -16,11 +16,13 @@ export function ViewportHUD({
|
||||
mode,
|
||||
selection,
|
||||
ready,
|
||||
mapEditing = false,
|
||||
}: {
|
||||
paused: boolean;
|
||||
mode: InteractionMode;
|
||||
selection: ViewerSelection | null;
|
||||
ready: boolean;
|
||||
mapEditing?: boolean;
|
||||
}) {
|
||||
if (!ready) return null;
|
||||
return (
|
||||
@@ -45,7 +47,7 @@ export function ViewportHUD({
|
||||
</div>
|
||||
<div
|
||||
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" />
|
||||
<span>{primaryGestures[mode]}</span>
|
||||
@@ -57,7 +59,20 @@ export function ViewportHUD({
|
||||
·
|
||||
</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">
|
||||
·
|
||||
@@ -65,6 +80,7 @@ export function ViewportHUD({
|
||||
<Kbd>1</Kbd>
|
||||
<span>旋转视角</span>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -75,7 +75,7 @@ export function WorkbenchHeader({
|
||||
onToggleFullscreen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel/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="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">
|
||||
@@ -170,8 +170,8 @@ export function WorkbenchHeader({
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
tooltip={rightOpen ? '隐藏属性面板' : '显示属性面板'}
|
||||
aria-label={rightOpen ? '隐藏属性面板' : '显示属性面板'}
|
||||
tooltip={rightOpen ? '隐藏右侧面板' : '显示右侧面板'}
|
||||
aria-label={rightOpen ? '隐藏右侧面板' : '显示右侧面板'}
|
||||
aria-expanded={rightOpen}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export function Badge({
|
||||
return (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function Button({
|
||||
return (
|
||||
<button
|
||||
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}
|
||||
>
|
||||
{icon && (
|
||||
|
||||
@@ -16,7 +16,7 @@ export function IconButton({
|
||||
<button
|
||||
type={type}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -106,7 +106,7 @@ export function ResizablePanel({
|
||||
<button
|
||||
type="button"
|
||||
role="separator"
|
||||
aria-label={side === 'left' ? '调整工程面板宽度' : '调整属性面板宽度'}
|
||||
aria-label={side === 'left' ? '调整工程面板宽度' : '调整右侧面板宽度'}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={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>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export * from './Separator';
|
||||
export * from './Skeleton';
|
||||
export * from './Tabs';
|
||||
export * from './ResizablePanel';
|
||||
export * from './VerticalSplitPane';
|
||||
export * from './Kbd';
|
||||
export * from './PropertyRow';
|
||||
export * from './CopyButton';
|
||||
@@ -22,3 +23,4 @@ export * from './DropdownMenu';
|
||||
export * from './ProgressBar';
|
||||
export * from './LiveRegion';
|
||||
export * from './SearchableCombobox';
|
||||
export * from './ScrubbableNumberInput';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
const renderLibrary = (
|
||||
@@ -16,7 +16,53 @@ const renderLibrary = (
|
||||
);
|
||||
|
||||
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();
|
||||
renderLibrary(onAdd);
|
||||
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
|
||||
@@ -26,7 +72,26 @@ describe('MapAssetLibrary', () => {
|
||||
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 dataTransfer = {
|
||||
effectAllowed: 'none',
|
||||
@@ -37,15 +102,28 @@ describe('MapAssetLibrary', () => {
|
||||
target: { value: 'locked' },
|
||||
});
|
||||
fireEvent.dragStart(document.querySelector('[data-map-asset="ramp"]')!, { dataTransfer });
|
||||
expect(values.get(MAP_ASSET_DRAG_MIME)).toBe('ramp');
|
||||
expect(values.get(MAP_ASSET_PLACEMENT_MIME)).toBe('locked');
|
||||
expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({
|
||||
kind: 'certified',
|
||||
type: 'ramp',
|
||||
placementMode: 'locked',
|
||||
});
|
||||
});
|
||||
|
||||
it('以示意图卡片选择系统参数化地形', () => {
|
||||
it('以示意图卡片添加或拖动预设地形', () => {
|
||||
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);
|
||||
expect(screen.getAllByText('8.00 × 8.00 m')).toHaveLength(9);
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加随机粗糙地形' }));
|
||||
fireEvent.dragStart(document.querySelector('[data-system-terrain="wave"]')!, { dataTransfer });
|
||||
expect(onSelectTerrain).toHaveBeenCalledWith('rough');
|
||||
expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({
|
||||
kind: 'terrain',
|
||||
preset: 'wave',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
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 {
|
||||
CERTIFIED_MAP_ASSETS,
|
||||
MAP_ASSET_DRAG_MIME,
|
||||
MAP_ASSET_PLACEMENT_MIME,
|
||||
encodeMapLibraryDragPayload,
|
||||
MAP_LIBRARY_DRAG_MIME,
|
||||
} from './editor/assetCatalog';
|
||||
import {
|
||||
MAP_OBJECT_PLACEMENT_LABELS,
|
||||
type EditableMapDocument,
|
||||
type EditableMapObjectType,
|
||||
type MapObjectPlacementMode,
|
||||
} from './editor/types';
|
||||
import type { MapEntry } from '../project/types';
|
||||
import {
|
||||
PHYSICAL_MAP_PRESET_LABELS,
|
||||
SYSTEM_TERRAIN_PRESETS,
|
||||
@@ -135,58 +135,94 @@ function AddAction({ label }: { label: string }) {
|
||||
export function MapAssetLibrary({
|
||||
disabled,
|
||||
terrainSize,
|
||||
document,
|
||||
maps = [],
|
||||
placementMode: controlledPlacementMode,
|
||||
onPlacementModeChange,
|
||||
onAdd,
|
||||
onAddProjectMap,
|
||||
onSelectTerrain,
|
||||
onSelectObject,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
terrainSize: number;
|
||||
document?: EditableMapDocument | null;
|
||||
maps?: MapEntry[];
|
||||
placementMode?: MapObjectPlacementMode;
|
||||
onPlacementModeChange?: (mode: MapObjectPlacementMode) => void;
|
||||
onAdd: (
|
||||
type: EditableMapObjectType,
|
||||
placementMode: MapObjectPlacementMode,
|
||||
) => void | Promise<void>;
|
||||
onAddProjectMap?: (descriptorPath: string) => 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 (
|
||||
<section aria-label="认证资产" className="space-y-3 p-3">
|
||||
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
|
||||
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
|
||||
<span>场景结构</span>
|
||||
<span className="technical-value text-[10px] text-text-tertiary">
|
||||
{document?.objects.length ?? 0} 个对象
|
||||
</span>
|
||||
<section aria-label="地图资产库" className="space-y-3 p-3">
|
||||
<header>
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-text-primary">
|
||||
<MapPinned className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
|
||||
场景资产库
|
||||
</div>
|
||||
{document?.objects.length ? (
|
||||
<div className="mt-2 max-h-36 space-y-0.5 overflow-auto panel-scroll">
|
||||
{document.objects.map((object) => (
|
||||
<button
|
||||
key={object.id}
|
||||
type="button"
|
||||
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"
|
||||
onClick={() => onSelectObject?.(object.id)}
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||
从地图包、物理几何原语和预设地形拖放到视口;已放置实例统一在上方场景大纲管理。
|
||||
</p>
|
||||
</header>
|
||||
{maps.length > 0 && (
|
||||
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
|
||||
<div className="text-xs font-medium text-text-primary">地图包</div>
|
||||
<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" />
|
||||
<span className="min-w-0 flex-1 truncate">{object.name}</span>
|
||||
<span className="text-[9px] text-text-tertiary">{object.type}</span>
|
||||
</button>
|
||||
<MapPinned className="h-3.5 w-3.5 shrink-0 text-accent" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-text-secondary">
|
||||
{map.name}
|
||||
</span>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
aria-label={`放置工程地图 ${map.name}`}
|
||||
onClick={() => onAddProjectMap?.(map.descriptorPath)}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
|
||||
选择工程地图后,这里显示场景对象层级;可从下方资产库创建场景。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<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" />
|
||||
认证资产
|
||||
物理几何原语
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||
点击添加,或按住资产拖到画布落位。
|
||||
@@ -199,7 +235,7 @@ export function MapAssetLibrary({
|
||||
className="mt-1 w-full"
|
||||
value={placementMode}
|
||||
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) => (
|
||||
<option key={mode} value={mode}>
|
||||
@@ -222,8 +258,14 @@ export function MapAssetLibrary({
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData(MAP_ASSET_DRAG_MIME, asset.type);
|
||||
event.dataTransfer.setData(MAP_ASSET_PLACEMENT_MIME, placementMode);
|
||||
event.dataTransfer.setData(
|
||||
MAP_LIBRARY_DRAG_MIME,
|
||||
encodeMapLibraryDragPayload({
|
||||
kind: 'certified',
|
||||
type: asset.type,
|
||||
placementMode,
|
||||
}),
|
||||
);
|
||||
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="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" />
|
||||
系统参数化地形
|
||||
预设地形
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||
本地确定性生成;点击“添加”后在右侧“地图”调整尺寸、难度和随机种子。
|
||||
点击添加或拖到画布定位。新增先进入轻量预览,可连续放置后一次编译。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SYSTEM_TERRAIN_PRESETS.map((preset) => (
|
||||
<article
|
||||
key={preset}
|
||||
draggable={!disabled}
|
||||
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">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
|
||||
@@ -87,6 +87,39 @@ describe('composeProjectMap', () => {
|
||||
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('地图明确提供地面时替换平台基础地面', () => {
|
||||
const floorMap = manifest(
|
||||
'<mujoco><worldbody><geom name="floor" type="box" pos="0 0 -.1" size="5 5 .1"/></worldbody></mujoco>',
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { ProjectManifest } from '../project/types';
|
||||
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 encoder = new TextEncoder();
|
||||
@@ -23,6 +29,13 @@ export interface ProjectMapComposition {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface ProjectMapCompositionOptions {
|
||||
/** 场景实例命名空间;允许同一 map.json 被重复放置。 */
|
||||
instanceId?: string;
|
||||
/** 多地图场景中只允许一个实例移动机器人到出生点。 */
|
||||
applySpawnPoint?: boolean;
|
||||
}
|
||||
|
||||
function xml(data: Uint8Array, label: string): Document {
|
||||
const document = new DOMParser().parseFromString(decoder.decode(data), 'application/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(
|
||||
document: Document,
|
||||
spawn: SpawnPoint | undefined,
|
||||
@@ -145,6 +171,22 @@ function mapProvidesGround(document: Document): boolean {
|
||||
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 {
|
||||
const prefix = `${MAP_PREFIX}${mapId.replace(/[^a-zA-Z0-9_-]/g, '_')}_`;
|
||||
const names = new Map<string, string>();
|
||||
@@ -203,19 +245,22 @@ export function composeProjectMap(
|
||||
manifest: ProjectManifest,
|
||||
resolvedMap: ResolvedProjectMap,
|
||||
selection: Extract<MapSelection, { kind: 'project' }>,
|
||||
options: ProjectMapCompositionOptions = {},
|
||||
): ProjectMapComposition {
|
||||
const robot = xml(robotSource, '机器人 MJCF');
|
||||
const robotWorldbody = robot.querySelector('mujoco > worldbody');
|
||||
if (!robotWorldbody) throw new Error('机器人 MJCF 缺少 worldbody');
|
||||
const warnings: string[] = [];
|
||||
const spawn = selection.spawnPointId
|
||||
const shouldApplySpawn = options.applySpawnPoint ?? true;
|
||||
const spawn =
|
||||
shouldApplySpawn && selection.spawnPointId
|
||||
? resolvedMap.definition.spawnPoints.find(
|
||||
(candidate) => candidate.id === selection.spawnPointId,
|
||||
)
|
||||
: undefined;
|
||||
if (selection.spawnPointId && !spawn)
|
||||
if (shouldApplySpawn && selection.spawnPointId && !spawn)
|
||||
throw new Error(`地图中不存在出生点:${selection.spawnPointId}`);
|
||||
applySpawnPoint(robot, spawn, selection.robotRootBody, warnings);
|
||||
applySpawnPoint(robot, transformSpawnPoint(spawn, selection), selection.robotRootBody, warnings);
|
||||
|
||||
let geomCount = 0;
|
||||
if (resolvedMap.physicsPath) {
|
||||
@@ -225,7 +270,13 @@ export function composeProjectMap(
|
||||
validateMapStructure(map);
|
||||
const replacesGround = mapProvidesGround(map);
|
||||
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) {
|
||||
const friction = Math.min(5, Math.max(0.05, selection.frictionOverride));
|
||||
for (const geom of Array.from(map.querySelectorAll('worldbody geom')))
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { EditableMapDocument } from './editor/types';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { EditableMapDocument, MapEditorInteractionCallbacks } from './editor/types';
|
||||
import { MapEditorPanel } from './MapEditorPanel';
|
||||
|
||||
const interactionProps = {
|
||||
descriptorPath: 'maps/map/map.json',
|
||||
onDraftChange: () => {},
|
||||
onConvert: async () => true,
|
||||
canConvert: true,
|
||||
onBindInteraction: () => {},
|
||||
onSelectPreview: () => {},
|
||||
onTransformMode: () => {},
|
||||
onSnapping: () => {},
|
||||
onSessionStateChange: () => {},
|
||||
};
|
||||
const document: EditableMapDocument = {
|
||||
schemaVersion: 1,
|
||||
@@ -34,34 +35,139 @@ describe('MapEditorPanel', () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增' }));
|
||||
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: '应用并重新编译' }));
|
||||
await waitFor(() => expect(apply).toHaveBeenCalled());
|
||||
expect(preview).not.toHaveBeenLastCalledWith(null);
|
||||
expect(apply.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
revision: 1,
|
||||
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(
|
||||
<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: '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('对象绕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 () => {
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
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 {
|
||||
editableObjectGroundHeight,
|
||||
MAP_OBJECT_PLACEMENT_LABELS,
|
||||
type EditableMapDocument,
|
||||
type EditableMapObject,
|
||||
type EditableMapObjectType,
|
||||
type MapEditorInteractionCallbacks,
|
||||
type MapEditorTransformMode,
|
||||
type MapObjectPlacementMode,
|
||||
import type {
|
||||
EditableMapDocument,
|
||||
EditableMapObject,
|
||||
EditableMapObjectType,
|
||||
MapEditorInteractionCallbacks,
|
||||
MapEditorSessionState,
|
||||
} from './editor/types';
|
||||
import { MapObjectInspector } from './MapObjectInspector';
|
||||
|
||||
const labels: Record<EditableMapObjectType, string> = {
|
||||
box: '方盒',
|
||||
@@ -19,10 +17,6 @@ const labels: Record<EditableMapObjectType, string> = {
|
||||
ramp: '坡道',
|
||||
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(
|
||||
object: EditableMapObject,
|
||||
scale: [number, number, number],
|
||||
@@ -55,80 +49,178 @@ function scaledParameters(
|
||||
}
|
||||
export function MapEditorPanel({
|
||||
document,
|
||||
draftDocument,
|
||||
descriptorPath,
|
||||
loading,
|
||||
onPreview,
|
||||
onDraftChange,
|
||||
onApply,
|
||||
onExport,
|
||||
onConvert,
|
||||
canConvert,
|
||||
selectedObjectId,
|
||||
onBindInteraction,
|
||||
onSelectPreview,
|
||||
onTransformMode,
|
||||
onSnapping,
|
||||
onSessionStateChange,
|
||||
onSurfaceHeight,
|
||||
}: {
|
||||
document: EditableMapDocument | null;
|
||||
draftDocument?: EditableMapDocument;
|
||||
descriptorPath: string;
|
||||
loading: boolean;
|
||||
onPreview: (document: EditableMapDocument | null) => void;
|
||||
onDraftChange: (descriptorPath: string, document: EditableMapDocument, dirty: boolean) => void;
|
||||
onApply: (document: EditableMapDocument) => Promise<boolean>;
|
||||
onExport: () => void;
|
||||
onConvert: () => Promise<boolean>;
|
||||
canConvert: boolean;
|
||||
selectedObjectId?: string;
|
||||
onBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
|
||||
onSelectPreview: (id: string | null) => void;
|
||||
onTransformMode: (mode: MapEditorTransformMode) => void;
|
||||
onSnapping: (translation: number | null, rotationDegrees: number | null) => void;
|
||||
onSessionStateChange?: (descriptorPath: string, state: MapEditorSessionState | 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),
|
||||
[selected, setSelected] = useState<string>(),
|
||||
[selected, setSelected] = useState<string | undefined>(selectedObjectId),
|
||||
[selectedType, setSelectedType] = useState<EditableMapObjectType>('box'),
|
||||
[transformMode, setTransformMode] = useState<MapEditorTransformMode>('translate'),
|
||||
[snapping, setSnapping] = useState(true),
|
||||
[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 next = session?.document ?? null;
|
||||
render((value) => value + 1);
|
||||
onPreview(session?.document ?? null);
|
||||
onPreview(next);
|
||||
if (session && next) onDraftChange(descriptorPath, next, session.dirty);
|
||||
reportSessionState();
|
||||
};
|
||||
useEffect(() => {
|
||||
onPreview(session?.document ?? null);
|
||||
return () => onPreview(null);
|
||||
}, [session, onPreview]);
|
||||
const next = session?.document ?? null;
|
||||
onPreview(next);
|
||||
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(() => {
|
||||
if (!session) {
|
||||
onBindInteraction(null);
|
||||
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 = {
|
||||
onSelect: (id) => setSelected(id ?? undefined),
|
||||
onSelect: (id) => {
|
||||
session.selectedId = id ?? undefined;
|
||||
setSelected(id ?? undefined);
|
||||
},
|
||||
onTransform: ({ id, position, quaternion, scale }) => {
|
||||
if (loading) return;
|
||||
const object = session.document.objects.find((item) => item.id === id);
|
||||
if (!object) return;
|
||||
session.update(id, {
|
||||
pose: { position, quaternion },
|
||||
parameters: scaledParameters(object, scale),
|
||||
});
|
||||
session.selectedId = id;
|
||||
setSelected(id);
|
||||
render((value) => value + 1);
|
||||
onPreview(session.document);
|
||||
publish();
|
||||
},
|
||||
onAddAsset: (type, position, placementMode) => {
|
||||
const object = session.addAsset(type, position, placementMode);
|
||||
onAddAsset: (type, position, placementMode, externalSupportTop) => {
|
||||
if (loading) return;
|
||||
const object = session.addAsset(type, position, placementMode, externalSupportTop);
|
||||
setSelected(object.id);
|
||||
render((value) => value + 1);
|
||||
onPreview(session.document);
|
||||
publish();
|
||||
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);
|
||||
return () => onBindInteraction(null);
|
||||
}, [session, onBindInteraction, onPreview, onSelectPreview]);
|
||||
}, [
|
||||
session,
|
||||
descriptorPath,
|
||||
loading,
|
||||
onBindInteraction,
|
||||
onPreview,
|
||||
onDraftChange,
|
||||
onSelectPreview,
|
||||
onSessionStateChange,
|
||||
]);
|
||||
useEffect(() => {
|
||||
onTransformMode(transformMode);
|
||||
onSnapping(snapping ? 0.1 : null, snapping ? 5 : null);
|
||||
}, [transformMode, snapping, onTransformMode, onSnapping]);
|
||||
if (session && selectedId) onSelectPreview(selectedId);
|
||||
}, [session, selectedId, onSelectPreview]);
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
if (loading) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.matches('input, textarea, select') || target?.isContentEditable || event.altKey)
|
||||
return;
|
||||
@@ -137,35 +229,48 @@ export function MapEditorPanel({
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) session.redo();
|
||||
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);
|
||||
onSelectPreview(null);
|
||||
}
|
||||
render((value) => value + 1);
|
||||
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') {
|
||||
event.preventDefault();
|
||||
session.redo();
|
||||
render((value) => value + 1);
|
||||
onPreview(session.document);
|
||||
} else if (!event.ctrlKey && !event.metaKey && key === 'w') setTransformMode('translate');
|
||||
else if (!event.ctrlKey && !event.metaKey && key === 'e') setTransformMode('rotate');
|
||||
else if (!event.ctrlKey && !event.metaKey && key === 's') setTransformMode('scale');
|
||||
else if (event.key === 'Escape') {
|
||||
onDraftChange(descriptorPath, session.document, session.dirty);
|
||||
onSessionStateChange?.(descriptorPath, {
|
||||
dirty: session.dirty,
|
||||
changeCount: session.changeCount,
|
||||
canUndo: session.canUndo,
|
||||
canRedo: session.canRedo,
|
||||
});
|
||||
} else if (event.key === 'Escape') {
|
||||
setSelected(undefined);
|
||||
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);
|
||||
return () => window.removeEventListener('keydown', keydown);
|
||||
}, [session, selected, onPreview, onSelectPreview]);
|
||||
}, [
|
||||
session,
|
||||
descriptorPath,
|
||||
selectedId,
|
||||
loading,
|
||||
onPreview,
|
||||
onDraftChange,
|
||||
onSelectPreview,
|
||||
onSessionStateChange,
|
||||
]);
|
||||
if (!session)
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
const current = session.document;
|
||||
const active = current.objects.find((object) => object.id === selected);
|
||||
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 active = current.objects.find((object) => object.id === selectedId);
|
||||
const updateSpawnPosition = (
|
||||
id: string,
|
||||
position: [number, number, number],
|
||||
index: number,
|
||||
value: number,
|
||||
) => {
|
||||
if (!Number.isFinite(value)) return;
|
||||
if (loading || !Number.isFinite(value)) return;
|
||||
const next = [...position] as [number, number, number];
|
||||
next[index] = value;
|
||||
session.updateSpawn(id, { position: next });
|
||||
@@ -224,44 +314,32 @@ export function MapEditorPanel({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2 rounded border border-border-subtle p-2">
|
||||
<Button
|
||||
variant={transformMode === 'translate' ? 'primary' : 'secondary'}
|
||||
aria-pressed={transformMode === 'translate'}
|
||||
disabled={loading}
|
||||
onClick={() => setTransformMode('translate')}
|
||||
>
|
||||
移动工具 W
|
||||
</Button>
|
||||
<Button
|
||||
variant={transformMode === 'rotate' ? 'primary' : 'secondary'}
|
||||
aria-pressed={transformMode === 'rotate'}
|
||||
disabled={loading}
|
||||
onClick={() => setTransformMode('rotate')}
|
||||
>
|
||||
旋转工具 E
|
||||
</Button>
|
||||
<Button
|
||||
variant={transformMode === 'scale' ? 'primary' : 'secondary'}
|
||||
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)}
|
||||
{active && (
|
||||
<MapObjectInspector
|
||||
object={active}
|
||||
loading={loading}
|
||||
onUpdate={(patch) => {
|
||||
session.update(active.id, patch);
|
||||
refresh();
|
||||
}}
|
||||
onDuplicate={() => {
|
||||
const copy = session.duplicate(active.id);
|
||||
setSelected(copy.id);
|
||||
refresh();
|
||||
onSelectPreview(copy.id);
|
||||
}}
|
||||
onDelete={() => {
|
||||
session.remove(active.id);
|
||||
setSelected(undefined);
|
||||
refresh();
|
||||
onSelectPreview(null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p className="col-span-3 text-[10px] text-text-tertiary">
|
||||
在视口点击对象后拖动操纵轴;只允许绕世界 Z 轴旋转,缩放会写入原语尺寸。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section className="space-y-2 rounded-lg border border-border-subtle p-2.5">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-text-tertiary">
|
||||
地图对象
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
@@ -294,7 +372,7 @@ export function MapEditorPanel({
|
||||
<button
|
||||
key={object.id}
|
||||
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={() => {
|
||||
setSelected(object.id);
|
||||
onSelectPreview(object.id);
|
||||
@@ -304,162 +382,11 @@ export function MapEditorPanel({
|
||||
{object.placementMode === 'locked' ? ' · 已锁定' : ''}
|
||||
</button>
|
||||
))}
|
||||
{!current.objects.length && <div className="text-xs text-text-tertiary">暂无对象</div>}
|
||||
</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>
|
||||
),
|
||||
{!current.objects.length && (
|
||||
<div className="text-xs text-text-tertiary">暂无对象,可从左侧资产库拖入视口</div>
|
||||
)}
|
||||
</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>
|
||||
<label className="block text-[11px] text-text-secondary">
|
||||
绕 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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<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">
|
||||
@@ -480,6 +407,7 @@ export function MapEditorPanel({
|
||||
aria-label={`出生点名称 ${spawn.id}`}
|
||||
className="h-7 w-full rounded border border-border-strong bg-input px-1 text-xs"
|
||||
value={spawn.name}
|
||||
disabled={loading}
|
||||
onChange={(event) => {
|
||||
if (!event.target.value.trim()) return;
|
||||
session.updateSpawn(spawn.id, { name: event.target.value });
|
||||
@@ -488,41 +416,29 @@ export function MapEditorPanel({
|
||||
/>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{spawn.position.map((value, index) => (
|
||||
<label key={index} className="text-[10px] text-text-secondary">
|
||||
{['X', 'Y', 'Z'][index]}
|
||||
<input
|
||||
<ScrubbableNumberInput
|
||||
key={index}
|
||||
label={['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}
|
||||
onChange={(event) =>
|
||||
updateSpawnPosition(
|
||||
spawn.id,
|
||||
spawn.position,
|
||||
index,
|
||||
Number(event.target.value),
|
||||
)
|
||||
step={0.1}
|
||||
disabled={loading}
|
||||
onValueChange={(next) =>
|
||||
updateSpawnPosition(spawn.id, spawn.position, index, next)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label className="text-[10px] text-text-secondary">
|
||||
Yaw°
|
||||
<input
|
||||
<ScrubbableNumberInput
|
||||
label="Yaw°"
|
||||
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}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (!Number.isFinite(value)) return;
|
||||
session.updateSpawn(spawn.id, { yawDeg: value });
|
||||
step={1}
|
||||
disabled={loading}
|
||||
onValueChange={(yawDeg) => {
|
||||
session.updateSpawn(spawn.id, { yawDeg });
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
@@ -581,8 +497,7 @@ export function MapEditorPanel({
|
||||
onClick={async () => {
|
||||
const latest = session.document;
|
||||
const candidate = { ...latest, revision: latest.revision + 1 };
|
||||
if (await onApply(candidate)) onPreview(null);
|
||||
else onPreview(session.document);
|
||||
if (!(await onApply(candidate))) onPreview(session.document);
|
||||
}}
|
||||
>
|
||||
应用并重新编译
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { discoverMapEntries, resolveProjectMap } from './MapLoader';
|
||||
import { discoverMapEntries, resolveProjectMap, visualMapAssets } from './MapLoader';
|
||||
import type { ProjectFile, ProjectManifest } from '../project/types';
|
||||
|
||||
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 创作层', () => {
|
||||
const editable = JSON.stringify({
|
||||
...JSON.parse(descriptor),
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { MapEntry, ProjectFile, ProjectManifest } from '../project/types';
|
||||
import { decodeMapDefinition } from './mapSchema';
|
||||
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';
|
||||
|
||||
function projectFile(manifest: ProjectManifest, path: string): ProjectFile {
|
||||
@@ -92,6 +98,7 @@ export function visualMapAsset(
|
||||
const resolved = resolveProjectMap(manifest, selection.descriptorPath);
|
||||
if (!resolved.visualPath || !resolved.definition.visual) return null;
|
||||
const visual = projectFile(manifest, resolved.visualPath);
|
||||
const transform = mapSelectionTransform(selection);
|
||||
return {
|
||||
id: resolved.definition.id,
|
||||
name: resolved.definition.name,
|
||||
@@ -99,5 +106,17 @@ export function visualMapAsset(
|
||||
data: visual.data,
|
||||
castShadow: resolved.definition.visual.castShadow ?? 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 }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -11,21 +11,50 @@ const common = {
|
||||
showMapCollision: false,
|
||||
editorDocument: null,
|
||||
onEditorPreview: () => {},
|
||||
onEditorDraftChange: () => {},
|
||||
onEditorApply: async () => true,
|
||||
onEditorExport: () => {},
|
||||
onEditorConvert: async () => true,
|
||||
onEditorBindInteraction: () => {},
|
||||
onEditorSelect: () => {},
|
||||
onEditorTransformMode: () => {},
|
||||
onEditorSnapping: () => {},
|
||||
onEditorSessionStateChange: () => {},
|
||||
onMapDisplay: () => {},
|
||||
onDraft: () => {},
|
||||
};
|
||||
|
||||
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('选择楼梯参数后请求重新编译', () => {
|
||||
const onApply = vi.fn();
|
||||
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
|
||||
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
|
||||
render(
|
||||
<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: '6' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
@@ -37,8 +66,13 @@ describe('PhysicalMapPanel', () => {
|
||||
|
||||
it('在右侧参数面板调整系统地形并提交', () => {
|
||||
const onApply = vi.fn();
|
||||
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
|
||||
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
|
||||
render(
|
||||
<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('地形难度(0–1)'), { target: { value: '0.8' } });
|
||||
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('可以移动并旋转资产地图', () => {
|
||||
const onApply = vi.fn();
|
||||
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
|
||||
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
|
||||
render(
|
||||
<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('位置 Y(m)'), { target: { value: '-1.5' } });
|
||||
fireEvent.change(screen.getByLabelText('旋转 Z(°)'), { target: { value: '45' } });
|
||||
@@ -67,12 +155,13 @@ describe('PhysicalMapPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('显示工程地图、出生点和根 Body', () => {
|
||||
it('显示工程地图、实例变换、出生点和根 Body', () => {
|
||||
const onApply = vi.fn();
|
||||
const onDraft = vi.fn();
|
||||
render(
|
||||
<PhysicalMapPanel
|
||||
{...common}
|
||||
value={{ kind: 'none' }}
|
||||
value={{ kind: 'project', descriptorPath: 'maps/warehouse/map.json' }}
|
||||
maps={[
|
||||
{
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
@@ -83,12 +172,14 @@ describe('PhysicalMapPanel', () => {
|
||||
},
|
||||
]}
|
||||
rootBodies={['robot', 'payload']}
|
||||
onDraft={onDraft}
|
||||
onApply={onApply}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('地图来源'), {
|
||||
target: { value: 'project:maps/warehouse/map.json' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('位置 X(m)'), { target: { value: '2.5' } });
|
||||
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');
|
||||
fireEvent.change(screen.getByLabelText('机器人根 Body'), { target: { value: 'robot' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
@@ -96,10 +187,16 @@ describe('PhysicalMapPanel', () => {
|
||||
expect.objectContaining({
|
||||
kind: 'project',
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
positionX: 2.5,
|
||||
positionY: -1.5,
|
||||
yawDeg: 45,
|
||||
spawnPointId: 'door',
|
||||
robotRootBody: 'robot',
|
||||
}),
|
||||
);
|
||||
expect(onDraft).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ kind: 'project', robotRootBody: 'robot' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('原生 URDF 模式只允许移除地图', () => {
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Select } from '../components/ui';
|
||||
import { Button, ScrubbableNumberInput, Select } from '../components/ui';
|
||||
import type { MapEntry } from '../project/types';
|
||||
import {
|
||||
DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||
PHYSICAL_MAP_PRESET_LABELS,
|
||||
isSystemTerrainPreset,
|
||||
mapSelectionTransform,
|
||||
type MapSelection,
|
||||
type PhysicalMapConfig,
|
||||
type PhysicalMapPreset,
|
||||
type PlacedMapSelection,
|
||||
} from './types';
|
||||
import { normalizePhysicalMapConfig } from './physicalMap';
|
||||
import type {
|
||||
EditableMapDocument,
|
||||
MapEditorInteractionCallbacks,
|
||||
MapEditorTransformMode,
|
||||
MapEditorSessionState,
|
||||
} from './editor/types';
|
||||
import { MapEditorPanel } from './MapEditorPanel';
|
||||
|
||||
@@ -35,19 +37,15 @@ function NumberField({
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">{label}</span>
|
||||
<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"
|
||||
<ScrubbableNumberInput
|
||||
label={label}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
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;
|
||||
return {
|
||||
...selection,
|
||||
...mapSelectionTransform(selection),
|
||||
frictionOverride:
|
||||
friction === undefined || !Number.isFinite(friction)
|
||||
? undefined
|
||||
@@ -76,15 +75,20 @@ export function PhysicalMapPanel({
|
||||
showVisualMap,
|
||||
showMapCollision,
|
||||
editorDocument,
|
||||
editorDraftDocument,
|
||||
sceneDirty = false,
|
||||
onEditorPreview,
|
||||
onEditorDraftChange,
|
||||
onEditorApply,
|
||||
onEditorExport,
|
||||
onEditorConvert,
|
||||
selectedEditorObjectId,
|
||||
onEditorBindInteraction,
|
||||
onEditorSelect,
|
||||
onEditorTransformMode,
|
||||
onEditorSnapping,
|
||||
onEditorSessionStateChange,
|
||||
onEditorSurfaceHeight,
|
||||
onMapDisplay,
|
||||
onDraft,
|
||||
onApply,
|
||||
}: {
|
||||
value: MapSelection;
|
||||
@@ -95,20 +99,37 @@ export function PhysicalMapPanel({
|
||||
showVisualMap: boolean;
|
||||
showMapCollision: boolean;
|
||||
editorDocument: EditableMapDocument | null;
|
||||
editorDraftDocument?: EditableMapDocument;
|
||||
sceneDirty?: boolean;
|
||||
onEditorPreview: (document: EditableMapDocument | null) => void;
|
||||
onEditorDraftChange: (
|
||||
descriptorPath: string,
|
||||
document: EditableMapDocument,
|
||||
dirty: boolean,
|
||||
) => void;
|
||||
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
|
||||
onEditorExport: () => void;
|
||||
onEditorConvert: () => Promise<boolean>;
|
||||
selectedEditorObjectId?: string;
|
||||
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
|
||||
onEditorSelect: (id: string | null) => void;
|
||||
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
|
||||
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
|
||||
onEditorSessionStateChange?: (
|
||||
descriptorPath: string,
|
||||
state: MapEditorSessionState | null,
|
||||
) => void;
|
||||
onEditorSurfaceHeight?: (position: readonly [number, number, number]) => number | null;
|
||||
onMapDisplay: (visual: boolean, collision: boolean) => void;
|
||||
onDraft: (value: PlacedMapSelection) => 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 changed = JSON.stringify(normalized) !== JSON.stringify(value);
|
||||
const changed = JSON.stringify(normalized) !== JSON.stringify(normalizeSelection(value));
|
||||
const compileDisabled = loading || (nativeUrdf && normalized.kind !== 'none');
|
||||
const sourceValue =
|
||||
draft.kind === 'none'
|
||||
@@ -120,9 +141,18 @@ export function PhysicalMapPanel({
|
||||
draft.kind === 'project'
|
||||
? maps.find((entry) => entry.descriptorPath === draft.descriptorPath)
|
||||
: 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]) => {
|
||||
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) => {
|
||||
@@ -141,20 +171,104 @@ export function PhysicalMapPanel({
|
||||
setDraft({
|
||||
kind: 'project',
|
||||
descriptorPath,
|
||||
positionX: 0,
|
||||
positionY: 0,
|
||||
yawDeg: 0,
|
||||
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 (
|
||||
<div className="space-y-4 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>
|
||||
</div>
|
||||
|
||||
{editorPanel}
|
||||
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">地图来源</span>
|
||||
<Select
|
||||
@@ -176,6 +290,10 @@ export function PhysicalMapPanel({
|
||||
|
||||
{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">
|
||||
<span className="mb-1 block">地图预设</span>
|
||||
<Select
|
||||
@@ -205,10 +323,7 @@ export function PhysicalMapPanel({
|
||||
draft.config.yawDeg === 0)
|
||||
}
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
kind: 'builtin',
|
||||
config: { ...draft.config, positionX: 0, positionY: 0, yawDeg: 0 },
|
||||
})
|
||||
replaceBuiltin({ ...draft.config, positionX: 0, positionY: 0, yawDeg: 0 })
|
||||
}
|
||||
>
|
||||
复位
|
||||
@@ -349,6 +464,54 @@ export function PhysicalMapPanel({
|
||||
<div className="break-all text-[11px] text-text-tertiary">
|
||||
{projectMap.descriptorPath}
|
||||
</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 && (
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">出生点</span>
|
||||
@@ -358,7 +521,7 @@ export function PhysicalMapPanel({
|
||||
value={draft.spawnPointId ?? ''}
|
||||
disabled={loading}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, spawnPointId: event.target.value || undefined })
|
||||
replaceProject({ ...draft, spawnPointId: event.target.value || undefined })
|
||||
}
|
||||
>
|
||||
<option value="">不改变机器人位置</option>
|
||||
@@ -379,7 +542,7 @@ export function PhysicalMapPanel({
|
||||
value={draft.robotRootBody ?? ''}
|
||||
disabled={loading}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, robotRootBody: event.target.value || undefined })
|
||||
replaceProject({ ...draft, robotRootBody: event.target.value || undefined })
|
||||
}
|
||||
>
|
||||
<option value="">自动识别</option>
|
||||
@@ -403,7 +566,7 @@ export function PhysicalMapPanel({
|
||||
disabled={loading}
|
||||
className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary"
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
replaceProject({
|
||||
...draft,
|
||||
frictionOverride: event.target.value ? Number(event.target.value) : undefined,
|
||||
})
|
||||
@@ -413,26 +576,6 @@ export function PhysicalMapPanel({
|
||||
</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">
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span>显示视觉地图</span>
|
||||
@@ -461,10 +604,19 @@ export function PhysicalMapPanel({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{sceneDirty && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded border border-accent/30 bg-accent-soft p-2 text-xs text-accent"
|
||||
>
|
||||
场景草稿正在轻量预览;工程地图、认证资产与参数地形会在确认后一次性编译。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
disabled={compileDisabled || !changed}
|
||||
disabled={compileDisabled || (!changed && !sceneDirty)}
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -23,9 +23,17 @@ export class MapEditSession {
|
||||
private redoStack: HistoryEntry[] = [];
|
||||
selectedId?: string;
|
||||
|
||||
constructor(document: EditableMapDocument) {
|
||||
this.current = clone(parseEditableMapDocument(document));
|
||||
this.saved = clone(this.current);
|
||||
constructor(
|
||||
document: EditableMapDocument,
|
||||
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 {
|
||||
return clone(this.current);
|
||||
@@ -33,12 +41,22 @@ export class MapEditSession {
|
||||
get dirty(): boolean {
|
||||
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 {
|
||||
return this.undoStack.length > 0;
|
||||
}
|
||||
get canRedo(): boolean {
|
||||
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 {
|
||||
const before = clone(this.current),
|
||||
after = clone(this.current);
|
||||
@@ -51,7 +69,7 @@ export class MapEditSession {
|
||||
}
|
||||
add(type: EditableMapObjectType): EditableMapObject {
|
||||
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.selectedId = object.id;
|
||||
return structuredClone(object);
|
||||
@@ -60,9 +78,10 @@ export class MapEditSession {
|
||||
type: EditableMapObjectType,
|
||||
position?: [number, number, number],
|
||||
placementMode: MapObjectPlacementMode = 'auto_ground',
|
||||
externalSupportTop = 0,
|
||||
): EditableMapObject {
|
||||
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.selectedId = object.id;
|
||||
return structuredClone(object);
|
||||
@@ -81,7 +100,7 @@ export class MapEditSession {
|
||||
copy.pose.position[1] + 0.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.selectedId = copy.id;
|
||||
return structuredClone(copy);
|
||||
@@ -93,7 +112,7 @@ export class MapEditSession {
|
||||
const next = structuredClone(patch);
|
||||
if (object.placementMode === 'locked' && next.placementMode === undefined) delete next.pose;
|
||||
Object.assign(object, next);
|
||||
applyObjectPlacement(object, draft.objects);
|
||||
applyObjectPlacement(object, draft.objects, this.supportTop(object));
|
||||
});
|
||||
}
|
||||
remove(id: string): void {
|
||||
|
||||
@@ -1,13 +1,56 @@
|
||||
import {
|
||||
createEditableObject,
|
||||
editableObjectGroundHeight,
|
||||
isMapObjectPlacementMode,
|
||||
type EditableMapObject,
|
||||
type EditableMapObjectType,
|
||||
type MapObjectPlacementMode,
|
||||
} from './types';
|
||||
import { isSystemTerrainPreset, type SystemTerrainPreset } from '../types';
|
||||
|
||||
export const MAP_ASSET_DRAG_MIME = 'application/x-mujoco-map-asset';
|
||||
export const MAP_ASSET_PLACEMENT_MIME = 'application/x-mujoco-map-placement';
|
||||
export const MAP_LIBRARY_DRAG_MIME = 'application/x-mujoco-map-library-item';
|
||||
|
||||
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 {
|
||||
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 object = session.add('box');
|
||||
expect(session.dirty).toBe(true);
|
||||
expect(session.changeCount).toBe(1);
|
||||
session.update(object.id, { name: '墙' });
|
||||
expect(session.changeCount).toBe(2);
|
||||
expect(session.document.objects[0].name).toBe('墙');
|
||||
const copy = session.duplicate(object.id);
|
||||
expect(copy).toMatchObject({ name: '墙 副本', pose: { position: [0.2, 0.2, 0.5] } });
|
||||
@@ -95,6 +97,20 @@ describe('地图 V3 编辑核心', () => {
|
||||
expect(session.document.spawnPoints).toHaveLength(1);
|
||||
session.discard();
|
||||
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('将认证资产按画布落点加入草稿并自动对齐地面', () => {
|
||||
@@ -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('支持重力落位到最高承载面并锁定位姿', () => {
|
||||
const session = new MapEditSession(document());
|
||||
const support = session.addAsset('box', [0, 0, 0]);
|
||||
|
||||
@@ -54,28 +54,31 @@ function objectTop(object: EditableMapObject): number {
|
||||
export function gravitySettledPosition(
|
||||
object: EditableMapObject,
|
||||
objects: EditableMapObject[],
|
||||
externalSupportTop = 0,
|
||||
): [number, number, number] {
|
||||
const target = footprint(object);
|
||||
let supportTop = 0;
|
||||
let supportTop = externalSupportTop;
|
||||
for (const candidate of objects) {
|
||||
if (candidate.id === object.id || !candidate.enabled || !overlaps(target, footprint(candidate)))
|
||||
continue;
|
||||
supportTop = Math.max(supportTop, objectTop(candidate));
|
||||
}
|
||||
const settledZ = supportTop + editableObjectGroundHeight(object);
|
||||
return [
|
||||
object.pose.position[0],
|
||||
object.pose.position[1],
|
||||
supportTop + editableObjectGroundHeight(object),
|
||||
Math.round(settledZ * 1_000_000_000) / 1_000_000_000,
|
||||
];
|
||||
}
|
||||
|
||||
export function applyObjectPlacement(
|
||||
object: EditableMapObject,
|
||||
objects: EditableMapObject[],
|
||||
externalSupportTop = 0,
|
||||
): void {
|
||||
if (object.placementMode === 'locked') return;
|
||||
object.pose.position =
|
||||
object.placementMode === 'gravity'
|
||||
? gravitySettledPosition(object, objects)
|
||||
? gravitySettledPosition(object, objects, externalSupportTop)
|
||||
: [object.pose.position[0], object.pose.position[1], editableObjectGroundHeight(object)];
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export type MapObjectPlacementMode = 'auto_ground' | 'gravity' | 'locked';
|
||||
export const MAP_OBJECT_PLACEMENT_LABELS: Record<MapObjectPlacementMode, string> = {
|
||||
auto_ground: '自动贴地',
|
||||
gravity: '自动落位(重力)',
|
||||
locked: '锁定',
|
||||
locked: '锁定位姿',
|
||||
};
|
||||
|
||||
export function isMapObjectPlacementMode(value: string): value is MapObjectPlacementMode {
|
||||
@@ -21,6 +21,16 @@ export interface MapEditorTransform {
|
||||
scale: [number, number, number];
|
||||
}
|
||||
|
||||
export interface MapEditorSessionState {
|
||||
dirty: boolean;
|
||||
changeCount: number;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 视口与 MapEditSession 之间的命令端口。视口层只发送意图,不直接修改地图文档。
|
||||
*/
|
||||
export interface MapEditorInteractionCallbacks {
|
||||
onSelect(id: string | null): void;
|
||||
onTransform(transform: MapEditorTransform): void;
|
||||
@@ -28,7 +38,12 @@ export interface MapEditorInteractionCallbacks {
|
||||
type: EditableMapObjectType,
|
||||
position?: [number, number, number],
|
||||
placementMode?: MapObjectPlacementMode,
|
||||
externalSupportTop?: number,
|
||||
): void;
|
||||
onSetPlacementMode(id: string, mode: MapObjectPlacementMode): void;
|
||||
onAlignToSurface(id: string): void;
|
||||
onDelete(id: string): void;
|
||||
onDiscard(): void;
|
||||
}
|
||||
|
||||
export interface EditableMapObject {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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('系统粗糙地形生成内联高度场并替换原平面', () => {
|
||||
const source = encoder.encode(
|
||||
'<mujoco><worldbody><geom name="__platform_ground__" type="plane"/></worldbody></mujoco>',
|
||||
|
||||
@@ -17,6 +17,18 @@ export interface PhysicalMapComposition {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
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(
|
||||
geom: Element,
|
||||
name: string,
|
||||
config: PhysicalMapConfig,
|
||||
rgba: string,
|
||||
prefix = MAP_NAME_PREFIX,
|
||||
): void {
|
||||
geom.setAttribute('name', `${MAP_NAME_PREFIX}${name}__`);
|
||||
geom.setAttribute('name', `${prefix}${name}__`);
|
||||
geom.setAttribute('friction', `${config.friction} 0.005 0.0001`);
|
||||
geom.setAttribute('group', '2');
|
||||
geom.setAttribute('rgba', rgba);
|
||||
@@ -90,9 +110,10 @@ function addBox(
|
||||
halfSize: [number, number, number],
|
||||
rgba: string,
|
||||
quaternion?: [number, number, number, number],
|
||||
prefix = MAP_NAME_PREFIX,
|
||||
): void {
|
||||
const geom = document.createElement('geom');
|
||||
setCommonGeomAttributes(geom, name, config, rgba);
|
||||
setCommonGeomAttributes(geom, name, config, rgba, prefix);
|
||||
geom.setAttribute('type', 'box');
|
||||
geom.setAttribute('pos', position.join(' '));
|
||||
geom.setAttribute('size', halfSize.join(' '));
|
||||
@@ -105,6 +126,7 @@ function addHeightfield(
|
||||
worldbody: Element,
|
||||
config: PhysicalMapConfig,
|
||||
heightfield: GeneratedTerrainHeightfield,
|
||||
prefix = MAP_NAME_PREFIX,
|
||||
): void {
|
||||
let asset = document.querySelector('mujoco > asset');
|
||||
if (!asset) {
|
||||
@@ -116,7 +138,7 @@ function addHeightfield(
|
||||
const minimum = Math.min(...heightfield.heights),
|
||||
maximum = Math.max(...heightfield.heights),
|
||||
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');
|
||||
source.setAttribute('name', name);
|
||||
source.setAttribute('nrow', String(heightfield.rowSegments + 1));
|
||||
@@ -129,17 +151,22 @@ function addHeightfield(
|
||||
asset.append(source);
|
||||
|
||||
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('hfield', name);
|
||||
geom.setAttribute('pos', `0 0 ${minimum}`);
|
||||
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 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('quat', `${Math.cos(halfYaw)} 0 0 ${Math.sin(halfYaw)}`);
|
||||
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 {
|
||||
const existing = Array.from(worldbody.children).find(isGroundPlane);
|
||||
if (existing) {
|
||||
@@ -177,6 +281,7 @@ function ensureGround(document: Document, worldbody: Element, config: PhysicalMa
|
||||
export function composePhysicalMap(
|
||||
source: Uint8Array,
|
||||
requestedConfig: PhysicalMapConfig,
|
||||
options: PhysicalMapCompositionOptions = {},
|
||||
): PhysicalMapComposition {
|
||||
const config = normalizePhysicalMapConfig(requestedConfig);
|
||||
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 无法解析');
|
||||
const worldbody = document.querySelector('mujoco > 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();
|
||||
for (const emptyAsset of Array.from(document.querySelectorAll('mujoco > asset:empty')))
|
||||
emptyAsset.remove();
|
||||
|
||||
let geomCount: number;
|
||||
let geomCount = 0;
|
||||
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 colors: Record<string, string> = {
|
||||
obstacle: '0.48 0.34 0.2 1',
|
||||
@@ -215,75 +324,38 @@ export function composePhysicalMap(
|
||||
item.position,
|
||||
[item.size[0] / 2, item.size[1] / 2, item.size[2] / 2],
|
||||
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);
|
||||
} else {
|
||||
let boxes = generateBuiltinMapBoxes(config);
|
||||
if (options.preserveExistingGround) {
|
||||
// 场景实例中的平地必须是可移动的有限地块;其他旧版预设则复用已有承载面。
|
||||
if (config.preset !== 'flat' && !hasSupportingGround(worldbody))
|
||||
boxes = generateBuiltinMapBoxes({ ...config, preset: 'flat' }).concat(boxes);
|
||||
} else {
|
||||
geomCount = ensureGround(document, worldbody, config);
|
||||
boxes = boxes.filter((item) => item.name !== 'ground');
|
||||
}
|
||||
if (config.preset === 'slope') {
|
||||
const mapRoot = createMapRoot(document, worldbody, config);
|
||||
const angleRadians = (config.slopeAngle * Math.PI) / 180;
|
||||
const rampLength = Math.min(config.size * 0.42, 6);
|
||||
const thickness = 0.12;
|
||||
const startX = 1.25;
|
||||
if (boxes.length) {
|
||||
const mapRoot = createMapRoot(document, worldbody, config, prefix);
|
||||
for (const item of boxes)
|
||||
addBox(
|
||||
document,
|
||||
mapRoot,
|
||||
config,
|
||||
'slope',
|
||||
[
|
||||
startX + (rampLength * Math.cos(angleRadians)) / 2,
|
||||
0,
|
||||
thickness / 2 + (rampLength * Math.sin(angleRadians)) / 2,
|
||||
],
|
||||
[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',
|
||||
item.name,
|
||||
item.position,
|
||||
[item.size[0] / 2, item.size[1] / 2, item.size[2] / 2],
|
||||
item.rgba,
|
||||
item.quaternion,
|
||||
prefix,
|
||||
);
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ export type SystemTerrainPreset = (typeof SYSTEM_TERRAIN_PRESETS)[number];
|
||||
export type PhysicalMapPreset =
|
||||
'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);
|
||||
}
|
||||
|
||||
@@ -82,11 +82,45 @@ export type MapSelection =
|
||||
| {
|
||||
kind: 'project';
|
||||
descriptorPath: string;
|
||||
/** 工程地图实例位姿;descriptor 内容始终保持源坐标不变。 */
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
yawDeg?: number;
|
||||
spawnPointId?: string;
|
||||
robotRootBody?: string;
|
||||
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 {
|
||||
definition: MapDefinition;
|
||||
descriptorPath: string;
|
||||
@@ -102,6 +136,67 @@ export interface VisualMapAsset {
|
||||
data: Uint8Array;
|
||||
castShadow: 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 = {
|
||||
|
||||
@@ -36,12 +36,20 @@ function BodyBranch({
|
||||
node,
|
||||
depth,
|
||||
onJointHover,
|
||||
onSelectBody,
|
||||
onSelectJoint,
|
||||
selectedBodyId,
|
||||
selectedJointId,
|
||||
searching,
|
||||
query,
|
||||
}: {
|
||||
node: BodyNode;
|
||||
depth: number;
|
||||
onJointHover: (jointId: number | null) => void;
|
||||
onSelectBody?: (bodyId: number) => void;
|
||||
onSelectJoint?: (joint: JointInfo) => void;
|
||||
selectedBodyId?: number;
|
||||
selectedJointId?: number;
|
||||
searching: boolean;
|
||||
query: string;
|
||||
}) {
|
||||
@@ -60,11 +68,15 @@ function BodyBranch({
|
||||
<summary
|
||||
role="treeitem"
|
||||
aria-expanded={shownOpen}
|
||||
aria-selected={selectedBodyId === node.id}
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
onSelectBody?.(node.id);
|
||||
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" />
|
||||
<span className="truncate">
|
||||
@@ -74,10 +86,14 @@ function BodyBranch({
|
||||
<ul role="group" className="ml-3 border-l border-border pl-1">
|
||||
{node.joints.map((joint) => (
|
||||
<li role="none" key={joint.id}>
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
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"
|
||||
aria-selected={selectedJointId === joint.id}
|
||||
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)}
|
||||
onMouseLeave={() => onJointHover(null)}
|
||||
onFocus={() => onJointHover(joint.id)}
|
||||
@@ -86,7 +102,7 @@ function BodyBranch({
|
||||
>
|
||||
<Disc3 aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
|
||||
<SearchHighlight text={joint.name} query={query} />
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{node.children.map((child) => (
|
||||
@@ -95,6 +111,10 @@ function BodyBranch({
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
onJointHover={onJointHover}
|
||||
onSelectBody={onSelectBody}
|
||||
onSelectJoint={onSelectJoint}
|
||||
selectedBodyId={selectedBodyId}
|
||||
selectedJointId={selectedJointId}
|
||||
searching={searching}
|
||||
query={query}
|
||||
/>
|
||||
@@ -102,14 +122,20 @@ function BodyBranch({
|
||||
</ul>
|
||||
</details>
|
||||
) : (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
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"
|
||||
aria-selected={selectedBodyId === node.id}
|
||||
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" />
|
||||
<SearchHighlight text={node.name} query={query} />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
@@ -169,11 +195,19 @@ export function ModelStructureTree({
|
||||
bodies,
|
||||
joints,
|
||||
onJointHover,
|
||||
onSelectBody,
|
||||
onSelectJoint,
|
||||
selectedBodyId,
|
||||
selectedJointId,
|
||||
query = '',
|
||||
}: {
|
||||
bodies: BodyInfo[];
|
||||
joints: JointInfo[];
|
||||
onJointHover: (jointId: number | null) => void;
|
||||
onSelectBody?: (bodyId: number) => void;
|
||||
onSelectJoint?: (joint: JointInfo) => void;
|
||||
selectedBodyId?: number;
|
||||
selectedJointId?: number;
|
||||
query?: string;
|
||||
}) {
|
||||
const normalized = query.trim().toLocaleLowerCase(),
|
||||
@@ -211,8 +245,12 @@ export function ModelStructureTree({
|
||||
renderRow={(item) =>
|
||||
item.kind === 'body' ? (
|
||||
<div
|
||||
aria-selected={selectedBodyId === item.body.id}
|
||||
onClick={() => onSelectBody?.(item.body.id)}
|
||||
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 }}
|
||||
>
|
||||
<Box className="h-3.5 w-3.5 text-accent" />
|
||||
@@ -220,8 +258,12 @@ export function ModelStructureTree({
|
||||
</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 }}
|
||||
onClick={() => onSelectJoint?.(item.joint)}
|
||||
onMouseEnter={() => onJointHover(item.joint.id)}
|
||||
onMouseLeave={() => onJointHover(null)}
|
||||
>
|
||||
@@ -244,6 +286,10 @@ export function ModelStructureTree({
|
||||
node={root}
|
||||
depth={0}
|
||||
onJointHover={onJointHover}
|
||||
onSelectBody={onSelectBody}
|
||||
onSelectJoint={onSelectJoint}
|
||||
selectedBodyId={selectedBodyId}
|
||||
selectedJointId={selectedJointId}
|
||||
searching={Boolean(normalized)}
|
||||
query={query}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { zipSync } from 'fflate';
|
||||
import {
|
||||
choosePreferredEntry,
|
||||
discoverEntries,
|
||||
filesFromDrop,
|
||||
importBrowserFiles,
|
||||
normalizeProjectPath,
|
||||
prepareProjectForMujoco,
|
||||
@@ -35,6 +36,30 @@ describe('project importer', () => {
|
||||
expect(entries).toHaveLength(3);
|
||||
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 () => {
|
||||
const zipped = zipSync({
|
||||
'robot/model.urdf': encode('<robot name="r"/>'),
|
||||
|
||||
@@ -427,6 +427,29 @@ export async function importBrowserFiles(
|
||||
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 {
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
@@ -464,7 +487,27 @@ export async function filesFromDrop(
|
||||
items: DataTransferItemList,
|
||||
fallback: FileList,
|
||||
): 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(
|
||||
(item) =>
|
||||
(item as unknown as { webkitGetAsEntry?: () => LegacyEntry | null }).webkitGetAsEntry?.() ??
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ActuatorInfo, ActuatorParameters } from './SimulationSession';
|
||||
import { Badge } from '../components/ui';
|
||||
import { Badge, ScrubbableNumberInput } from '../components/ui';
|
||||
|
||||
export function ActuatorControl({
|
||||
actuator,
|
||||
@@ -187,25 +187,15 @@ function ParameterInput({
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-[10px] text-text-tertiary">
|
||||
<span className="mb-1 block truncate">{label}</span>
|
||||
<input
|
||||
key={value}
|
||||
type="number"
|
||||
step="any"
|
||||
defaultValue={Number.isFinite(value) ? value : 0}
|
||||
<ScrubbableNumberInput
|
||||
label={label}
|
||||
value={Number.isFinite(value) ? value : 0}
|
||||
step={Math.max(0.001, Math.abs(value) * 0.01)}
|
||||
disabled={disabled}
|
||||
className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40"
|
||||
onBlur={(event) => {
|
||||
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();
|
||||
onValueChange={(next) => {
|
||||
if (next !== value) onCommit(next);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,8 @@ import type {
|
||||
import { composePhysicalMap } from '../map/physicalMap';
|
||||
import { composeProjectMap } from '../map/MapComposer';
|
||||
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 { UrdfBaseMode, UrdfEnhancementOptions };
|
||||
@@ -38,7 +39,9 @@ export interface PhysicsLoadOptions {
|
||||
urdfMode?: UrdfLoadMode;
|
||||
baseMode?: UrdfBaseMode;
|
||||
enhancements?: UrdfEnhancementOptions;
|
||||
/** @deprecated 单地图兼容入口;新场景应使用 mapAssets。 */
|
||||
map?: MapSelection;
|
||||
mapAssets?: readonly PlacedMapAsset[];
|
||||
onProgress?: (progress: PhysicsLoadProgress) => void;
|
||||
}
|
||||
|
||||
@@ -126,6 +129,8 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
sensorType: 'camera',
|
||||
};
|
||||
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) => {
|
||||
if (!this.disposed && generation === this.loadGeneration)
|
||||
options.onProgress?.({ value, label });
|
||||
@@ -191,24 +196,31 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
intermediate.dispose();
|
||||
}
|
||||
}
|
||||
if (mapSelection.kind !== 'none') {
|
||||
if (hasMaps) {
|
||||
report(0.72, '组合机器人与物理地图');
|
||||
if (entry?.format === 'urdf' && urdfMode === 'native')
|
||||
throw new Error('原生 URDF 模式暂不支持地图,请切换为转换模式');
|
||||
const slash = modelRelativePath.lastIndexOf('/');
|
||||
const directory = slash >= 0 ? modelRelativePath.slice(0, slash + 1) : '';
|
||||
const mapPath = `${directory}.__mujoco_map_scene_${manifest.id.replace(/[^a-zA-Z0-9_-]/g, '_')}.xml`;
|
||||
if (mapSelection.kind === 'builtin') {
|
||||
const composed = composePhysicalMap(
|
||||
new TextEncoder().encode(workspace.readText(modelRelativePath)),
|
||||
mapSelection.config,
|
||||
const source = new TextEncoder().encode(workspace.readText(modelRelativePath));
|
||||
if (placedMapAssets) {
|
||||
const composed = composePlacedMapAssets(
|
||||
source,
|
||||
mapPath,
|
||||
prepared.manifest,
|
||||
placedMapAssets,
|
||||
);
|
||||
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);
|
||||
} else {
|
||||
} else if (mapSelection.kind === 'project') {
|
||||
const resolvedMap = resolveProjectMap(prepared.manifest, mapSelection.descriptorPath);
|
||||
const composed = composeProjectMap(
|
||||
new TextEncoder().encode(workspace.readText(modelRelativePath)),
|
||||
source,
|
||||
mapPath,
|
||||
prepared.manifest,
|
||||
resolvedMap,
|
||||
@@ -339,21 +351,41 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
return this.supportFiles.map((file) => ({ ...file, data: file.data.slice() }));
|
||||
}
|
||||
releaseRetired(): void {
|
||||
this.retiredSession?.dispose();
|
||||
const retiredSession = this.retiredSession;
|
||||
const retiredWorkspace = this.retiredWorkspace;
|
||||
this.retiredSession = null;
|
||||
this.retiredWorkspace?.dispose();
|
||||
this.retiredWorkspace = null;
|
||||
this.retiredSupportFiles = [];
|
||||
try {
|
||||
retiredSession?.dispose();
|
||||
} catch (error) {
|
||||
console.warn('[MuJoCo] 释放旧仿真会话失败', error);
|
||||
}
|
||||
try {
|
||||
retiredWorkspace?.dispose();
|
||||
} catch (error) {
|
||||
console.warn('[MuJoCo] 释放旧内存工作区失败', error);
|
||||
}
|
||||
}
|
||||
rollbackRetired(): void {
|
||||
this.session?.dispose();
|
||||
this.workspace?.dispose();
|
||||
const failedSession = this.session;
|
||||
const failedWorkspace = this.workspace;
|
||||
this.session = this.retiredSession;
|
||||
this.workspace = this.retiredWorkspace;
|
||||
this.supportFiles = this.retiredSupportFiles;
|
||||
this.retiredSession = null;
|
||||
this.retiredWorkspace = null;
|
||||
this.retiredSupportFiles = [];
|
||||
try {
|
||||
failedSession?.dispose();
|
||||
} catch (error) {
|
||||
console.warn('[MuJoCo] 释放失败的候选仿真会话失败', error);
|
||||
}
|
||||
try {
|
||||
failedWorkspace?.dispose();
|
||||
} catch (error) {
|
||||
console.warn('[MuJoCo] 释放失败的候选内存工作区失败', error);
|
||||
}
|
||||
}
|
||||
exportMjcf(): Uint8Array {
|
||||
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 {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.removeController();
|
||||
this.removeRLPolicy();
|
||||
this.clearExternalForce();
|
||||
this.perturb.delete();
|
||||
this.data.delete();
|
||||
this.model.delete();
|
||||
const cleanup = (label: string, action: () => void) => {
|
||||
try {
|
||||
action();
|
||||
} catch (error) {
|
||||
console.warn(`[MuJoCo] 释放${label}失败`, error);
|
||||
}
|
||||
};
|
||||
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
@@ -29,6 +29,9 @@
|
||||
--ui-success-border: #a7ddca;
|
||||
--ui-scrollbar: #a9b5c4;
|
||||
--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;
|
||||
}
|
||||
.theme-dark {
|
||||
@@ -58,6 +61,9 @@
|
||||
--ui-success-border: #285f50;
|
||||
--ui-scrollbar: #46566c;
|
||||
--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;
|
||||
}
|
||||
@layer base {
|
||||
@@ -106,15 +112,91 @@
|
||||
.technical-value {
|
||||
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 {
|
||||
isolation: isolate;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 50% 18%,
|
||||
color-mix(in srgb, var(--ui-accent) 8%, transparent),
|
||||
transparent 34%
|
||||
circle at 50% 14%,
|
||||
color-mix(in srgb, var(--ui-accent) 10%, transparent),
|
||||
transparent 35%
|
||||
),
|
||||
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 {
|
||||
animation: workspace-enter 360ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
box-shadow:
|
||||
@@ -134,6 +216,25 @@
|
||||
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 {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -1 +1,22 @@
|
||||
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
Reference in New Issue
Block a user