diff --git a/.gitignore b/.gitignore index e264caff..32bc15ea 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..52914f36 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +Please always speak chinese. +python 虚拟环境路径在/home/cen/Embodied_Workspace/Mujoco_Projects/mujoco/.venv/bin/activate +系统是Ubuntu 24.04 LTS \ No newline at end of file diff --git a/README.md b/README.md index 94c1aecb..8a30dc64 100644 --- a/README.md +++ b/README.md @@ -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 或训练日志。 ## 上游与许可证 diff --git a/context.md b/context.md new file mode 100644 index 00000000..9dd38e4b --- /dev/null +++ b/context.md @@ -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 外未改动项目。" +} +``` diff --git a/package-lock.json b/package-lock.json index e4fbea06..1a8b8740 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3fd8dfdb..8eb467d6 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/requirements-dev.txt b/requirements-dev.txt index 68e63057..2e813688 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1 +1,2 @@ +-r training_server/requirements.txt ruff==0.16.5 diff --git a/training_server/README.md b/training_server/README.md index 557c0922..377721ac 100644 --- a/training_server/README.md +++ b/training_server/README.md @@ -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 ``` diff --git a/training_server/requirements.txt b/training_server/requirements.txt new file mode 100644 index 00000000..176e96a7 --- /dev/null +++ b/training_server/requirements.txt @@ -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 diff --git a/training_server/rl/scripts/evaluate.py b/training_server/rl/scripts/evaluate.py new file mode 100644 index 00000000..6c78bc0b --- /dev/null +++ b/training_server/rl/scripts/evaluate.py @@ -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() diff --git a/training_server/rl/scripts/train.py b/training_server/rl/scripts/train.py index 8f38282c..5fc7f1e1 100644 --- a/training_server/rl/scripts/train.py +++ b/training_server/rl/scripts/train.py @@ -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,11 +138,14 @@ 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: - # Load checkpoint from local filesystem. - resume_path = get_checkpoint_path( - log_root_path, cfg.agent.load_run, cfg.agent.load_checkpoint - ) + 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 + ) # Only record videos on rank 0 to avoid multiple workers writing to the same files. if cfg.video and rank == 0: @@ -141,16 +173,32 @@ 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) + 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" + ) - runner.learn( - num_learning_iterations=cfg.agent.max_iterations, init_at_random_ep_len=True + 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() - 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 + 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) diff --git a/training_server/rl/src/tasks/velocity/config/go2/env_cfgs.py b/training_server/rl/src/tasks/velocity/config/go2/env_cfgs.py index 244d72da..e84ff8f9 100644 --- a/training_server/rl/src/tasks/velocity/config/go2/env_cfgs.py +++ b/training_server/rl/src/tasks/velocity/config/go2/env_cfgs.py @@ -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, diff --git a/training_server/rl/src/tasks/velocity/mdp/__init__.py b/training_server/rl/src/tasks/velocity/mdp/__init__.py index 1d3be528..0c83b1ad 100644 --- a/training_server/rl/src/tasks/velocity/mdp/__init__.py +++ b/training_server/rl/src/tasks/velocity/mdp/__init__.py @@ -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 diff --git a/training_server/rl/src/tasks/velocity/mdp/metrics.py b/training_server/rl/src/tasks/velocity/mdp/metrics.py new file mode 100644 index 00000000..937e5e73 --- /dev/null +++ b/training_server/rl/src/tasks/velocity/mdp/metrics.py @@ -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) diff --git a/training_server/rl/src/tasks/velocity/velocity_env_cfg.py b/training_server/rl/src/tasks/velocity/velocity_env_cfg.py index feebe378..1cc70db0 100644 --- a/training_server/rl/src/tasks/velocity/velocity_env_cfg.py +++ b/training_server/rl/src/tasks/velocity/velocity_env_cfg.py @@ -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), diff --git a/training_server/server.py b/training_server/server.py index e96e132d..f12524cd 100644 --- a/training_server/server.py +++ b/training_server/server.py @@ -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 - threading.Thread( - target=self._run, args=(job,), name=f"training-{job.id[:8]}", daemon=True - ).start() + 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, "接口不存在") - self._json(HTTPStatus.ACCEPTED, self.manager.start(self._payload())) + 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() diff --git a/training_server/tests/test_server.py b/training_server/tests/test_server.py index 46d2ab35..b0d7f8cf 100644 --- a/training_server/tests/test_server.py +++ b/training_server/tests/test_server.py @@ -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" diff --git a/training_server/tests/test_tuning.py b/training_server/tests/test_tuning.py new file mode 100644 index 00000000..e988ed27 --- /dev/null +++ b/training_server/tests/test_tuning.py @@ -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() diff --git a/training_server/tests/test_tuning_manager.py b/training_server/tests/test_tuning_manager.py new file mode 100644 index 00000000..a90d0c7d --- /dev/null +++ b/training_server/tests/test_tuning_manager.py @@ -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() diff --git a/training_server/tuning/__init__.py b/training_server/tuning/__init__.py new file mode 100644 index 00000000..d51a7928 --- /dev/null +++ b/training_server/tuning/__init__.py @@ -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", +] diff --git a/training_server/tuning/advisor.py b/training_server/tuning/advisor.py new file mode 100644 index 00000000..afeffed5 --- /dev/null +++ b/training_server/tuning/advisor.py @@ -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__} diff --git a/training_server/tuning/manager.py b/training_server/tuning/manager.py new file mode 100644 index 00000000..d2306012 --- /dev/null +++ b/training_server/tuning/manager.py @@ -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) diff --git a/training_server/tuning/process.py b/training_server/tuning/process.py new file mode 100644 index 00000000..0537e6f8 --- /dev/null +++ b/training_server/tuning/process.py @@ -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) diff --git a/training_server/tuning/schema.py b/training_server/tuning/schema.py new file mode 100644 index 00000000..e7a2c819 --- /dev/null +++ b/training_server/tuning/schema.py @@ -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() + } diff --git a/training_server/tuning/scoring.py b/training_server/tuning/scoring.py new file mode 100644 index 00000000..028effe5 --- /dev/null +++ b/training_server/tuning/scoring.py @@ -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, + } diff --git a/training_server/tuning/storage.py b/training_server/tuning/storage.py new file mode 100644 index 00000000..cfba8abd --- /dev/null +++ b/training_server/tuning/storage.py @@ -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 + ] diff --git a/training_server/tuning/study.py b/training_server/tuning/study.py new file mode 100644 index 00000000..1db8b639 --- /dev/null +++ b/training_server/tuning/study.py @@ -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), + } diff --git a/training_server/tuning/tensorboard.py b/training_server/tuning/tensorboard.py new file mode 100644 index 00000000..7cca4876 --- /dev/null +++ b/training_server/tuning/tensorboard.py @@ -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) diff --git a/web_platform/ARCHITECTURE.md b/web_platform/ARCHITECTURE.md index 050c99c9..cd618cbd 100644 --- a/web_platform/ARCHITECTURE.md +++ b/web_platform/ARCHITECTURE.md @@ -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/`。 diff --git a/web_platform/README.md b/web_platform/README.md index aa53bfe6..963e2db9 100644 --- a/web_platform/README.md +++ b/web_platform/README.md @@ -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)。 diff --git a/web_platform/e2e/app.spec.ts b/web_platform/e2e/app.spec.ts index 3c067464..74822e3c 100644 --- a/web_platform/e2e/app.spec.ts +++ b/web_platform/e2e/app.spec.ts @@ -77,6 +77,14 @@ const LARGE_MODEL = ` `; +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( + '', + ), + }); + 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 }) => { diff --git a/web_platform/src/app/App.tsx b/web_platform/src/app/App.tsx index 3d756c91..852fb76a 100644 --- a/web_platform/src/app/App.tsx +++ b/web_platform/src/app/App.tsx @@ -5,6 +5,7 @@ import { Suspense, useCallback, useEffect, + useMemo, useRef, useState, type ChangeEvent, @@ -18,6 +19,7 @@ import { CircleHelp, Code2, Crosshair, + Database, Download, Hand, Maximize, @@ -27,6 +29,7 @@ import { Play, RotateCcw, Settings as SettingsIcon, + SlidersHorizontal, SunMoon, } from 'lucide-react'; import { DEFAULT_IMPORT_LIMITS, type MapEntry, type ProjectManifest } from '../project/types'; @@ -54,8 +57,17 @@ import { import { useAppStore, type AppDiagnostic } from '../stores/useAppStore'; import { WorkbenchHeader } from './components/WorkbenchHeader'; import { ViewerToolDock } from './components/ViewerToolDock'; +import { + MapAssetDropIndicator, + MapDraftStatusOverlay, + MapViewportToolbar, + type MapAssetDropTarget, +} from './components/MapViewportTools'; import { ModelControlsSidebar } from './components/ModelControlsSidebar'; -import { ProjectSidebar } from './components/ProjectSidebar'; +import { ProjectSidebar, type ProjectResourceTab } from './components/ProjectSidebar'; +import type { WorkspaceTool } from './components/WorkspaceToolsPanel'; +import type { EditorSelection } from './editorSelection'; +import { useMapEditorShortcuts } from './hooks/useMapEditorShortcuts'; import { WorkspaceOverlays, type ImportProgress } from './components/WorkspaceOverlays'; import { EntrySelectionDialog } from './components/EntrySelectionDialog'; import { ErrorRecoveryPanel } from './components/ErrorRecoveryPanel'; @@ -87,36 +99,59 @@ import { upsertCachedMjcf, } from '../project/cachedFiles'; import { + createPlacedMapAsset, DEFAULT_MAP_SELECTION, DEFAULT_PHYSICAL_MAP_CONFIG, + PHYSICAL_MAP_PRESET_LABELS, + mapLocalPointToWorld, + mapSelectionTransform, + mapWorldPointToLocal, + updatePlacedMapAsset, type MapSelection, + type PlacedMapAsset, + type PlacedMapSelection, type SystemTerrainPreset, } from '../map/types'; -import { discoverMapEntries, resolveProjectMap, visualMapAsset } from '../map/MapLoader'; -import { decodeMapDefinition } from '../map/mapSchema'; +import { discoverMapEntries, resolveProjectMap, visualMapAssets } from '../map/MapLoader'; import { decodeEditableMapDocument, encodeEditableMapDocument } from '../map/editor/editorSchema'; import type { EditableMapDocument, EditableMapObjectType, MapEditorInteractionCallbacks, + MapEditorSessionState, MapEditorTransformMode, MapObjectPlacementMode, } from '../map/editor/types'; -import { isMapObjectPlacementMode } from '../map/editor/types'; -import { - isEditableMapObjectType, - MAP_ASSET_DRAG_MIME, - MAP_ASSET_PLACEMENT_MIME, -} from '../map/editor/assetCatalog'; +import { decodeMapLibraryDragPayload, MAP_LIBRARY_DRAG_MIME } from '../map/editor/assetCatalog'; import { compileEditableMapDocument } from '../map/editor/MapDocumentCompiler'; +import { materializeEditableMapDrafts } from '../map/editor/EditableMapDraftCommit'; +import { findCompiledEditableMapPick } from '../map/editor/compiledMapPick'; +import { mapSceneSurfaceHeightAt } from '../map/sceneSurface'; import { importEditableMapDocument } from '../map/editor/MapDocumentImporter'; import { resolveProjectAssetPath } from '../map/mapPaths'; +import { + clonePlacedMapAssets, + mapEditorDraftPreviewInstances, + resolveMapSceneLoadAssets, + restoreAppliedMapScene, + summarizeMapSceneDraft, + transformParametricMapAsset, +} from '../map/mapSceneDraft'; const SourceEditorDialog = lazy(() => import('./components/SourceEditorDialog').then((module) => ({ default: module.SourceEditorDialog, })), ); +const WorkspaceToolsPanel = lazy(() => + import('./components/WorkspaceToolsPanel').then((module) => ({ + default: module.WorkspaceToolsPanel, + })), +); + +function hasTransferType(dataTransfer: DataTransfer, type: string): boolean { + return Array.from(dataTransfer.types).includes(type); +} function diagnostic( category: AppDiagnostic['category'], @@ -178,6 +213,30 @@ function urdfLinkNames(project: ProjectManifest | null, path: string | undefined .filter((name): name is string => Boolean(name)); } +function omitManifestFiles( + manifest: ProjectManifest, + omittedPaths: ReadonlySet, +): ProjectManifest { + if (!omittedPaths.size) return manifest; + const files = manifest.files.filter((file) => !omittedPaths.has(file.path)); + return { + ...manifest, + files, + maps: discoverMapEntries(files), + totalBytes: files.reduce((total, file) => total + file.size, 0), + }; +} + +function manifestEditorDocuments(manifest: ProjectManifest): Map { + const documents = new Map(); + for (const map of manifest.maps) { + if (!map.authoringPath) continue; + const file = manifest.files.find((candidate) => candidate.path === map.authoringPath); + if (file) documents.set(map.descriptorPath, decodeEditableMapDocument(file.data)); + } + return documents; +} + export function App() { const state = useAppStore( useShallow((value) => ({ @@ -211,15 +270,26 @@ export function App() { importInFlight = useRef(false), adapter = useRef(new MainThreadPhysicsAdapter()), root = useRef(null), + viewportShell = useRef(null), viewerHost = useRef(null), viewer = useRef(null), viewerReady = useRef | null>(null), dragDepth = useRef(0), editorInteraction = useRef(null), + editorDraftsRef = useRef>(new Map()), + provisionalMapFilesRef = useRef>(new Map()), + pendingEditorObjectId = useRef(undefined), + compiledMapBodyInteraction = useRef<(bodyName: string) => boolean>(() => false), + mapEditorPreviewInteraction = useRef<(mapAssetId: string, objectId: string) => void>(() => {}), + parametricMapInteraction = useRef<{ + onSelect(id: string | null): void; + onTransform(id: string, position: [number, number], yawDeg: number): void; + } | null>(null), pendingMapAsset = useRef<{ type: EditableMapObjectType; position?: [number, number, number]; placementMode: MapObjectPlacementMode; + externalSupportTop?: number; } | null>(null), urdfEnhancementsRef = useRef({ addActuators: true, @@ -251,15 +321,32 @@ export function App() { [policyStatus, setPolicyStatus] = useState(), [projectMaps, setProjectMaps] = useState([]), [editorDocument, setEditorDocument] = useState(null), - [projectSidebarTab, setProjectSidebarTab] = useState<'project' | 'structure' | 'assets'>( - 'project', - ); + [editorDrafts, setEditorDrafts] = useState>(() => new Map()), + [committedEditorDocuments, setCommittedEditorDocuments] = useState< + Map + >(() => new Map()), + [projectSidebarTab, setProjectSidebarTab] = useState('assets'), + [editorSelection, setEditorSelection] = useState(null), + [workspaceTool, setWorkspaceTool] = useState(null), + [mapTransformMode, setMapTransformMode] = useState('translate'), + [mapSnapping, setMapSnapping] = useState(true), + [assetPlacementMode, setAssetPlacementMode] = useState('auto_ground'), + [editorSessionStates, setEditorSessionStates] = useState>( + () => new Map(), + ), + [mapAssetDropTarget, setMapAssetDropTarget] = useState(); const [urdfMode, setUrdfMode] = useState('mjcf'), urdfModeRef = useRef('mjcf'); const [baseMode, setBaseMode] = useState('floating'), baseModeRef = useRef('floating'); const [mapSelection, setMapSelection] = useState(DEFAULT_MAP_SELECTION), mapSelectionRef = useRef(DEFAULT_MAP_SELECTION), + [placedMapAssets, setPlacedMapAssets] = useState([]), + placedMapAssetsRef = useRef([]), + [appliedMapAssets, setAppliedMapAssets] = useState([]), + appliedMapAssetsRef = useRef([]), + [activeMapAssetId, setActiveMapAssetId] = useState(), + activeMapAssetIdRef = useRef(undefined), [showVisualMap, setShowVisualMap] = useState(true), [showMapCollision, setShowMapCollision] = useState(false); const [displayOptions, setDisplayOptions] = useState(initialDisplayOptions), @@ -271,6 +358,47 @@ export function App() { const showCollision = displayOptions.showCollision, setShowCollision = (value: boolean) => setDisplayOptions((options) => ({ ...options, showCollision: value })); + const mapSceneDraft = useMemo( + () => summarizeMapSceneDraft(placedMapAssets, appliedMapAssets), + [placedMapAssets, appliedMapAssets], + ); + const pendingSceneIds = useMemo(() => { + const ids = new Set(mapSceneDraft.changedIds); + for (const asset of placedMapAssets) + if (asset.selection.kind === 'project' && editorDrafts.has(asset.selection.descriptorPath)) + ids.add(asset.id); + return [...ids]; + }, [mapSceneDraft.changedIds, placedMapAssets, editorDrafts]); + const editorOnlyDraftCount = useMemo(() => { + const changedIds = new Set(mapSceneDraft.changedIds); + const coveredDescriptors = new Set( + placedMapAssets.flatMap((asset) => + changedIds.has(asset.id) && asset.selection.kind === 'project' + ? [asset.selection.descriptorPath] + : [], + ), + ); + return [...editorDrafts.keys()] + .filter((path) => !coveredDescriptors.has(path)) + .reduce( + (count, path) => count + Math.max(1, editorSessionStates.get(path)?.changeCount ?? 1), + 0, + ); + }, [mapSceneDraft.changedIds, placedMapAssets, editorDrafts, editorSessionStates]); + const sceneDraftChangeCount = mapSceneDraft.changeCount + editorOnlyDraftCount, + mapSceneDirty = sceneDraftChangeCount > 0; + const activeEditorView = + mapSelection.kind === 'project' + ? (editorDrafts.get(mapSelection.descriptorPath) ?? editorDocument) + : null; + const selectedMapObject = + editorSelection?.kind === 'map-object' && editorSelection.mapAssetId === activeMapAssetId + ? activeEditorView?.objects.find((object) => object.id === editorSelection.objectId) + : undefined; + const mapEditingActive = + Boolean(activeMapAssetId) && + (editorSelection?.kind === 'map' || editorSelection?.kind === 'map-object'); + const activePlacementMode = selectedMapObject?.placementMode ?? assetPlacementMode; const viewerSettings = useRef({ mode: state.mode, forceScale, @@ -307,7 +435,15 @@ export function App() { .then(({ MuJoCoViewer: Viewer }) => { if (!active) return null; const next = new Viewer(host, { - onSelection: state.setSelection, + onSelection: (selection) => { + if (selection && compiledMapBodyInteraction.current(selection.bodyName)) { + state.setSelection(null); + return; + } + state.setSelection(selection); + setEditorSelection(selection ? { kind: 'body', bodyId: selection.bodyId } : null); + if (selection) setRightOpen(true); + }, onFrame: (frame, fps, snapshot) => { const memory = (performance as Performance & { memory?: { usedJSHeapSize: number } }) .memory?.usedJSHeapSize; @@ -333,7 +469,23 @@ export function App() { diagnostic(error.message.includes('控制器') ? '仿真' : '渲染', error), ); }, - onMapEditorSelect: (id) => editorInteraction.current?.onSelect(id), + onMapEditorSelect: (id) => { + editorInteraction.current?.onSelect(id); + const mapAssetId = activeMapAssetIdRef.current; + setEditorSelection( + mapAssetId + ? id + ? { kind: 'map-object', mapAssetId, objectId: id } + : { kind: 'map', mapAssetId } + : null, + ); + if (mapAssetId) setRightOpen(true); + }, + onMapEditorPreviewSelect: (mapAssetId, objectId) => + mapEditorPreviewInteraction.current(mapAssetId, objectId), + onParametricMapSelect: (id) => parametricMapInteraction.current?.onSelect(id), + onParametricMapTransform: (id, position, yawDeg) => + parametricMapInteraction.current?.onTransform(id, position, yawDeg), onMapEditorTransform: (id, position, quaternion, scale) => editorInteraction.current?.onTransform({ id, position, quaternion, scale }), }); @@ -349,6 +501,11 @@ export function App() { next.setMapDisplay(settings.showVisualMap, settings.showMapCollision); next.setShowSensorCamera(settings.showSensorCamera); next.setTheme(settings.theme); + next.setParametricMapAssets( + placedMapAssetsRef.current, + summarizeMapSceneDraft(placedMapAssetsRef.current, appliedMapAssetsRef.current) + .changedIds, + ); return next; }) .catch((error) => { @@ -372,6 +529,12 @@ export function App() { useEffect(() => { viewer.current?.setMode(state.mode); }, [state.mode]); + useEffect(() => { + viewer.current?.setMapEditorTransformMode(mapTransformMode); + }, [mapTransformMode]); + useEffect(() => { + viewer.current?.setMapEditorSnapping(mapSnapping ? 0.1 : null, mapSnapping ? 5 : null); + }, [mapSnapping]); useEffect(() => { if (viewer.current) viewer.current.forceScale = forceScale; }, [forceScale]); @@ -386,6 +549,9 @@ export function App() { useEffect(() => { viewer.current?.setMapDisplay(showVisualMap, showMapCollision); }, [showVisualMap, showMapCollision]); + useEffect(() => { + viewer.current?.setParametricMapAssets(placedMapAssets, mapSceneDraft.changedIds); + }, [placedMapAssets, mapSceneDraft.changedIds]); useEffect(() => { if (window.innerWidth < 900) return; try { @@ -414,129 +580,171 @@ export function App() { document.addEventListener('fullscreenchange', change); return () => document.removeEventListener('fullscreenchange', change); }, []); - const loadEntry = useCallback(async (path: string, requestedMode?: UrdfLoadMode) => { - if (!manifest.current || loadInFlight.current) return false; - const previousEntry = useAppStore.getState().selectedEntry; - loadInFlight.current = true; - setIgnoreJointLimits(false); - setControllerStatus(undefined); - setPolicyStatus(undefined); - state.setEntry(path); - state.setLoading(true); - setImportProgress({ - title: '正在准备仿真', - label: '初始化三维视口', - detail: path, - value: 0.4, - }); - state.setDiagnostic(undefined); - setGeneratedMjcf(undefined); - setGeneratedMjcfPath(undefined); - adapter.current.setPaused(true); - try { - const activeViewer = viewer.current ?? (await viewerReady.current); - if (!activeViewer) throw new Error('三维视口尚未就绪,请重试'); - const snapshot = await adapter.current.load(manifest.current, path, { - urdfMode: requestedMode ?? urdfModeRef.current, - baseMode: baseModeRef.current, - enhancements: urdfEnhancementsRef.current, - map: mapSelectionRef.current, - onProgress: ({ value, label }) => - setImportProgress({ - title: '正在准备仿真', - label, - detail: path, - value: 0.4 + value * 0.53, - }), - }); - const supportFiles = adapter.current.cachedSupportFiles(); + const loadEntry = useCallback( + async ( + path: string, + requestedMode?: UrdfLoadMode, + requestedSceneAssets?: readonly PlacedMapAsset[], + ) => { + if (!manifest.current || loadInFlight.current) return false; + // 普通模型重载始终使用上次成功应用的地图基线。只有场景提交入口可以显式 + // 传入草稿实例,防止切换模型/URDF 模式时把一半场景静默提前提交。 + const sceneAssets = resolveMapSceneLoadAssets( + appliedMapAssetsRef.current, + requestedSceneAssets, + ); + const previousState = useAppStore.getState(); + const previousEntry = previousState.selectedEntry; + const previousPaused = previousState.paused; + loadInFlight.current = true; + setIgnoreJointLimits(false); + setControllerStatus(undefined); + setPolicyStatus(undefined); + state.setEntry(path); + state.setLoading(true); setImportProgress({ title: '正在准备仿真', - label: '创建三维场景', + label: '初始化三维视口', detail: path, - value: 0.94, + value: 0.4, }); - adapter.current.setSpeed(useAppStore.getState().speed); - try { - activeViewer.attach(adapter.current.session); - } catch (error) { - adapter.current.rollbackRetired(); - activeViewer.attach(adapter.current.session); - throw error; - } - adapter.current.releaseRetired(); - if (supportFiles.length && manifest.current) { - manifest.current = mergeCachedFiles(manifest.current, supportFiles); - state.setProject( - manifest.current.name, - manifest.current.files.map((file) => ({ path: file.path, size: file.size })), - manifest.current.entries, - path, - ); - } - state.setSnapshot(snapshot); - state.setSelection(null); + state.setDiagnostic(undefined); + setGeneratedMjcf(undefined); + setGeneratedMjcfPath(undefined); + adapter.current.setPaused(true); state.setPaused(true); - setImportProgress({ - title: '正在准备仿真', - label: '加载视觉地图与材质', - detail: path, - value: 0.97, - }); - await activeViewer.setVisualMap(null); - let visualMapWarning: string | undefined; + let attachedViewer: MuJoCoViewer | null = null; + let sessionSwapped = false; try { - const asset = manifest.current - ? visualMapAsset(manifest.current, mapSelectionRef.current) - : null; - await activeViewer.setVisualMap(asset); + const activeViewer = viewer.current ?? (await viewerReady.current); + if (!activeViewer) throw new Error('三维视口尚未就绪,请重试'); + const snapshot = await adapter.current.load(manifest.current, path, { + urdfMode: requestedMode ?? urdfModeRef.current, + baseMode: baseModeRef.current, + enhancements: urdfEnhancementsRef.current, + mapAssets: sceneAssets, + onProgress: ({ value, label }) => + setImportProgress({ + title: '正在准备仿真', + label, + detail: path, + value: 0.4 + value * 0.53, + }), + }); + const supportFiles = adapter.current.cachedSupportFiles(); + setImportProgress({ + title: '正在准备仿真', + label: '创建三维场景', + detail: path, + value: 0.94, + }); + adapter.current.setSpeed(useAppStore.getState().speed); + try { + activeViewer.attach(adapter.current.session); + attachedViewer = activeViewer; + sessionSwapped = true; + } catch (error) { + adapter.current.rollbackRetired(); + activeViewer.attach(adapter.current.session); + throw error; + } + if (supportFiles.length && manifest.current) { + manifest.current = mergeCachedFiles(manifest.current, supportFiles); + state.setProject( + manifest.current.name, + manifest.current.files.map((file) => ({ path: file.path, size: file.size })), + manifest.current.entries, + path, + ); + } + state.setSnapshot(snapshot); + state.setSelection(null); + setEditorSelection(null); + state.setPaused(true); + setImportProgress({ + title: '正在准备仿真', + label: '加载视觉地图与材质', + detail: path, + value: 0.97, + }); + await activeViewer.setVisualMaps([]); + let visualMapWarning: string | undefined; + try { + const assets = manifest.current + ? visualMapAssets(manifest.current, placedMapAssetsRef.current) + : []; + await activeViewer.setVisualMaps(assets); + } catch (error) { + visualMapWarning = `视觉地图加载失败:${error instanceof Error ? error.message : String(error)}`; + console.warn('[MuJoCo] 视觉地图加载失败', error); + } + try { + setGeneratedMjcf(new TextDecoder().decode(adapter.current.exportMjcf())); + setGeneratedMjcfPath(convertedCachePath(path)); + } catch (error) { + console.warn('[MuJoCo] 无法生成源码预览', error); + } + const notice: WorkbenchNotification = { + id: ++notificationId.current, + title: + snapshot.warnings.length || visualMapWarning + ? `模型已加载 · ${snapshot.warnings.length + (visualMapWarning ? 1 : 0)} 项兼容调整` + : '模型加载完成', + detail: + [...snapshot.warnings, ...(visualMapWarning ? [visualMapWarning] : [])].join('\n') || + path, + tone: snapshot.warnings.length || visualMapWarning ? 'warning' : 'success', + at: Date.now(), + }; + setNotifications((items) => [notice, ...items].slice(0, 20)); + setToast(notice); + if (requestedSceneAssets !== undefined) { + const committedMapAssets = clonePlacedMapAssets(sceneAssets); + appliedMapAssetsRef.current = committedMapAssets; + setAppliedMapAssets(committedMapAssets); + } + activeViewer.setParametricMapAssets( + placedMapAssetsRef.current, + summarizeMapSceneDraft(placedMapAssetsRef.current, appliedMapAssetsRef.current) + .changedIds, + ); + adapter.current.releaseRetired(); + sessionSwapped = false; + return true; } catch (error) { - visualMapWarning = `视觉地图加载失败:${error instanceof Error ? error.message : String(error)}`; - console.warn('[MuJoCo] 视觉地图加载失败', error); + if (sessionSwapped && attachedViewer) { + try { + adapter.current.rollbackRetired(); + attachedViewer.attach(adapter.current.session); + } catch (rollbackError) { + console.error('[MuJoCo] 无法恢复上一仿真会话', rollbackError); + } + } + state.setDiagnostic(diagnostic('模型编译', error, path)); + const notice: WorkbenchNotification = { + id: ++notificationId.current, + title: '模型编译失败', + detail: error instanceof Error ? error.message : String(error), + tone: 'danger', + at: Date.now(), + }; + setNotifications((items) => [notice, ...items].slice(0, 20)); + setToast(notice); + const retained = adapter.current.snapshot(); + if (retained && previousEntry) state.setEntry(previousEntry); + adapter.current.setPaused(previousPaused); + state.setPaused(previousPaused); + setControllerStatus(retained?.controller); + setPolicyStatus(retained?.rlPolicy); + return false; + } finally { + loadInFlight.current = false; + setImportProgress(undefined); + state.setLoading(false); } - try { - setGeneratedMjcf(new TextDecoder().decode(adapter.current.exportMjcf())); - setGeneratedMjcfPath(convertedCachePath(path)); - } catch (error) { - console.warn('[MuJoCo] 无法生成源码预览', error); - } - const notice: WorkbenchNotification = { - id: ++notificationId.current, - title: - snapshot.warnings.length || visualMapWarning - ? `模型已加载 · ${snapshot.warnings.length + (visualMapWarning ? 1 : 0)} 项兼容调整` - : '模型加载完成', - detail: - [...snapshot.warnings, ...(visualMapWarning ? [visualMapWarning] : [])].join('\n') || - path, - tone: snapshot.warnings.length || visualMapWarning ? 'warning' : 'success', - at: Date.now(), - }; - setNotifications((items) => [notice, ...items].slice(0, 20)); - setToast(notice); - return true; - } catch (error) { - state.setDiagnostic(diagnostic('模型编译', error, path)); - const notice: WorkbenchNotification = { - id: ++notificationId.current, - title: '模型编译失败', - detail: error instanceof Error ? error.message : String(error), - tone: 'danger', - at: Date.now(), - }; - setNotifications((items) => [notice, ...items].slice(0, 20)); - setToast(notice); - const retained = adapter.current.snapshot(); - if (retained && previousEntry) state.setEntry(previousEntry); - setControllerStatus(retained?.controller); - setPolicyStatus(retained?.rlPolicy); - return false; - } finally { - loadInFlight.current = false; - setImportProgress(undefined); - state.setLoading(false); - } - }, []); + }, + [], + ); const requestLoadEntry = useCallback( async (path: string) => { const entry = manifest.current?.entries.find((candidate) => candidate.path === path); @@ -599,9 +807,21 @@ export function App() { }); manifest.current = next; setProjectMaps(next.maps); - setProjectSidebarTab('project'); + setCommittedEditorDocuments(manifestEditorDocuments(next)); + setProjectSidebarTab('assets'); + setEditorSelection(null); setEditorDocument(null); + editorDraftsRef.current = new Map(); + setEditorDrafts(new Map()); + provisionalMapFilesRef.current.clear(); viewer.current?.setMapEditorDocument(null); + placedMapAssetsRef.current = []; + setPlacedMapAssets([]); + appliedMapAssetsRef.current = []; + setAppliedMapAssets([]); + viewer.current?.setParametricMapAssets([], []); + activeMapAssetIdRef.current = undefined; + setActiveMapAssetId(undefined); mapSelectionRef.current = DEFAULT_MAP_SELECTION; setMapSelection(DEFAULT_MAP_SELECTION); setSelectedControllerPath(next.files.find((file) => /\.py$/i.test(file.path))?.path); @@ -642,7 +862,7 @@ export function App() { if (state.projectName) setRemoveConfirmOpen(true); }; const confirmRemoveProject = () => { - void viewer.current?.setVisualMap(null); + void viewer.current?.setVisualMaps([]); viewer.current?.attach(null); adapter.current.dispose(); adapter.current = new MainThreadPhysicsAdapter(); @@ -656,9 +876,22 @@ export function App() { setSelectedPolicyPath(undefined); setPolicyStatus(undefined); setProjectMaps([]); - setProjectSidebarTab('project'); + setCommittedEditorDocuments(new Map()); + setProjectSidebarTab('assets'); + setEditorSelection(null); setEditorDocument(null); + editorDraftsRef.current = new Map(); + setEditorDrafts(new Map()); + setEditorSessionStates(new Map()); + provisionalMapFilesRef.current.clear(); viewer.current?.setMapEditorDocument(null); + placedMapAssetsRef.current = []; + setPlacedMapAssets([]); + appliedMapAssetsRef.current = []; + setAppliedMapAssets([]); + viewer.current?.setParametricMapAssets([], []); + activeMapAssetIdRef.current = undefined; + setActiveMapAssetId(undefined); mapSelectionRef.current = DEFAULT_MAP_SELECTION; setMapSelection(DEFAULT_MAP_SELECTION); state.clearProject(); @@ -681,34 +914,125 @@ export function App() { if (entry?.format === 'urdf' && urdfModeRef.current === 'mjcf') void loadEntry(entry.path, 'mjcf'); }; + const editorSurfaceHeight = useCallback( + (position: readonly [number, number, number]): number | null => { + const selection = mapSelectionRef.current; + const worldPosition = + selection.kind === 'project' + ? mapLocalPointToWorld(position, selection) + : ([...position] as [number, number, number]); + return mapSceneSurfaceHeightAt( + placedMapAssetsRef.current, + worldPosition[0], + worldPosition[1], + ); + }, + [], + ); const previewEditorDocument = useCallback((document: EditableMapDocument | null) => { - viewer.current?.setMapEditorDocument(document); + const selection = mapSelectionRef.current; + viewer.current?.setMapEditorDocument( + document, + selection.kind === 'project' ? mapSelectionTransform(selection) : undefined, + ); if (document) { adapter.current.setPaused(true); state.setPaused(true); } }, []); + const updateEditorDraft = useCallback( + (descriptorPath: string, document: EditableMapDocument, dirty: boolean) => { + if (loadInFlight.current) return; + const drafts = new Map(editorDraftsRef.current); + if (dirty) drafts.set(descriptorPath, structuredClone(document)); + else drafts.delete(descriptorPath); + editorDraftsRef.current = drafts; + setEditorDrafts(drafts); + }, + [], + ); + const clearEditorDrafts = useCallback(() => { + editorDraftsRef.current = new Map(); + setEditorDrafts(new Map()); + setEditorSessionStates(new Map()); + }, []); const bindEditorInteraction = useCallback((callbacks: MapEditorInteractionCallbacks | null) => { editorInteraction.current = callbacks; const pending = pendingMapAsset.current; if (callbacks && pending) { pendingMapAsset.current = null; - callbacks.onAddAsset(pending.type, pending.position, pending.placementMode); + callbacks.onAddAsset( + pending.type, + pending.position, + pending.placementMode, + pending.externalSupportTop, + ); + } + const objectId = pendingEditorObjectId.current; + if (callbacks && objectId) { + pendingEditorObjectId.current = undefined; + callbacks.onSelect(objectId); } }, []); const selectEditorObject = useCallback((id: string | null) => { viewer.current?.selectMapEditorObject(id); + const mapAssetId = activeMapAssetIdRef.current; + setEditorSelection( + mapAssetId + ? id + ? { kind: 'map-object', mapAssetId, objectId: id } + : { kind: 'map', mapAssetId } + : null, + ); }, []); - const setEditorTransformMode = useCallback((mode: MapEditorTransformMode) => { - viewer.current?.setMapEditorTransformMode(mode); - }, []); - const setEditorSnapping = useCallback( - (translation: number | null, rotationDegrees: number | null) => { - viewer.current?.setMapEditorSnapping(translation, rotationDegrees); + const updateEditorSessionState = useCallback( + (descriptorPath: string, sessionState: MapEditorSessionState | null) => { + setEditorSessionStates((current) => { + const next = new Map(current); + if (sessionState?.dirty) next.set(descriptorPath, sessionState); + else next.delete(descriptorPath); + return next; + }); }, [], ); - const readEditorDocument = (selection: MapSelection): EditableMapDocument | null => { + const activateMapEditing = useCallback(() => { + state.setMode('select'); + }, []); + const changeMapTransformMode = useCallback((nextMode: MapEditorTransformMode) => { + setMapTransformMode(nextMode); + state.setMode('select'); + }, []); + const changeMapPlacementMode = useCallback( + (placementMode: MapObjectPlacementMode) => { + setAssetPlacementMode(placementMode); + if (placementMode === 'locked') + setMapTransformMode((currentMode) => (currentMode === 'scale' ? 'translate' : currentMode)); + if (editorSelection?.kind === 'map-object') { + editorInteraction.current?.onSetPlacementMode(editorSelection.objectId, placementMode); + if (placementMode !== 'locked') + viewer.current?.flashMapEditorSurfaceAlignment(editorSelection.objectId); + } + }, + [editorSelection], + ); + const alignSelectedMapObject = useCallback(() => { + if (editorSelection?.kind !== 'map-object') return; + editorInteraction.current?.onAlignToSurface(editorSelection.objectId); + viewer.current?.flashMapEditorSurfaceAlignment(editorSelection.objectId); + }, [editorSelection]); + const focusSelectedObject = useCallback(() => { + const bodyId = + editorSelection?.kind === 'body' || editorSelection?.kind === 'joint' + ? editorSelection.bodyId + : undefined; + viewer.current?.focusSelection(bodyId); + }, [editorSelection]); + const deleteSelectedMapObject = useCallback(() => { + if (editorSelection?.kind === 'map-object') + editorInteraction.current?.onDelete(editorSelection.objectId); + }, [editorSelection]); + const readEditorDocument = useCallback((selection: MapSelection): EditableMapDocument | null => { if (selection.kind !== 'project' || !manifest.current) return null; const resolved = resolveProjectMap(manifest.current, selection.descriptorPath); if (!resolved.authoringPath) return null; @@ -716,11 +1040,231 @@ export function App() { (candidate) => candidate.path === resolved.authoringPath, ); return file ? decodeEditableMapDocument(file.data) : null; + }, []); + const sceneEditorDocuments = useMemo( + () => new Map([...committedEditorDocuments, ...editorDrafts]), + [committedEditorDocuments, editorDrafts], + ); + const mapEditorDraftPreviews = useMemo( + () => + mapEditorDraftPreviewInstances( + placedMapAssets, + pendingSceneIds, + sceneEditorDocuments, + activeMapAssetId, + ), + [placedMapAssets, pendingSceneIds, sceneEditorDocuments, activeMapAssetId], + ); + useEffect(() => { + let active = true; + const update = (activeViewer: MuJoCoViewer | null) => { + if (active) activeViewer?.setMapEditorPreviewInstances(mapEditorDraftPreviews); + }; + if (viewer.current) update(viewer.current); + else void viewerReady.current?.then(update); + return () => { + active = false; + }; + }, [mapEditorDraftPreviews]); + const selectionName = (selection: PlacedMapSelection): string => + selection.kind === 'builtin' + ? PHYSICAL_MAP_PRESET_LABELS[selection.config.preset] + : (projectMaps.find((map) => map.descriptorPath === selection.descriptorPath)?.name ?? + selection.descriptorPath.split('/').at(-2) ?? + '工程地图'); + const uniqueMapAssetName = ( + base: string, + assets: readonly PlacedMapAsset[] = placedMapAssetsRef.current, + excludedId?: string, + ): string => { + const names = new Set( + assets.filter((asset) => asset.id !== excludedId).map((asset) => asset.name), + ); + if (!names.has(base)) return base; + let index = 2; + while (names.has(`${base} ${index}`)) index += 1; + return `${base} ${index}`; }; + const setMapScene = useCallback( + (assets: PlacedMapAsset[], activeId: string | undefined, selection?: MapSelection) => { + const active = activeId ? assets.find((asset) => asset.id === activeId) : undefined; + const nextSelection = selection ?? active?.selection ?? DEFAULT_MAP_SELECTION; + if (activeMapAssetIdRef.current !== active?.id) { + viewer.current?.selectParametricMapAsset(null); + viewer.current?.selectMapEditorObject(null); + editorInteraction.current = null; + } + placedMapAssetsRef.current = assets; + setPlacedMapAssets(assets); + activeMapAssetIdRef.current = active?.id; + setActiveMapAssetId(active?.id); + mapSelectionRef.current = nextSelection; + setMapSelection(nextSelection); + if (nextSelection.kind === 'builtin') + setMapTransformMode((currentMode) => (currentMode === 'scale' ? 'translate' : currentMode)); + }, + [], + ); + const focusMapProperties = useCallback(() => { + setRightOpen(true); + }, []); + const previewVisualMapScene = useCallback( + (assets: readonly PlacedMapAsset[], reload: boolean) => { + const current = manifest.current; + const activeViewer = viewer.current; + if (!current || !activeViewer) return; + try { + const visuals = visualMapAssets(current, assets); + if (reload) + void activeViewer.setVisualMaps(visuals).catch((error) => { + console.warn('[MuJoCo] 视觉地图草稿预览失败', error); + }); + else activeViewer.setVisualMapTransforms(visuals); + } catch (error) { + console.warn('[MuJoCo] 无法解析视觉地图草稿', error); + } + }, + [], + ); + const stageMapSelectionDraft = useCallback( + (selection: PlacedMapSelection) => { + if (useAppStore.getState().loading || loadInFlight.current) return; + const activeId = activeMapAssetIdRef.current; + const active = activeId + ? placedMapAssetsRef.current.find((asset) => asset.id === activeId) + : undefined; + if (!active || active.selection.kind !== selection.kind) return; + if ( + active.selection.kind === 'project' && + selection.kind === 'project' && + active.selection.descriptorPath !== selection.descriptorPath + ) + return; + const assets = updatePlacedMapAsset(placedMapAssetsRef.current, active.id, selection); + setMapScene(assets, active.id, selection); + if (selection.kind === 'project') { + previewVisualMapScene(assets, false); + const committed = readEditorDocument(selection); + setEditorDocument(committed); + previewEditorDocument(editorDraftsRef.current.get(selection.descriptorPath) ?? committed); + } + adapter.current.setPaused(true); + useAppStore.getState().setPaused(true); + }, + [previewEditorDocument, previewVisualMapScene, readEditorDocument, setMapScene], + ); + const activateMapAsset = useCallback( + (id: string, objectId?: string) => { + if (useAppStore.getState().loading || loadInFlight.current) return; + const asset = placedMapAssetsRef.current.find((candidate) => candidate.id === id); + if (!asset) return; + pendingEditorObjectId.current = objectId; + viewer.current?.selectParametricMapAsset(null); + viewer.current?.selectMapEditorObject(null); + editorInteraction.current?.onSelect(null); + setMapScene(placedMapAssetsRef.current, id, asset.selection); + const committedEditorDocument = readEditorDocument(asset.selection); + const previewDocument = + asset.selection.kind === 'project' + ? (editorDraftsRef.current.get(asset.selection.descriptorPath) ?? committedEditorDocument) + : null; + setEditorDocument(committedEditorDocument); + previewEditorDocument(previewDocument); + if (objectId) { + viewer.current?.selectMapEditorObject(objectId); + if (editorInteraction.current) { + editorInteraction.current.onSelect(objectId); + pendingEditorObjectId.current = undefined; + } + } + setEditorSelection( + objectId + ? { kind: 'map-object', mapAssetId: id, objectId } + : { kind: 'map', mapAssetId: id }, + ); + useAppStore.getState().setSelection(null); + focusMapProperties(); + }, + [focusMapProperties, previewEditorDocument, readEditorDocument, setMapScene], + ); + useEffect(() => { + const interaction = (mapAssetId: string, objectId: string) => + activateMapAsset(mapAssetId, objectId); + mapEditorPreviewInteraction.current = interaction; + return () => { + if (mapEditorPreviewInteraction.current === interaction) + mapEditorPreviewInteraction.current = () => {}; + }; + }, [activateMapAsset]); + const selectCompiledEditableMapObject = useCallback( + (bodyName: string): boolean => { + const current = manifest.current; + if (!current || useAppStore.getState().loading || loadInFlight.current) return false; + const target = findCompiledEditableMapPick(bodyName, current, placedMapAssetsRef.current); + if (!target) return false; + activateMapAsset(target.mapAssetId, target.objectId); + adapter.current.setPaused(true); + useAppStore.getState().setPaused(true); + return true; + }, + [activateMapAsset], + ); + useEffect(() => { + compiledMapBodyInteraction.current = selectCompiledEditableMapObject; + return () => { + if (compiledMapBodyInteraction.current === selectCompiledEditableMapObject) + compiledMapBodyInteraction.current = () => false; + }; + }, [selectCompiledEditableMapObject]); + const selectParametricMapInViewport = useCallback( + (id: string | null) => { + if (!id) return; + const asset = placedMapAssetsRef.current.find((candidate) => candidate.id === id); + if (!asset || asset.selection.kind !== 'builtin' || asset.selection.config.preset === 'none') + return; + activateMapAsset(id); + if (activeMapAssetIdRef.current !== id) return; + viewer.current?.selectParametricMapAsset(id); + adapter.current.setPaused(true); + useAppStore.getState().setPaused(true); + useAppStore.getState().setSelection(null); + setEditorSelection({ kind: 'map', mapAssetId: id }); + setRightOpen(true); + }, + [activateMapAsset], + ); + const updateParametricMapTransform = useCallback( + (id: string, position: [number, number], yawDeg: number) => { + if (useAppStore.getState().loading || loadInFlight.current) return; + const next = transformParametricMapAsset(placedMapAssetsRef.current, id, position, yawDeg); + if (next === placedMapAssetsRef.current) return; + const assets = [...next]; + const active = assets.find((asset) => asset.id === id); + if (!active) return; + setMapScene(assets, id, active.selection); + setEditorDocument(null); + adapter.current.setPaused(true); + useAppStore.getState().setPaused(true); + setEditorSelection({ kind: 'map', mapAssetId: id }); + setRightOpen(true); + }, + [setMapScene], + ); + useEffect(() => { + const interaction = { + onSelect: selectParametricMapInViewport, + onTransform: updateParametricMapTransform, + }; + parametricMapInteraction.current = interaction; + return () => { + if (parametricMapInteraction.current === interaction) parametricMapInteraction.current = null; + }; + }, [selectParametricMapInViewport, updateParametricMapTransform]); const createEditableScene = async ( type: EditableMapObjectType, position?: [number, number, number], placementMode: MapObjectPlacementMode = 'auto_ground', + externalSupportTop = 0, ): Promise => { const current = manifest.current; const entryPath = state.selectedEntry; @@ -802,13 +1346,27 @@ export function App() { try { const maps = discoverMapEntries(files); const candidate: ProjectManifest = { ...current, files, maps, totalBytes }; - const selection: MapSelection = { kind: 'project', descriptorPath }; - pendingMapAsset.current = { type, position, placementMode }; + const selection: PlacedMapSelection = { + kind: 'project', + descriptorPath, + positionX: 0, + positionY: 0, + yawDeg: 0, + }; + const placed = createPlacedMapAsset(selection, uniqueMapAssetName(definition.name)); + pendingMapAsset.current = { type, position, placementMode, externalSupportTop }; manifest.current = candidate; - mapSelectionRef.current = selection; + provisionalMapFilesRef.current.set(descriptorPath, [ + descriptorPath, + physicsPath, + authoringPath, + ]); setProjectMaps(maps); + setCommittedEditorDocuments(manifestEditorDocuments(candidate)); + setMapScene([...placedMapAssetsRef.current, placed], placed.id, selection); + setEditorSelection({ kind: 'map', mapAssetId: placed.id }); + setRightOpen(true); setEditorDocument(document); - setMapSelection(selection); previewEditorDocument(document); state.setProject( candidate.name, @@ -830,109 +1388,246 @@ export function App() { placementMode: MapObjectPlacementMode = 'auto_ground', ) => { if (state.loading || loadInFlight.current) return; + focusMapProperties(); + const worldPosition = position; + const externalSupportTop = + position && placementMode === 'gravity' + ? (mapSceneSurfaceHeightAt(placedMapAssetsRef.current, position[0], position[1]) ?? 0) + : 0; const interaction = editorInteraction.current; if (interaction) { - interaction.onAddAsset(type, position, placementMode); + let localPosition = worldPosition; + const selection = mapSelectionRef.current; + if (worldPosition && selection.kind === 'project') + localPosition = mapWorldPointToLocal(worldPosition, selection); + interaction.onAddAsset(type, localPosition, placementMode, externalSupportTop); return; } - await createEditableScene(type, position, placementMode); + await createEditableScene(type, worldPosition, placementMode, externalSupportTop); + }; + const appendMapSelection = (selection: PlacedMapSelection, requestedName?: string): boolean => { + const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry); + if (!entry || state.loading || loadInFlight.current) return false; + if (entry.format === 'urdf' && urdfModeRef.current === 'native') { + state.setDiagnostic( + diagnostic('模型编译', new Error('原生 URDF 不能注入地图,请切换为转换模式'), entry.path), + ); + return false; + } + const name = uniqueMapAssetName(requestedName ?? selectionName(selection)); + const placed = createPlacedMapAsset(selection, name); + const assets = [...placedMapAssetsRef.current, placed]; + setMapScene(assets, placed.id, selection); + setEditorSelection({ kind: 'map', mapAssetId: placed.id }); + setRightOpen(true); + if (selection.kind === 'project') previewVisualMapScene(assets, true); + setEditorDocument(readEditorDocument(selection)); + viewer.current?.setMapEditorDocument(null); + adapter.current.setPaused(true); + useAppStore.getState().setPaused(true); + return true; + }; + const stageMapAssetRemoval = (id: string) => { + const previousAssets = placedMapAssetsRef.current; + const previousActiveId = activeMapAssetIdRef.current; + const nextAssets = previousAssets.filter((asset) => asset.id !== id); + if (nextAssets.length === previousAssets.length) return false; + const nextActive = + previousActiveId === id + ? (nextAssets.at(-1) ?? undefined) + : nextAssets.find((asset) => asset.id === previousActiveId); + setMapScene(nextAssets, nextActive?.id, nextActive?.selection); + setEditorSelection(nextActive ? { kind: 'map', mapAssetId: nextActive.id } : null); + setEditorDocument(nextActive ? readEditorDocument(nextActive.selection) : null); + viewer.current?.setMapEditorDocument(null); + return true; + }; + const removePlacedMapAsset = (id: string) => { + if (state.loading || loadInFlight.current) return; + const target = placedMapAssetsRef.current.find((asset) => asset.id === id); + if (!target || !stageMapAssetRemoval(id)) return; + if (target.selection.kind === 'project') + previewVisualMapScene(placedMapAssetsRef.current, true); + adapter.current.setPaused(true); + useAppStore.getState().setPaused(true); + }; + const commitMapScene = async ( + requestedDrafts: ReadonlyMap = editorDraftsRef.current, + ): Promise => { + const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry); + const current = manifest.current; + if (!entry || !current || state.loading || loadInFlight.current) return false; + if (entry.format === 'urdf' && urdfModeRef.current === 'native') { + state.setDiagnostic( + diagnostic('模型编译', new Error('原生 URDF 不能注入地图,请切换为转换模式'), entry.path), + ); + return false; + } + + const submittedDrafts = new Map( + [...requestedDrafts].map(([path, document]) => [path, structuredClone(document)]), + ); + let candidate: ProjectManifest; + try { + candidate = materializeEditableMapDrafts(current, submittedDrafts); + const referencedDescriptors = new Set( + placedMapAssetsRef.current.flatMap((asset) => + asset.selection.kind === 'project' ? [asset.selection.descriptorPath] : [], + ), + ); + const omittedPaths = new Set(); + for (const [descriptorPath, paths] of provisionalMapFilesRef.current) + if (!referencedDescriptors.has(descriptorPath)) + for (const path of paths) omittedPaths.add(path); + candidate = omitManifestFiles(candidate, omittedPaths); + if (candidate.totalBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes) + throw new Error('应用场景草稿后工程总大小超过 512 MiB'); + } catch (error) { + state.setDiagnostic(diagnostic('模型编译', error, entry.path)); + return false; + } + + manifest.current = candidate; + const loaded = await loadEntry(entry.path, undefined, placedMapAssetsRef.current); + if (!loaded) { + manifest.current = current; + return false; + } + + const loadedManifest = manifest.current ?? candidate; + const maps = discoverMapEntries(loadedManifest.files); + const committed: ProjectManifest = { ...loadedManifest, maps }; + manifest.current = committed; + provisionalMapFilesRef.current.clear(); + const remainingDrafts = new Map(editorDraftsRef.current); + for (const [path, submitted] of submittedDrafts) { + const latest = remainingDrafts.get(path); + if (latest && JSON.stringify(latest) === JSON.stringify(submitted)) + remainingDrafts.delete(path); + } + editorDraftsRef.current = remainingDrafts; + setEditorDrafts(remainingDrafts); + setEditorSessionStates((currentStates) => { + const nextStates = new Map(currentStates); + for (const path of submittedDrafts.keys()) + if (!remainingDrafts.has(path)) nextStates.delete(path); + return nextStates; + }); + setProjectMaps(maps); + setCommittedEditorDocuments(manifestEditorDocuments(committed)); + const committedEditorDocument = readEditorDocument(mapSelectionRef.current); + const remainingEditorDraft = + mapSelectionRef.current.kind === 'project' + ? remainingDrafts.get(mapSelectionRef.current.descriptorPath) + : undefined; + setEditorDocument(committedEditorDocument); + previewEditorDocument(remainingEditorDraft ?? committedEditorDocument); + state.setProject( + committed.name, + committed.files.map((file) => ({ path: file.path, size: file.size })), + committed.entries, + entry.path, + ); + state.setSnapshot(adapter.current.snapshot() ?? undefined); + const selectedMapAssetId = activeMapAssetIdRef.current; + setEditorSelection(selectedMapAssetId ? { kind: 'map', mapAssetId: selectedMapAssetId } : null); + return true; + }; + const discardMapSceneDraft = () => { + if (state.loading || loadInFlight.current) return; + editorInteraction.current?.onDiscard(); + clearEditorDrafts(); + const omittedPaths = new Set(); + for (const paths of provisionalMapFilesRef.current.values()) + for (const path of paths) omittedPaths.add(path); + if (manifest.current && omittedPaths.size) { + const restoredManifest = omitManifestFiles(manifest.current, omittedPaths); + manifest.current = restoredManifest; + provisionalMapFilesRef.current.clear(); + setProjectMaps(restoredManifest.maps); + setCommittedEditorDocuments(manifestEditorDocuments(restoredManifest)); + state.setProject( + restoredManifest.name, + restoredManifest.files.map((file) => ({ path: file.path, size: file.size })), + restoredManifest.entries, + state.selectedEntry, + ); + state.setSnapshot(adapter.current.snapshot() ?? undefined); + } + const assets = restoreAppliedMapScene(appliedMapAssetsRef.current); + const active = + assets.find((asset) => asset.id === activeMapAssetIdRef.current) ?? assets.at(-1); + setMapScene(assets, active?.id, active?.selection); + setEditorSelection(active ? { kind: 'map', mapAssetId: active.id } : null); + previewVisualMapScene(assets, true); + setEditorDocument(active ? readEditorDocument(active.selection) : null); + viewer.current?.setMapEditorDocument(null); }; const applyMapSelection = (value: MapSelection) => { - const previous = mapSelectionRef.current; const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry); if (!entry) return; - if (entry.format === 'urdf' && urdfModeRef.current === 'native' && value.kind !== 'none') { + const activeId = activeMapAssetIdRef.current; + const active = activeId + ? placedMapAssetsRef.current.find((asset) => asset.id === activeId) + : undefined; + if (value.kind === 'none') { + if (!active) return; + removePlacedMapAsset(active.id); + void commitMapScene(); + return; + } + if (entry.format === 'urdf' && urdfModeRef.current === 'native') { state.setDiagnostic( diagnostic('模型编译', new Error('原生 URDF 不能注入地图,请切换为转换模式'), entry.path), ); return; } - mapSelectionRef.current = value; - setMapSelection(value); - void loadEntry(entry.path).then((loaded) => { - if (loaded) { - setEditorDocument(readEditorDocument(value)); - viewer.current?.setMapEditorDocument(null); - return; - } - mapSelectionRef.current = previous; - setMapSelection(previous); - }); + if (!active) { + if (appendMapSelection(value)) void commitMapScene(); + return; + } + const nextName = uniqueMapAssetName( + selectionName(value), + placedMapAssetsRef.current, + active.id, + ); + const nextAssets = updatePlacedMapAsset(placedMapAssetsRef.current, active.id, value, nextName); + setMapScene(nextAssets, active.id, value); + setEditorSelection({ kind: 'map', mapAssetId: active.id }); + setEditorDocument(readEditorDocument(value)); + viewer.current?.setMapEditorDocument(null); + void commitMapScene(); }; - const selectTerrainAsset = (preset: SystemTerrainPreset) => { - const current = mapSelectionRef.current; - applyMapSelection({ - kind: 'builtin', - config: { - ...(current.kind === 'builtin' ? current.config : DEFAULT_PHYSICAL_MAP_CONFIG), - preset, + const selectTerrainAsset = (preset: SystemTerrainPreset, position?: [number, number, number]) => { + const positionX = position ? Math.round(position[0] * 10) / 10 : 0; + const positionY = position ? Math.round(position[1] * 10) / 10 : 0; + void appendMapSelection( + { + kind: 'builtin', + config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset, positionX, positionY }, }, - }); + PHYSICAL_MAP_PRESET_LABELS[preset], + ); + }; + const addProjectMapAsset = (descriptorPath: string, position?: [number, number, number]) => { + const map = projectMaps.find((candidate) => candidate.descriptorPath === descriptorPath); + if (!map) return; + appendMapSelection( + { + kind: 'project', + descriptorPath, + positionX: position ? Math.round(position[0] * 10) / 10 : 0, + positionY: position ? Math.round(position[1] * 10) / 10 : 0, + yawDeg: 0, + }, + map.name, + ); }; const applyEditorDocument = async (document: EditableMapDocument): Promise => { const selection = mapSelectionRef.current; - const current = manifest.current; - const entryPath = state.selectedEntry; - if (selection.kind !== 'project' || !current || !entryPath) return false; - const resolved = resolveProjectMap(current, selection.descriptorPath); - if (!resolved.authoringPath || !resolved.physicsPath) { - state.setDiagnostic( - diagnostic( - '模型编译', - new Error('可编辑地图必须同时声明 authoring.source 和 physics.source'), - ), - ); - return false; - } - try { - const authoringData = encodeEditableMapDocument(document); - const physicsData = compileEditableMapDocument(document); - const definition = decodeMapDefinition( - current.files.find((file) => file.path === resolved.descriptorPath)!.data, - ); - definition.spawnPoints = document.spawnPoints; - const descriptorData = new TextEncoder().encode(`${JSON.stringify(definition, null, 2)}\n`); - const replacements = new Map([ - [resolved.authoringPath, authoringData], - [resolved.physicsPath, physicsData], - [resolved.descriptorPath, descriptorData], - ]); - const files = current.files.map((file) => { - const data = replacements.get(file.path); - return data ? { ...file, data, size: data.byteLength } : file; - }); - const candidate: ProjectManifest = { - ...current, - files, - maps: discoverMapEntries(files), - totalBytes: files.reduce((total, file) => total + file.size, 0), - }; - manifest.current = candidate; - const loaded = await loadEntry(entryPath); - if (!loaded) { - manifest.current = current; - return false; - } - const loadedManifest = manifest.current ?? candidate; - const maps = discoverMapEntries(loadedManifest.files); - const committed = { ...loadedManifest, maps }; - manifest.current = committed; - setProjectMaps(maps); - setEditorDocument(document); - viewer.current?.setMapEditorDocument(null); - state.setProject( - committed.name, - committed.files.map((file) => ({ path: file.path, size: file.size })), - committed.entries, - entryPath, - ); - state.setSnapshot(adapter.current.snapshot() ?? undefined); - return true; - } catch (error) { - manifest.current = current; - state.setDiagnostic(diagnostic('模型编译', error, resolved.authoringPath)); - return false; - } + if (selection.kind !== 'project' || !manifest.current || !state.selectedEntry) return false; + updateEditorDraft(selection.descriptorPath, document, true); + return commitMapScene(new Map(editorDraftsRef.current)); }; const convertSelectedMap = async (): Promise => { const selection = mapSelectionRef.current; @@ -1003,6 +1698,7 @@ export function App() { const committed = { ...loadedManifest, maps }; manifest.current = committed; setProjectMaps(maps); + setCommittedEditorDocuments(manifestEditorDocuments(committed)); setEditorDocument(document); state.setProject( committed.name, @@ -1011,6 +1707,10 @@ export function App() { entryPath, ); state.setSnapshot(adapter.current.snapshot() ?? undefined); + const selectedMapAssetId = activeMapAssetIdRef.current; + setEditorSelection( + selectedMapAssetId ? { kind: 'map', mapAssetId: selectedMapAssetId } : null, + ); notify('已创建可编辑地图副本', authoringPath); return true; } catch (error) { @@ -1041,47 +1741,68 @@ export function App() { const resetDragState = () => { dragDepth.current = 0; setDragActive(false); + setMapAssetDropTarget(undefined); }; const dragEnter = (event: DragEvent) => { - if ( - event.dataTransfer.types.includes('Files') && - !event.dataTransfer.types.includes(MAP_ASSET_DRAG_MIME) - ) { + if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) { + const bounds = viewportShell.current?.getBoundingClientRect(); + if (bounds) + setMapAssetDropTarget({ + left: event.clientX - bounds.left, + top: event.clientY - bounds.top, + position: viewer.current?.mapPlanePoint(event.clientX, event.clientY) ?? null, + }); + return; + } + if (hasTransferType(event.dataTransfer, 'Files')) { dragDepth.current += 1; if (!state.loading) setDragActive(true); } }; const dragLeave = (event: DragEvent) => { - if (!event.dataTransfer.types.includes('Files')) return; + if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) + setMapAssetDropTarget(undefined); + return; + } + if (!hasTransferType(event.dataTransfer, 'Files')) return; dragDepth.current = Math.max(0, dragDepth.current - 1); if (dragDepth.current === 0) setDragActive(false); }; const dragOver = (event: DragEvent) => { event.preventDefault(); - if (event.dataTransfer.types.includes(MAP_ASSET_DRAG_MIME)) { - event.dataTransfer.dropEffect = viewer.current?.mapPlanePoint(event.clientX, event.clientY) - ? 'copy' - : 'none'; + if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) { + const position = viewer.current?.mapPlanePoint(event.clientX, event.clientY) ?? null; + const bounds = viewportShell.current?.getBoundingClientRect(); + if (bounds) + setMapAssetDropTarget({ + left: event.clientX - bounds.left, + top: event.clientY - bounds.top, + position, + }); + event.dataTransfer.dropEffect = position ? 'copy' : 'none'; return; } - if (event.dataTransfer.types.includes('Files')) + if (hasTransferType(event.dataTransfer, 'Files')) event.dataTransfer.dropEffect = state.loading ? 'none' : 'copy'; }; const drop = (event: DragEvent) => { event.preventDefault(); resetDragState(); - const assetType = event.dataTransfer.getData(MAP_ASSET_DRAG_MIME), - requestedPlacement = event.dataTransfer.getData(MAP_ASSET_PLACEMENT_MIME), - placementMode = isMapObjectPlacementMode(requestedPlacement) - ? requestedPlacement - : 'auto_ground'; - if (isEditableMapObjectType(assetType)) { + const mapAsset = decodeMapLibraryDragPayload(event.dataTransfer.getData(MAP_LIBRARY_DRAG_MIME)); + if (mapAsset) { event.stopPropagation(); const position = viewer.current?.mapPlanePoint(event.clientX, event.clientY); - if (position) void addCertifiedMapAsset(assetType, position, placementMode); + if (!position) return; + if (mapAsset.kind === 'certified') + void addCertifiedMapAsset(mapAsset.type, position, mapAsset.placementMode); + else if (mapAsset.kind === 'terrain') selectTerrainAsset(mapAsset.preset, position); + else addProjectMapAsset(mapAsset.descriptorPath, position); return; } if (state.loading || importInFlight.current) return; + // 必须在 drop 用户手势仍有效时读取句柄;Chromium 随后会清空 DataTransfer。 + const filesPromise = filesFromDrop(event.dataTransfer.items, event.dataTransfer.files); importInFlight.current = true; state.setLoading(true); setImportProgress({ @@ -1091,7 +1812,7 @@ export function App() { }); void (async () => { try { - const files = await filesFromDrop(event.dataTransfer.items, event.dataTransfer.files); + const files = await filesPromise; await ingest(files, true); } catch (error) { importInFlight.current = false; @@ -1412,6 +2133,10 @@ export function App() { if (document.fullscreenElement) void document.exitFullscreen().catch(() => {}); else if (root.current) void root.current.requestFullscreen().catch(() => {}); }; + const showWorkspaceTool = (tool: WorkspaceTool) => { + setWorkspaceTool(tool); + setRightOpen(true); + }; const applyLayoutPreset = (preset: LayoutPreset) => { if (preset === 'viewport') { setLeftOpen(false); @@ -1424,6 +2149,7 @@ export function App() { } else if (preset === 'control') { setLeftOpen(false); setRightOpen(true); + setWorkspaceTool('controls'); dispatchLayoutWidths(288, 384); } else { setLeftOpen(true); @@ -1431,19 +2157,36 @@ export function App() { dispatchLayoutWidths(288, 288); } }; + useMapEditorShortcuts({ + enabled: Boolean(state.snapshot), + mapEditing: mapEditingActive, + dirty: mapSceneDirty, + loading: state.loading, + hasSelection: Boolean(editorSelection ?? state.selection), + canDelete: editorSelection?.kind === 'map-object', + canScale: mapSelection.kind === 'project' && selectedMapObject?.placementMode !== 'locked', + onTransformMode: changeMapTransformMode, + onFocusSelection: focusSelectedObject, + onDeleteSelection: deleteSelectedMapObject, + onToggleSnapping: () => setMapSnapping((value) => !value), + onSave: () => void commitMapScene(), + }); useEffect(() => { const key = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; if ( document.activeElement instanceof HTMLElement && document.activeElement.closest('[role="dialog"]') ) return; + const target = event.target instanceof HTMLElement ? event.target : null; + if (target?.closest('input,select,textarea,[contenteditable="true"]')) return; if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'k') { event.preventDefault(); setCommandOpen(true); return; } - if ((event.target as HTMLElement).matches('input,select,button')) return; + if (target?.closest('button')) return; if (event.code === 'Space') { event.preventDefault(); togglePause(); @@ -1531,6 +2274,22 @@ export function App() { disabled: !state.snapshot, run: exportMjcf, }, + { + id: 'workspace-controls', + label: '在右侧打开控制台', + group: '工具', + icon: , + disabled: !state.snapshot, + run: () => showWorkspaceTool('controls'), + }, + { + id: 'workspace-data', + label: '在右侧打开数据录制', + group: '工具', + icon: , + disabled: !state.snapshot, + run: () => showWorkspaceTool('data'), + }, { id: 'left', label: leftOpen ? '隐藏工程面板' : '显示工程面板', @@ -1540,7 +2299,7 @@ export function App() { }, { id: 'right', - label: rightOpen ? '隐藏属性面板' : '显示属性面板', + label: rightOpen ? '隐藏右侧面板' : '显示右侧面板', group: '布局', icon: rightOpen ? : , run: () => setRightOpen((value) => !value), @@ -1567,6 +2326,63 @@ export function App() { run: () => setHelpOpen(true), }, ]; + const workspaceTools = workspaceTool ? ( + + 正在加载工作区工具… + + } + > + /\.py$/i.test(file.path)) + .map((file) => file.path)} + selectedControllerPath={selectedControllerPath} + controllerStatus={controllerStatus} + policyPaths={state.files + .filter((file) => /\.onnx$/i.test(file.path)) + .map((file) => file.path)} + selectedPolicyPath={selectedPolicyPath} + policyStatus={policyStatus} + onResetJoints={resetJoints} + onToggleJointLimits={toggleJointLimits} + onToggleAdvanced={() => setJointAdvanced((value) => !value)} + onToggleAngleUnit={() => setAngleUnit((value) => (value === 'rad' ? 'deg' : 'rad'))} + onActuator={setActuator} + onActuatorParameters={setActuatorParameters} + onJoint={setJoint} + onForceScale={setForceScale} + onSelectControllerPath={setSelectedControllerPath} + onLoadControllerPath={loadControllerPath} + onImportController={importController} + onToggleController={toggleController} + onControllerCommand={sendControllerCommand} + onRemoveController={removeController} + onSelectPolicyPath={setSelectedPolicyPath} + onLoadPolicyPath={loadPolicyPath} + onImportPolicy={importPolicy} + onTogglePolicy={togglePolicy} + onPolicyCommand={setPolicyCommand} + onRemovePolicy={removePolicy} + onDataRecorderConfigure={configureDataRecorder} + onDataRecordingStart={startDataRecording} + onDataRecordingStop={stopDataRecording} + onDataRecordingClear={clearDataRecording} + onDataRecordingExport={exportDataRecording} + /> + + ) : undefined; return (
viewer.current?.resetCamera()} @@ -1657,32 +2482,87 @@ export function App() { entries={state.entries} selectedEntry={state.selectedEntry} snapshot={state.snapshot} + selection={editorSelection} loading={state.loading} nativeUrdf={selectedFormat === 'urdf' && urdfMode === 'native'} mapSelection={mapSelection} - editorDocument={editorDocument} + maps={projectMaps} + placedMaps={placedMapAssets} + pendingSceneChangeCount={sceneDraftChangeCount} + pendingSceneIds={pendingSceneIds} + editorDocuments={sceneEditorDocuments} + assetPlacementMode={assetPlacementMode} activeTab={projectSidebarTab} onActiveTabChange={setProjectSidebarTab} onRemove={removeProject} onSelectEntry={requestLoadEntry} - onJointHover={(jointId) => viewer.current?.highlightJoint(jointId)} + onSelectBody={(bodyId) => { + state.setSelection(null); + setEditorSelection({ kind: 'body', bodyId }); + viewer.current?.selectMapEditorObject(null); + viewer.current?.selectParametricMapAsset(null); + viewer.current?.highlightJoint(null); + setRightOpen(true); + }} + onSelectJoint={(jointId, bodyId) => { + state.setSelection(null); + setEditorSelection({ kind: 'joint', jointId, bodyId }); + viewer.current?.selectMapEditorObject(null); + viewer.current?.selectParametricMapAsset(null); + viewer.current?.highlightJoint(jointId); + setRightOpen(true); + }} + onJointHover={(jointId) => + viewer.current?.highlightJoint( + jointId ?? (editorSelection?.kind === 'joint' ? editorSelection.jointId : null), + ) + } onAddMapAsset={(type, placementMode) => addCertifiedMapAsset(type, undefined, placementMode) } + onAddProjectMap={addProjectMapAsset} onSelectTerrain={selectTerrainAsset} - onSelectMapObject={(id) => { - editorInteraction.current?.onSelect(id); - selectEditorObject(id); - }} + onAssetPlacementModeChange={setAssetPlacementMode} + onApplyScene={() => void commitMapScene()} + onDiscardScene={discardMapSceneDraft} + onSelectMap={activateMapAsset} + onRemoveMap={removePlacedMapAsset} + onSelectMapObject={(mapId, objectId) => activateMapAsset(mapId, objectId)} /> -
+
+ + void commitMapScene()} + onDiscard={discardMapSceneDraft} + /> + /\.py$/i.test(file.path)) - .map((file) => file.path)} - selectedControllerPath={selectedControllerPath} - controllerStatus={controllerStatus} - policyPaths={state.files - .filter((file) => /\.onnx$/i.test(file.path)) - .map((file) => file.path)} - selectedPolicyPath={selectedPolicyPath} - policyStatus={policyStatus} mapSelection={mapSelection} + activeMapAssetId={activeMapAssetId} + activeMapAssetName={placedMapAssets.find((asset) => asset.id === activeMapAssetId)?.name} + mapSceneDirty={mapSceneDirty} maps={projectMaps} showVisualMap={showVisualMap} showMapCollision={showMapCollision} editorDocument={editorDocument} + editorDraftDocument={ + mapSelection.kind === 'project' + ? editorDrafts.get(mapSelection.descriptorPath) + : undefined + } + workspaceTool={workspaceTool} + workspaceTools={workspaceTools} + onWorkspaceToolChange={(tool) => { + setWorkspaceTool(tool); + if (tool) setRightOpen(true); + }} + onSelectJoint={(jointId, bodyId) => { + setEditorSelection({ kind: 'joint', jointId, bodyId }); + viewer.current?.highlightJoint(jointId); + }} onUrdfMode={changeUrdfMode} onBaseMode={changeBaseMode} onShowCollision={setShowCollision} @@ -1777,41 +2665,21 @@ export function App() { onActuator={setActuator} onActuatorParameters={setActuatorParameters} onJoint={setJoint} - onForceScale={setForceScale} - onSelectControllerPath={setSelectedControllerPath} - onLoadControllerPath={loadControllerPath} - onImportController={importController} - onToggleController={toggleController} - onControllerCommand={sendControllerCommand} - onRemoveController={removeController} - onSelectPolicyPath={setSelectedPolicyPath} - onLoadPolicyPath={loadPolicyPath} - onImportPolicy={importPolicy} - onTogglePolicy={togglePolicy} - onPolicyCommand={setPolicyCommand} - onRemovePolicy={removePolicy} onApplyMap={applyMapSelection} + onMapDraft={stageMapSelectionDraft} onEditorPreview={previewEditorDocument} + onEditorDraftChange={updateEditorDraft} onEditorApply={applyEditorDocument} onEditorExport={exportSelectedMap} onEditorConvert={convertSelectedMap} onEditorBindInteraction={bindEditorInteraction} onEditorSelect={selectEditorObject} - onEditorTransformMode={setEditorTransformMode} - onEditorSnapping={setEditorSnapping} + onEditorSessionStateChange={updateEditorSessionState} + onEditorSurfaceHeight={editorSurfaceHeight} onMapDisplay={(visual, collision) => { setShowVisualMap(visual); setShowMapCollision(collision); }} - onMapTabOpen={() => { - setLeftOpen(true); - setProjectSidebarTab('assets'); - }} - onDataRecorderConfigure={configureDataRecorder} - onDataRecordingStart={startDataRecording} - onDataRecordingStop={stopDataRecording} - onDataRecordingClear={clearDataRecording} - onDataRecordingExport={exportDataRecording} />
{pendingUrdfPath && ( diff --git a/web_platform/src/app/components/LayoutSettingsDialog.tsx b/web_platform/src/app/components/LayoutSettingsDialog.tsx index 3c8c48fc..62f3c2c2 100644 --- a/web_platform/src/app/components/LayoutSettingsDialog.tsx +++ b/web_platform/src/app/components/LayoutSettingsDialog.tsx @@ -43,7 +43,7 @@ export function LayoutSettingsDialog({ onClick={() => onRightOpen(!rightOpen)} icon={} > - 属性面板 + 右侧面板

布局预设

diff --git a/web_platform/src/app/components/MapViewportTools.test.tsx b/web_platform/src/app/components/MapViewportTools.test.tsx new file mode 100644 index 00000000..c9c5b750 --- /dev/null +++ b/web_platform/src/app/components/MapViewportTools.test.tsx @@ -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( + , + ); + + 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(screen.getByText('地图草稿已同步')).toBeVisible(); + expect(screen.getByRole('button', { name: '提交地图草稿' })).toBeDisabled(); + }); +}); diff --git a/web_platform/src/app/components/MapViewportTools.tsx b/web_platform/src/app/components/MapViewportTools.tsx new file mode 100644 index 00000000..d4b6af21 --- /dev/null +++ b/web_platform/src/app/components/MapViewportTools.tsx @@ -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[] = [ + { 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 ( +
+ + allowScale || item.value !== 'scale')} + value={mode} + onChange={onModeChange} + label="地图变换模式" + /> + onSnappingChange(!snapping)} + > + + +
+ ); +} + +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 ( +
+
+ ); +} + +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 ( +
+
+ + + + + {valid + ? `释放放置 · ${target.position![0].toFixed(1)}, ${target.position![1].toFixed(1)}` + : '请拖到 3D 地面'} + +
+
+ ); +} diff --git a/web_platform/src/app/components/ModelControlsSidebar.test.tsx b/web_platform/src/app/components/ModelControlsSidebar.test.tsx new file mode 100644 index 00000000..755598d7 --- /dev/null +++ b/web_platform/src/app/components/ModelControlsSidebar.test.tsx @@ -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 { + 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(); + expect(screen.getByText('未选择对象')).toBeVisible(); + expect(screen.getByText('模型摘要')).toBeVisible(); + expect(screen.getByText('未选择地图实例')).toBeVisible(); + }); + + it('按照统一选择自动路由 Body 与 Joint 检查器', () => { + const view = render( + , + ); + expect(screen.getByText('Robot / Body')).toBeVisible(); + expect(screen.getByText('arm', { selector: '[title="arm"]' })).toBeVisible(); + + view.rerender( + , + ); + expect(screen.getByText('Robot / Joint')).toBeVisible(); + expect(screen.getByText('Hinge · arm')).toBeVisible(); + expect(screen.getByText('关联 Actuator')).toBeVisible(); + }); + + it('控制台与数据录制在右侧面板内切换,不创建模态窗口', () => { + const onWorkspaceToolChange = vi.fn(); + render( + + 内嵌控制工具 + + ), + 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( + , + ); + 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']]); + }); +}); diff --git a/web_platform/src/app/components/ModelControlsSidebar.tsx b/web_platform/src/app/components/ModelControlsSidebar.tsx index 8583c6ba..09ff8125 100644 --- a/web_platform/src/app/components/ModelControlsSidebar.tsx +++ b/web_platform/src/app/components/ModelControlsSidebar.tsx @@ -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,326 +58,196 @@ 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; onEditorExport: () => void; onEditorConvert: () => Promise; 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) => 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' ? ( + + ) : activeView === 'data' ? ( + + ) : ( + + ); + const tabs = ( + props.onWorkspaceToolChange(value === 'inspector' ? null : value)} + /> + ); + if (!snapshot) return ( - -
导入模型后显示属性
+ + {tabs} + {props.workspaceTool ? ( + props.workspaceTools + ) : ( +
导入模型后显示检查器
+ )}
); - const properties = ( - <> - {s.model.nbody} Body}> -
- - - - - -
-
- {props.selectedFormat === 'urdf' && ( - - - -

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

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

- - 在视口中单击物体 -

- )} -
- + + 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 = ( + + body.id !== 0 && body.parentId === 0 && !body.name.startsWith('__platform_map_'), + ) + .map((body) => body.name)} + loading={props.loading} + nativeUrdf={props.selectedFormat === 'urdf' && props.urdfMode === 'native'} + 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} + onEditorSessionStateChange={props.onEditorSessionStateChange} + onEditorSurfaceHeight={props.onEditorSurfaceHeight} + onMapDisplay={props.onMapDisplay} + onDraft={props.onMapDraft} + onApply={props.onApplyMap} + /> ); - const controls = ( - <> - {s.rlPolicy.enabled ? '推理' : '停止'} : undefined} - > - - - - - - {s.controller.enabled ? '运行' : '停止'} : undefined} - > - - - {s.actuators.length}} - > - {s.actuators.length ? ( - s.actuators.map((actuator) => ( - props.onActuator(actuator.id, value)} - onParameters={(parameters) => props.onActuatorParameters(actuator.id, parameters)} - /> - )) - ) : ( -

模型没有驱动器

- )} -
- {s.joints.length}}> -
- - - - -
- {s.joints.map((joint) => { - const scale = joint.type === 3 && props.angleUnit === 'deg' ? 180 / Math.PI : 1, - unit = - joint.type === 3 - ? props.angleUnit === 'deg' - ? '°' - : ' rad' - : joint.type === 2 - ? ' m' - : ''; - return ( - props.onJoint(joint.id, value / scale)} - /> - ); - })} -
- - -

- 选择“外力施加”,在动态物体上按住拖动,松开即清零。 -

-
- - ); - return ( - - { - setTab(value); - if (value === 'map') props.onMapTabOpen?.(); - }} - items={[ - { - value: 'properties', - label: '属性', - icon: , - content: properties, - }, - { - value: 'controls', - label: '控制', - icon: , - content: controls, - }, - { - value: 'data', - label: '数据', - icon: , - content: ( - - ), - }, - { - value: 'map', - label: '地图', - icon: , - content: ( - - body.id !== 0 && - body.parentId === 0 && - !body.name.startsWith('__platform_map_'), - ) - .map((body) => body.name)} - loading={props.loading} - nativeUrdf={props.selectedFormat === 'urdf' && props.urdfMode === 'native'} - showVisualMap={props.showVisualMap} - showMapCollision={props.showMapCollision} - editorDocument={props.editorDocument} - onEditorPreview={props.onEditorPreview} - onEditorApply={props.onEditorApply} - onEditorExport={props.onEditorExport} - onEditorConvert={props.onEditorConvert} - onEditorBindInteraction={props.onEditorBindInteraction} - onEditorSelect={props.onEditorSelect} - onEditorTransformMode={props.onEditorTransformMode} - onEditorSnapping={props.onEditorSnapping} - onMapDisplay={props.onMapDisplay} - onApply={props.onApplyMap} - /> - ), - }, - ]} + + let inspector: ReactNode; + if (selectedBody) { + inspector = ( + + ); + } else if (selectedJoint) { + inspector = ( + + ); + } else if (mapSelected) { + const mapName = selectedMapObject?.name ?? props.activeMapAssetName ?? '地图实例'; + inspector = ( + <> + : } + eyebrow={selectedMapObject ? 'Map / Object' : 'Map / Instance'} + name={mapName} + meta={ + selectedMapObject + ? `${selectedMapObject.type} · ${props.activeMapAssetName ?? '地图'}` + : props.mapSelection.kind === 'builtin' + ? '参数化地形' + : '工程地图包实例' + } + /> + {mapPanel} + + ); + } else { + inspector = ( + <> + + {props.mapSelection.kind === 'none' && mapPanel} + + ); + } + + return ( + + {tabs} + + {props.workspaceTool && props.workspaceTools} ); } diff --git a/web_platform/src/app/components/ProjectSidebar.tsx b/web_platform/src/app/components/ProjectSidebar.tsx index cf3b3b66..d992aa36 100644 --- a/web_platform/src/app/components/ProjectSidebar.tsx +++ b/web_platform/src/app/components/ProjectSidebar.tsx @@ -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; + 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; + 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('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 ( - + }> {projectName ? ( <>
@@ -86,104 +114,129 @@ export function ProjectSidebar({
{projectName}
-
{files.length} 个文件
+
+ {snapshot + ? `${snapshot.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')).length} Body` + : '模型未加载'}{' '} + · {placedMaps.length} 个地图实例 · {files.length} 个文件 +
- - { - setInternalTab(value); - onActiveTabChange?.(value); - }} - items={[ - { - value: 'project', - label: '工程', - icon: , - content: ( - <> - -
- +
+
+ + + + } + second={ + { + setInternalTab(value); + onActiveTabChange?.(value); + }} + items={[ + { + value: 'assets', + label: '资产库', + icon: , + disabled: !snapshot, + content: ( + -
- - ), - }, - { - value: 'structure', - label: '模型结构', - icon: , - disabled: !snapshot, - content: snapshot ? ( - <> - -
- -
- - ) : ( -

加载模型后显示结构

- ), - }, - { - value: 'assets', - label: '资产', - icon: , - disabled: !snapshot, - content: ( - - ), - }, - ]} + ), + }, + { + value: 'files', + label: '工程文件', + icon: , + content: ( + <> + + +
+ +
+ + ), + }, + ]} + /> + } /> ) : ( -
导入模型后显示工程资源
+
导入模型后显示场景与资产
)}
); diff --git a/web_platform/src/app/components/RightSidebarTabs.tsx b/web_platform/src/app/components/RightSidebarTabs.tsx new file mode 100644 index 00000000..e11e1801 --- /dev/null +++ b/web_platform/src/app/components/RightSidebarTabs.tsx @@ -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 ( +
+ {VIEWS.map((view, index) => { + const Icon = view.icon; + const selected = value === view.value; + return ( + + ); + })} +
+ ); +} diff --git a/web_platform/src/app/components/SceneOutliner.test.tsx b/web_platform/src/app/components/SceneOutliner.test.tsx new file mode 100644 index 00000000..8b954f7d --- /dev/null +++ b/web_platform/src/app/components/SceneOutliner.test.tsx @@ -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 = {}) { + 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(); + 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'); + }); +}); diff --git a/web_platform/src/app/components/SceneOutliner.tsx b/web_platform/src/app/components/SceneOutliner.tsx new file mode 100644 index 00000000..588fc5d9 --- /dev/null +++ b/web_platform/src/app/components/SceneOutliner.tsx @@ -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, +): 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, + 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; + 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( + () => + 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 ( +
+ {pendingSceneChangeCount > 0 && ( +
+
+
+
+ + +
+
+ )} + + {snapshot && hasRobotMatches && ( +
+ + +
+ onSelectJoint(joint.id, joint.bodyId)} + onJointHover={onJointHover} + /> +
+
+ )} + + {visibleMaps.length > 0 && ( +
+ + +
    + {visibleMaps.map(({ asset, objects }) => { + const mapSelected = + (selection?.kind === 'map' || selection?.kind === 'map-object') && + selection.mapAssetId === asset.id; + return ( +
  • +
    + + +
    + {objects.length > 0 && ( +
      + {objects.map((object) => { + const selected = + selection?.kind === 'map-object' && + selection.mapAssetId === asset.id && + selection.objectId === object.id; + return ( +
    • + +
    • + ); + })} +
    + )} +
  • + ); + })} +
+
+ )} + + {!hasRobotMatches && !visibleMaps.length && ( + + )} +
+ ); +} diff --git a/web_platform/src/app/components/ShortcutHelpDialog.tsx b/web_platform/src/app/components/ShortcutHelpDialog.tsx index f1577a17..fd11e35c 100644 --- a/web_platform/src/app/components/ShortcutHelpDialog.tsx +++ b/web_platform/src/app/components/ShortcutHelpDialog.tsx @@ -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:
  • 左键拖动:旋转相机或执行当前交互工具
  • 右键拖动:平移相机
  • 滚轮:缩放视口
  • -
  • 选择物体后可在右侧“属性”中查看信息
  • +
  • 选择物体后可在右侧 Inspector 中编辑尺寸、位姿、材质和摩擦
  • +
  • 从左侧资产库拖动几何障碍物到视口地面即可放置
  • diff --git a/web_platform/src/app/components/SidebarPanel.tsx b/web_platform/src/app/components/SidebarPanel.tsx index c0aa106c..85187274 100644 --- a/web_platform/src/app/components/SidebarPanel.tsx +++ b/web_platform/src/app/components/SidebarPanel.tsx @@ -7,20 +7,22 @@ export function SidebarPanel({ side, children, visible = true, + icon, }: { title: string; side: 'left' | 'right'; children: ReactNode; visible?: boolean; + icon?: ReactNode; }) { return (