Cen #7
+3
-1
@@ -16,9 +16,10 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
|
||||||
# Local build and cache directories
|
# Local build, cache, and planning directories
|
||||||
build/
|
build/
|
||||||
.cache/
|
.cache/
|
||||||
|
/plans/
|
||||||
|
|
||||||
# Editors and operating systems
|
# Editors and operating systems
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -31,3 +32,4 @@ MUJOCO_LOG.TXT
|
|||||||
training_server/rl/logs/
|
training_server/rl/logs/
|
||||||
training_server/rl/wandb/
|
training_server/rl/wandb/
|
||||||
training_server/rl/outputs/
|
training_server/rl/outputs/
|
||||||
|
training_server/tuning-data/
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Please always speak chinese.
|
||||||
|
python 虚拟环境路径在/home/cen/Embodied_Workspace/Mujoco_Projects/mujoco/.venv/bin/activate
|
||||||
|
系统是Ubuntu 24.04 LTS
|
||||||
@@ -10,12 +10,12 @@
|
|||||||
- ROS `package://`、常见 URDF 兼容转换及 DAE 降级处理
|
- ROS `package://`、常见 URDF 兼容转换及 DAE 降级处理
|
||||||
- Three.js 模型、碰撞体、坐标系、关节轴、质心和惯量可视化
|
- Three.js 模型、碰撞体、坐标系、关节轴、质心和惯量可视化
|
||||||
- 播放、暂停、单步、重置、变速、关节拖动与外力交互
|
- 播放、暂停、单步、重置、变速、关节拖动与外力交互
|
||||||
- 内置平地、坡道、楼梯、随机障碍物及 9 类系统参数化地形(粗糙/波浪、金字塔阶梯、深坑、沟壑等)
|
- 统一地图资产库:工程地图、认证资产、内置地图及 9 类系统参数化地形共用点击/拖放、场景树、轻量预览、实例变换、放弃和一次编译事务
|
||||||
- 工程地图包:静态 MJCF/OBJ/STL/高度场碰撞层、GLB 视觉层和机器人出生点
|
- 工程地图包:静态 MJCF/OBJ/STL/高度场碰撞层、GLB 视觉层和机器人出生点;同源实例拥有独立位姿,物理/视觉/出生点同步变换
|
||||||
- V3 地图创作层:认证资产库支持点击添加、拖到画布落位和首个资产自动创建场景,并可通过表单与视口操纵器继续编辑、事务式应用及导出地图 ZIP
|
- V3 地图创作层:认证资产可在程序地形上自动重力落位,共用右侧属性和视口操纵器,支持源内容编辑、空白取消选择及导出地图 ZIP
|
||||||
- 浏览器内 Python 控制器(Pyodide)
|
- 浏览器内 Python 控制器(Pyodide)
|
||||||
- ONNX 强化学习策略推理(ONNX Runtime Web)
|
- ONNX 强化学习策略推理(ONNX Runtime Web)
|
||||||
- 内置 Go2 PPO 任务的本机 mjlab 训练桥接服务
|
- 内置 Go2 PPO 任务的本机 mjlab 训练桥接服务,以及 DeepSeek 驱动的奖励函数自调参、固定评估和 TensorBoard 风格独立工作台
|
||||||
- 响应式工作区、源码编辑、性能监控和中文诊断
|
- 响应式工作区、源码编辑、性能监控和中文诊断
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
@@ -82,7 +82,7 @@ npm run lint:python
|
|||||||
|
|
||||||
## 数据与安全边界
|
## 数据与安全边界
|
||||||
|
|
||||||
模型、资源、Python 控制器和 ONNX 策略默认只在当前浏览器会话中处理,不上传到服务器。训练桥接服务只监听本机回环地址,并仅执行服务端允许列表中的任务。
|
模型、资源、Python 控制器和 ONNX 策略默认只在当前浏览器会话中处理,不上传到服务器。训练桥接服务只监听本机回环地址,并仅执行服务端允许列表中的任务。启用自调参时,DeepSeek 只接收脱敏后的奖励参数、曲线摘要和评估数值;API key 仅存在训练服务环境中,不进入浏览器、URL、SQLite 或训练日志。
|
||||||
|
|
||||||
## 上游与许可证
|
## 上游与许可证
|
||||||
|
|
||||||
|
|||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
# Code Context
|
||||||
|
|
||||||
|
## Files Retrieved
|
||||||
|
1. `wasm/web_platform/src/simulation/SimulationSession.ts`(lines 1-107)- 仿真步进、控制器生命周期、模型名称解析和 `mj_step` 前控制写入的核心集成点。
|
||||||
|
2. `wasm/web_platform/src/simulation/PhysicsAdapter.ts`(lines 12-110)- UI 与 session 的稳定门面;模型切换、资源释放和 Go2-W URDF 增强发生于此。
|
||||||
|
3. `wasm/web_platform/src/app/App.tsx`(lines 37-78, 100-105)- 工程导入、控制器状态、错误反馈、右侧栏 wiring。
|
||||||
|
4. `wasm/web_platform/src/app/components/SidebarPanel.tsx`(lines 19-35)- 当前“控制”tab 仅嵌入 Python 控制器,是 ONNX 面板的最小 UI 插槽。
|
||||||
|
5. `wasm/web_platform/src/controller/PythonControllerRuntime.ts`(lines 30-120)- 可复用的控制器生命周期/状态设计;同时揭示现有控制回调必须同步。
|
||||||
|
6. `wasm/web_platform/src/controller/types.ts`(lines 1-42)- 当前状态被写死为 `language:'python'`,且 step API 可读关节、sensor、body quaternion。
|
||||||
|
7. `wasm/web_platform/src/project/importer.ts`(lines 1-48, 184-223)- 所有二进制文件已原样进入 manifest,ONNX 无需进入模型 entry 发现逻辑。
|
||||||
|
8. `wasm/web_platform/src/project/types.ts`(lines 1-41)- ProjectFile/大小上限;单文件允许 128 MiB、工程 512 MiB。
|
||||||
|
9. `wasm/package.json`(scripts/dependencies)- 已有 Vitest/Playwright,尚无 `onnxruntime-web`。
|
||||||
|
10. `wasm/web_platform/vite.config.ts`(lines 1-27)- Pyodide 本地资产复制是 ORT wasm 离线资产处理的直接范例。
|
||||||
|
11. `wasm/src/go2_w_balance.py`(lines 1-约480)- 仓库已有 Go2-W 手写 200 Hz 平衡/轮速控制,可作模型命名、IMU 和安全回退参考,但不是 RL 策略。
|
||||||
|
12. `/home/cen/Embodied_Workspace/unitree_rl_mjlab/deploy/robots/go2/config/policy/velocity/v0/params/deploy.yaml`(lines 1-56)- 部署时序、动作变换及精确 observation 顺序。
|
||||||
|
13. `/home/cen/Embodied_Workspace/unitree_rl_mjlab/deploy/robots/go2/src/State_RLBase.cpp`(lines 6-31)- 部署约定为 `params/deploy.yaml` + `exported/policy.onnx`,输出经 action manager 处理后按 joint map 下发。
|
||||||
|
14. `/home/cen/Embodied_Workspace/unitree_rl_mjlab/src/tasks/velocity/config/go2/env_cfgs.py`(lines 1-约160)- flat 任务移除 height scan,Go2 base 为 `base_link`。
|
||||||
|
|
||||||
|
## Key Code
|
||||||
|
|
||||||
|
### 已有正确插入顺序
|
||||||
|
`SimulationSession.singleStep/advance` 均执行:控制器 → 外力 → `mj_step`(`SimulationSession.ts:46-57`)。ONNX 动作必须沿用该位置,而不能在 React frame snapshot 后直接写 ctrl,否则会引入一物理步延迟和不稳定的渲染帧时序。
|
||||||
|
|
||||||
|
### 参考部署契约
|
||||||
|
Go2 deploy YAML 定义:
|
||||||
|
- `step_dt: 0.02`,即策略 50 Hz;仿真步可保持 0.002 s,动作 hold 10 步。
|
||||||
|
- observation 串接顺序和维数:`base_ang_vel(3), projected_gravity(3), velocity_commands(3), gait_phase(2), joint_pos_rel(12), joint_vel_rel(12), last_action(12)`,总计 **47 float32**。
|
||||||
|
- action 为 12 维位置目标:`processed = raw * 0.25 + default_joint_pos`;默认位姿见 YAML lines 3-5/18-20。
|
||||||
|
- joint 顺序不能取 MuJoCo ID 偶然顺序;部署 `joint_ids_map=[3,4,5,0,1,2,9,10,11,6,7,8]` 表示 RL 顺序与硬件/模型顺序存在显式重排。
|
||||||
|
- ONNX 与参数是配套件:`params/deploy.yaml` + `exported/policy.onnx`(`State_RLBase.cpp:12-16`)。仅导入 `.onnx` 无法可靠解释 observation/action。
|
||||||
|
|
||||||
|
### 最小文件级实现方案(建议,不修改)
|
||||||
|
1. **新增独立策略层**:`src/policy/types.ts`, `OnnxPolicyRuntime.ts`, `Go2VelocityTask.ts`。
|
||||||
|
- `OnnxPolicyRuntime` 只负责 ORT session、输入/输出名称与 shape 校验、generation cancellation、dispose。
|
||||||
|
- `Go2VelocityTask` 固化首个 task 的 47→12 契约、命令/phase/lastAction、名称到 joint/actuator 的显式映射、PD 目标或 position actuator 输出。
|
||||||
|
- 不应把 ONNX 逻辑塞入 `PythonControllerRuntime`;其 Pyodide proxy 与同步函数假设完全不同。
|
||||||
|
2. **`SimulationSession.ts`**:将单一 `pythonController` 最小泛化为互斥 controller slot,或新增 `onnxPolicy` 并在同一个 `runController()` 中互斥调度;load/remove/reset/dispose 复用 generation 防模型切换竞态。添加只读 observation helpers(按 joint/body/sensor 名称解析),避免 UI 从 snapshot 拼向量。禁用/异常时必须 `ctrl.fill(0)`、暂停并保留错误。
|
||||||
|
3. **异步风险处理**:`onnxruntime-web` 的 `session.run()` 返回 Promise,不能直接 await 于当前同步 `advance()` while loop。最小侵入方案是“最新完成动作 hold”:到 50 Hz 时复制 observation 发起一次推理(仅允许一个 in-flight),后续物理步继续持有上次动作,完成后原子替换;首帧保持安全默认位姿而非零力矩。状态须显示 inference latency/overrun。若要求训练等价的严格每 20 ms 动作,则需把物理 loop 改为异步/worker,已非最小侵入。
|
||||||
|
4. **`PhysicsAdapter.ts`**:接口新增 `loadOnnxPolicy(policyBytes, config)`, `setPolicyEnabled`, `setVelocityCommand`, `removePolicy`;MainThread adapter 只转发,模型 `releaseCurrent()` 自动 dispose。不要把 policy 写入 MuJoCo MEMFS,ORT 可直接吃 `Uint8Array`。
|
||||||
|
5. **项目导入**:`importer.ts` 已保留任意扩展名,ZIP/目录中的 `.onnx/.yaml` 会存在 `manifest.files`;无需修改 `discoverEntries`(它只应发现模型)。在 App 中按配套目录约定寻找 `exported/policy.onnx` 及 `params/deploy.yaml`,首版更稳妥的是新增浏览器可直接解析的 JSON policy manifest;不要为 YAML 引入重量解析器后猜测任意训练配置。独立单文件导入应沿用 Python 的 upsert 流程,但 accept 改 `.onnx` 并施加专用上限。
|
||||||
|
6. **依赖/构建**:`wasm/package.json` 加 `onnxruntime-web`;`vite.config.ts` 仿照 Pyodide 将 ORT wasm 文件复制到固定相对目录并设置 `ort.env.wasm.wasmPaths`,否则离线 `base:'./'` 构建容易 404。首版使用 wasm CPU 单线程,避免 WebGPU/COOP-COEP 扩大范围。
|
||||||
|
7. **UI**:新增 `PolicyPanel.tsx`,插在 `SidebarPanel.tsx:32` 的控制 tab,与 Python 控制器并列;展示模型/config、47×12 shape、50 Hz、启停、速度 x/y/yaw、推理耗时/错误。`App.tsx:40,55,72-77,104` 按现有 controller 状态模式增加 policy 状态及 callbacks;加载另一控制器时明确移除/禁用前一个,不能二者同时写 ctrl。
|
||||||
|
8. **首个 Go2-W 任务边界**:参考仓库提供的是 **Go2 12-DOF** velocity policy,不是 Go2-W policy;Go2-W 多四个轮关节。首版可把 12 个腿关节按该 policy 控制、轮 actuator 固定阻尼/零速,仅称“Go2-W 腿式站立/速度实验”,不能宣称与训练分布一致。真正轮式平衡/速度需要 Go2-W 专属训练导出(observation/action 很可能含轮速和 16 维动作)。
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
浏览器文件 → `ProjectManifest.files`(二进制 ONNX 已保留)→ `App` 选择配套 policy/config → `PhysicsAdapter` → `SimulationSession` 拥有 policy runtime。每个 MuJoCo 子步前 task 从 `MjData` 组 observation;每 0.02 s 异步提交 ONNX,完成输出经 scale/offset、显式 joint mapping 和限幅后成为 held action;物理 loop 每步应用 held action。snapshot 仅向 UI报告状态,不作为 observation 数据源。
|
||||||
|
|
||||||
|
## 观测/动作风险与严重度
|
||||||
|
- **blocker**:参考资产无 Go2-W ONNX,且仓库参考目录下 Go2 velocity 的 `exported/policy.onnx` 实际不存在;不能验证 input/output 名或数值一致性。
|
||||||
|
- **blocker**:Go2 12-DOF policy 与 Go2-W 16 actuator 拓扑不匹配。若按 `model.njnt/nactuator` 顺序拼接会 shape 错误或静默错控。
|
||||||
|
- **high**:`projected_gravity` 必须是重力单位向量由世界系旋到 body/IMU 局部系。MuJoCo `xquat` 为 wxyz;符号、quat inverse、IMU 安装姿态任一错误都会使策略立刻摔倒。加速度计并不等价于无噪声 projected gravity。
|
||||||
|
- **high**:`base_ang_vel` 需 body/local frame;直接使用 free joint `qvel[3:6]` 或 gyro sensor前必须确认 MuJoCo sensor frame与训练定义一致。
|
||||||
|
- **high**:`joint_pos_rel = q-default`,joint_vel 和 action 都必须是 YAML 的 RL 顺序;四足 FL/FR/RL/RR 次序在当前手写 Go2-W controller 与 deploy map 中并不天然一致。
|
||||||
|
- **high**:输出是 normalized action,不是 torque;必须 scale+offset 后交给 position actuator/PD。当前 `setActuator` 只写 ctrl,URDF 自动生成的是 motor(`PhysicsAdapter.ts:68`),因此若直接写位置值会被当 N·m。需要 task 内 PD torque,或为该 task 构造 position actuator。
|
||||||
|
- **medium**:gait phase 是 0.6 s 周期的 sin/cos,但应确认零点、sin/cos 顺序以及站立零命令时是否冻结;YAML 没编码完整函数语义。
|
||||||
|
- **medium**:异步 ORT latency 若超过 20 ms 会跳过策略 tick;必须统计 dropped/late inference,且避免并发堆积。
|
||||||
|
- **medium**:reset 应同时清 lastAction、phase、in-flight generation 和 held action;否则旧 Promise 可污染新 episode。
|
||||||
|
- **medium**:导入不可信 ONNX 可能造成大内存/计算消耗;现有 128 MiB 文件上限不足以约束 tensor shape/运行时内存。
|
||||||
|
|
||||||
|
## 测试建议
|
||||||
|
1. `src/policy/Go2VelocityTask.test.ts`:固定 qpos/qvel/quaternion/command,逐元素断言 47 维顺序、dtype、projected gravity、phase、joint reorder;固定 raw action 断言 `*0.25+offset` 和 actuator 映射。
|
||||||
|
2. `src/policy/OnnxPolicyRuntime.test.ts`:mock ORT,覆盖 input/output shape/name 错误、NaN/Inf、single-flight、超时/overrun、generation cancellation、dispose;用极小 identity ONNX fixture 做一次真实 wasm smoke test。
|
||||||
|
3. 新增 `SimulationSession.test.ts`(当前该核心类无测试):mock MainModule 验证 controller 在 `mj_step` 前执行、50 Hz decimation、held action、reset/disable/error 清 ctrl、Python/ONNX 互斥、模型切换后旧推理无效。
|
||||||
|
4. `PhysicsAdapter` contract test:load/remove/dispose 转发及失败后 workspace/runtime 都释放。
|
||||||
|
5. `importer.test.ts`:ZIP/目录保留 `.onnx` 与 config、路径规范化、重复/超限拒绝;entry discovery 不把 ONNX 当模型。
|
||||||
|
6. `PolicyPanel.test.tsx` + `SidebarPanel` 测试:导入、启停、命令范围、shape/错误展示、另一控制器启用时互斥。
|
||||||
|
7. Playwright:导入 Go2-W 工程+fixture policy,启用后推进若干秒,断言页面不崩、policy 状态与命令变化;数值验收应另做离线 golden trajectory(相同初态前 N 次 observation/action 与 Python/ORT reference 对齐),容差逐元素约 `1e-5`,并检查 base height/倾角安全阈值。
|
||||||
|
8. 常规命令:`npm run typecheck:platform`, `npm run lint:platform`, `npm run test:platform`, `npm run build:platform`, 最后 `npm run test:e2e:platform`。
|
||||||
|
|
||||||
|
## Start Here
|
||||||
|
先打开 `wasm/web_platform/src/simulation/SimulationSession.ts:44-93`:这是唯一能保证 observation 采样、控制输出和 `mj_step` 顺序正确,并统一 reset/error/dispose 语义的位置。
|
||||||
|
|
||||||
|
## Residual Risks
|
||||||
|
- 未获得 Go2-W 专属 ONNX 与导出 metadata,无法完成真实 shape/name/golden 验证。
|
||||||
|
- unitree_rl_mjlab 的 YAML 说明字段顺序与缩放,但 gait phase、frame convention 等函数语义仍需用实际导出/运行器确认。
|
||||||
|
- 主线程异步 inference 的 held-action 方案是最小侵入折衷,不提供硬实时或训练环境严格等价。
|
||||||
|
|
||||||
|
```acceptance-report
|
||||||
|
{
|
||||||
|
"criteriaSatisfied": [
|
||||||
|
{
|
||||||
|
"id": "criterion-1",
|
||||||
|
"status": "satisfied",
|
||||||
|
"evidence": "已给出 SimulationSession、PhysicsAdapter、App/Sidebar、导入与测试结构的具体文件/行范围方案,并按 blocker/high/medium 列出观测动作风险。"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"changedFiles": [],
|
||||||
|
"testsAddedOrUpdated": [],
|
||||||
|
"commandsRun": [
|
||||||
|
{
|
||||||
|
"command": "find/grep/read/nl(只读仓库与 unitree_rl_mjlab 参考配置)",
|
||||||
|
"result": "passed",
|
||||||
|
"summary": "确认现有控制步序、项目导入行为、测试布局及 Go2 deploy 的 47维观测/12维动作约定。"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"validationOutput": [
|
||||||
|
"参考 deploy.yaml: step_dt=0.02,观测 3+3+3+2+12+12+12=47,动作 12,scale=0.25。",
|
||||||
|
"参考部署约定为 params/deploy.yaml + exported/policy.onnx;当前 Go2 目录未发现实际 ONNX。",
|
||||||
|
"当前仓库 package dependencies 未包含 onnxruntime-web。"
|
||||||
|
],
|
||||||
|
"residualRisks": [
|
||||||
|
"缺少 Go2-W 专属 ONNX/metadata,参考仅为 Go2 12-DOF。",
|
||||||
|
"异步浏览器 ORT 与同步物理 loop 存在时序折衷。",
|
||||||
|
"projected gravity、角速度 frame、joint mapping 必须做 golden 对齐。"
|
||||||
|
],
|
||||||
|
"noStagedFiles": true,
|
||||||
|
"diffSummary": "只读分析;未修改仓库文件,仅写入指定 context.md 报告。",
|
||||||
|
"reviewFindings": [
|
||||||
|
"blocker: /home/cen/Embodied_Workspace/unitree_rl_mjlab/deploy/robots/go2 - 未发现 Go2 velocity policy.onnx,无法验证模型 I/O。",
|
||||||
|
"blocker: deploy.yaml:1-56 - 参考策略为 12-DOF Go2,与 Go2-W 16 actuator 不匹配。",
|
||||||
|
"high: wasm/web_platform/src/simulation/SimulationSession.ts:49-57 - 同步物理 while loop 无法直接 await ORT Promise。",
|
||||||
|
"high: wasm/web_platform/src/simulation/PhysicsAdapter.ts:68-70 - URDF 自动生成 motor,normalized position action 不可直接写 ctrl。"
|
||||||
|
],
|
||||||
|
"manualNotes": "报告已写入权威路径;除 context.md 外未改动项目。"
|
||||||
|
}
|
||||||
|
```
|
||||||
Generated
+9
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mujoco-web-platform",
|
"name": "mujoco-web-platform",
|
||||||
"version": "0.7.3",
|
"version": "0.8.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mujoco-web-platform",
|
"name": "mujoco-web-platform",
|
||||||
"version": "0.7.3",
|
"version": "0.8.3",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"pyodide": "^0.29.4",
|
"pyodide": "^0.29.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
"uplot": "^1.6.32",
|
||||||
"zustand": "^5.0.15"
|
"zustand": "^5.0.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -5091,6 +5092,12 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uplot": {
|
||||||
|
"version": "1.6.32",
|
||||||
|
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
|
||||||
|
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/uri-js": {
|
"node_modules/uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mujoco-web-platform",
|
"name": "mujoco-web-platform",
|
||||||
"version": "0.7.3",
|
"version": "0.8.3",
|
||||||
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
|
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -61,6 +61,7 @@
|
|||||||
"pyodide": "^0.29.4",
|
"pyodide": "^0.29.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
"uplot": "^1.6.32",
|
||||||
"zustand": "^5.0.15"
|
"zustand": "^5.0.15"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
-r training_server/requirements.txt
|
||||||
ruff==0.16.5
|
ruff==0.16.5
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 本地强化学习训练服务
|
# 本地强化学习训练服务
|
||||||
|
|
||||||
该服务把 Web 平台发出的受限训练请求转换为本机训练子进程,并提供状态轮询、停止任务和 `policy.onnx` 下载接口。服务只绑定 `127.0.0.1`,不执行前端传入的任意命令。
|
该服务把 Web 平台发出的受限训练请求转换为本机训练子进程,并提供状态轮询、停止任务和 `policy.onnx` 下载接口。它还提供 `Unitree-Go2-Flat` 奖励函数自调参:DeepSeek Agent 根据训练曲线与固定评估指标提出受限参数 patch,系统支持全自动或逐轮审批、successive-halving、TensorBoard scalar 查询、最佳 preset 与 ONNX 导出。服务只绑定 `127.0.0.1`,不执行前端传入的任意命令或 Agent 生成的代码。
|
||||||
|
|
||||||
仓库已在 [`rl/`](rl/) 内置 `Unitree-Go2-Flat` 所需的 PPO 训练代码、Go2 模型资产和 ONNX 导出逻辑,不再要求另外克隆 `unitree_rl_mjlab`。`mjlab`、PyTorch 等大型运行依赖仍需安装在本机训练环境中。
|
仓库已在 [`rl/`](rl/) 内置 `Unitree-Go2-Flat` 所需的 PPO 训练代码、Go2 模型资产和 ONNX 导出逻辑,不再要求另外克隆 `unitree_rl_mjlab`。`mjlab`、PyTorch 等大型运行依赖仍需安装在本机训练环境中。
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
```bash
|
```bash
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
python -m pip install -r training_server/rl/requirements.txt
|
python -m pip install -r training_server/rl/requirements.txt
|
||||||
|
python -m pip install -r training_server/requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
@@ -29,6 +30,17 @@ MUJOCO_TRAINING_TOKEN='至少十六个字符的随机令牌' npm run training-se
|
|||||||
--trainer-python /path/to/training-env/bin/python
|
--trainer-python /path/to/training-env/bin/python
|
||||||
```
|
```
|
||||||
|
|
||||||
|
启用云端自调参 Agent 时,在**服务端环境变量**中配置 DeepSeek;不要把 key 填到浏览器、URL 或命令行参数:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DEEPSEEK_API_KEY='你的 DeepSeek API key'
|
||||||
|
export MUJOCO_TUNING_AGENT_BASE_URL='https://api.deepseek.com' # 可省略
|
||||||
|
export MUJOCO_TUNING_AGENT_MODEL='deepseek-v4-flash' # 可省略
|
||||||
|
npm run training-server -- --trainer-python "$PWD/.venv/bin/python"
|
||||||
|
```
|
||||||
|
|
||||||
|
普通训练不要求 DeepSeek key。未配置时健康接口会把 tuning 标记为不可用;只有创建 session 时明确勾选 fallback,才允许 Agent 失败后使用 Optuna 候选,不会静默降级。
|
||||||
|
|
||||||
默认训练工程是仓库内的 `training_server/rl`。如需使用包含其他已注册任务的外部训练工程,仍可通过 `--trainer-root /path/to/trainer` 或 `UNITREE_RL_MJLAB_ROOT` 覆盖。默认端口是 `8765`。如果前端不是从 `localhost` 或 `127.0.0.1` 提供,可显式添加来源:
|
默认训练工程是仓库内的 `training_server/rl`。如需使用包含其他已注册任务的外部训练工程,仍可通过 `--trainer-root /path/to/trainer` 或 `UNITREE_RL_MJLAB_ROOT` 覆盖。默认端口是 `8765`。如果前端不是从 `localhost` 或 `127.0.0.1` 提供,可显式添加来源:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -37,7 +49,17 @@ python training_server/server.py \
|
|||||||
--allow-origin http://192.168.1.10:5173
|
--allow-origin http://192.168.1.10:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
服务一次只运行一个训练任务,最多保留 20 个任务的内存状态,每个任务最多保留 200 行最近日志。停止服务或在界面点击“停止训练”会同步终止整个训练进程组。训练请求的 W&B 模式默认为 `offline`,保留本地指标但不登录;也可以在界面选择完全禁用或在线模式。所有 API 请求都必须携带启动时生成的 Bearer Token。
|
普通训练与自调参共享同一个计算资源锁,任何时刻只允许一个训练/评估子进程占用 GPU。普通任务最多保留 20 个内存状态和每个任务 200 行最近日志;调参 session、trial、proposal、审计和 scalar 写入 SQLite/WAL,默认保存在 `training_server/rl/logs/auto_tuning/`。服务重启后等待审批/暂停状态可恢复,正在训练或评估的 trial 标记为 interrupted,只能从已完整保存的 checkpoint 显式恢复。停止服务或点击“停止”会终止整个进程组。
|
||||||
|
|
||||||
|
普通训练的 W&B 模式默认为 `offline`;调参 trial 强制使用本地 TensorBoard writer,DeepSeek 只接收最多 12 个 trial 的脱敏数值摘要和降采样曲线,不接收源代码、机器人资产、checkpoint、服务 token 或本地路径。所有 HTTP API 请求都必须携带启动时生成的 Bearer Token。
|
||||||
|
|
||||||
|
## 自调参流程
|
||||||
|
|
||||||
|
在主工作台连接训练服务后,点击“打开自调参 Agent 工作台”。默认预算为 12 个唯一配置:所有配置先训练 300 iterations,前 4 名续训到 900,前 2 名续训到 2000;默认使用 GPU 0 和 4096 个并行环境。首次使用建议先降低为 256–512 environments 做 smoke test。
|
||||||
|
|
||||||
|
固定评估使用站立、前进/侧移、转向和组合命令以及 3 个固定 seed。最终分数不直接使用可被权重放大的总 reward,而由速度跟踪 35%、动作平滑 20%、姿态稳定 15%、减少跌倒 15%、足端滑移 10%、能耗 5% 的权重无关指标组成。跌倒率高于基线 2% 或速度误差恶化超过 5% 的 trial 不晋级。逐轮审批模式会自动运行基线,之后每条 Agent 建议都等待批准、修改后批准或拒绝反馈;运行中的 session 也可在逐轮审批和全自动之间切换,切到全自动时会批准当前待处理建议。
|
||||||
|
|
||||||
|
最佳结果保存为不可变 preset,可在普通训练面板的“奖励配置”中选择,也可导出 JSON;不会覆盖仓库里的 Python 默认奖励配置。
|
||||||
|
|
||||||
## 接口
|
## 接口
|
||||||
|
|
||||||
@@ -45,14 +67,26 @@ python training_server/server.py \
|
|||||||
- `POST /api/training/jobs`:发起训练;
|
- `POST /api/training/jobs`:发起训练;
|
||||||
- `GET /api/training/jobs/{id}`:状态、迭代进度和最近日志;
|
- `GET /api/training/jobs/{id}`:状态、迭代进度和最近日志;
|
||||||
- `DELETE /api/training/jobs/{id}`:停止训练;
|
- `DELETE /api/training/jobs/{id}`:停止训练;
|
||||||
- `GET /api/training/jobs/{id}/artifacts/policy.onnx`:下载本次生成的策略。
|
- `GET /api/training/jobs/{id}/artifacts/policy.onnx`:下载本次生成的策略;
|
||||||
|
- `GET /api/tuning/capabilities`、`POST /api/tuning/agent/test`:检查/测试 Agent;
|
||||||
|
- `GET|POST /api/tuning/sessions`、`GET|DELETE /api/tuning/sessions/{id}`:列出、创建、查询、停止 session;
|
||||||
|
- `POST /api/tuning/sessions/{id}/pause|resume`:暂停后续调度或恢复;
|
||||||
|
- `POST /api/tuning/sessions/{id}/mode`:运行时切换 `automatic`/`approval` 模式;
|
||||||
|
- `PUT /api/tuning/sessions/{id}/constraints`:以 revision CAS 保存参数固定值/工程上下限,服务端在 Agent、fallback 与人工修改三条路径统一强制;
|
||||||
|
- `POST /api/tuning/sessions/{id}/step`:发放且只消费一个 Trial 调度令牌,完成训练与固定评估后重新暂停;
|
||||||
|
- `POST /api/tuning/sessions/{id}/rollback`:把同 Session 内已完成且通过安全门槛的 Trial 设为非破坏性后续基准,可同时验证其 checkpoint;
|
||||||
|
- `POST /api/tuning/sessions/{id}/proposals/{proposalId}/approve|reject`:审批、修改或拒绝建议;
|
||||||
|
- `GET /api/tuning/sessions/{id}/trials/{trialId}/metrics?afterStep=N`:查询降采样或增量 scalar;
|
||||||
|
- `GET /api/tuning/sessions/{id}/artifacts/best/policy.onnx`:下载最佳策略;
|
||||||
|
- `GET /api/tuning/presets`:列出可供普通训练复用的最佳奖励 preset。
|
||||||
|
|
||||||
任务保存在服务内存中,服务重启后历史任务状态会丢失;训练日志、checkpoint 和 ONNX 产物保留在 `training_server/rl/logs/rsl_rl/`,使用外部训练工程时则保留在对应工程中。
|
普通任务状态在服务重启后丢失,但日志、checkpoint 和 ONNX 保留在 `training_server/rl/logs/rsl_rl/`;调参状态、调度令牌、参数护栏、回滚基准及产物持久化在 `logs/auto_tuning/`。候选配置数可在 1–100 间设置(包含基线配置,仍受连续无提升早停约束)。API 只接收 32 位资源 ID,不接收客户端文件路径;奖励 patch 受到名称、符号、上下界、Session 护栏、每轮最多 4 项及 `0.5×–2×` 变化率校验。
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m pip install -r requirements-dev.txt
|
source .venv/bin/activate
|
||||||
|
python -m pip install -r requirements-dev.txt
|
||||||
npm run lint:python
|
npm run lint:python
|
||||||
npm run test:training-server
|
npm run test:training-server
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 本地自调参服务(Python 3.12)
|
||||||
|
pydantic-ai-slim[openai]==2.37.0
|
||||||
|
httpx2[socks]==2.12.0
|
||||||
|
optuna==4.9.0
|
||||||
|
tensorboard==2.21.0
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""Deterministic, headless evaluation for Unitree Go2 velocity policies."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from statistics import fmean, pstdev
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
TRAINER_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SERVICE_ROOT = TRAINER_ROOT.parent
|
||||||
|
for source_root in (TRAINER_ROOT, SERVICE_ROOT):
|
||||||
|
if str(source_root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(source_root))
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import tyro
|
||||||
|
import warp as wp
|
||||||
|
|
||||||
|
if not hasattr(wp, "context"):
|
||||||
|
from warp._src import context as warp_context
|
||||||
|
|
||||||
|
wp.context = warp_context # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
from mjlab.envs import ManagerBasedRlEnv
|
||||||
|
from mjlab.rl import MjlabOnPolicyRunner, RslRlVecEnvWrapper
|
||||||
|
from mjlab.tasks.registry import list_tasks, load_env_cfg, load_rl_cfg, load_runner_cls
|
||||||
|
from mjlab.tasks.velocity.mdp import UniformVelocityCommandCfg
|
||||||
|
from mjlab.utils.torch import configure_torch_backends
|
||||||
|
from torch.utils.tensorboard import SummaryWriter
|
||||||
|
|
||||||
|
from tuning.schema import apply_reward_configuration, validate_configuration
|
||||||
|
|
||||||
|
SCENARIOS = (
|
||||||
|
(0.0, 0.0, 0.0),
|
||||||
|
(0.5, 0.0, 0.0),
|
||||||
|
(1.0, 0.0, 0.0),
|
||||||
|
(1.5, 0.0, 0.0),
|
||||||
|
(0.0, 0.5, 0.0),
|
||||||
|
(0.0, -0.5, 0.0),
|
||||||
|
(0.0, 0.0, 0.5),
|
||||||
|
(0.0, 0.0, -0.5),
|
||||||
|
(0.8, 0.25, 0.35),
|
||||||
|
)
|
||||||
|
METRIC_NAMES = (
|
||||||
|
"linear_velocity_rmse",
|
||||||
|
"angular_velocity_rmse",
|
||||||
|
"mean_action_acc",
|
||||||
|
"orientation_error",
|
||||||
|
"slip_velocity",
|
||||||
|
"mechanical_power",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EvaluateConfig:
|
||||||
|
checkpoint: str
|
||||||
|
output: str
|
||||||
|
reward_config: str | None = None
|
||||||
|
num_envs: int = 256
|
||||||
|
steps_per_seed: int = 1000
|
||||||
|
seeds: tuple[int, ...] = field(default_factory=lambda: (101, 202, 303))
|
||||||
|
device: str | None = None
|
||||||
|
gpu_ids: list[int] | Literal["all"] | None = field(default_factory=lambda: [0])
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _fixed_commands(command: torch.Tensor) -> torch.Tensor:
|
||||||
|
values = torch.as_tensor(SCENARIOS, device=command.device, dtype=command.dtype)
|
||||||
|
indexes = torch.arange(command.shape[0], device=command.device) % values.shape[0]
|
||||||
|
command[:] = values[indexes]
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_seed(task_id: str, cfg: EvaluateConfig, seed: int) -> dict[str, float]:
|
||||||
|
torch.manual_seed(seed)
|
||||||
|
env_cfg = load_env_cfg(task_id, play=False)
|
||||||
|
agent_cfg = load_rl_cfg(task_id)
|
||||||
|
env_cfg.seed = seed
|
||||||
|
env_cfg.scene.num_envs = cfg.num_envs
|
||||||
|
env_cfg.curriculum = {}
|
||||||
|
env_cfg.observations["actor"].enable_corruption = False
|
||||||
|
env_cfg.events.pop("push_robot", None)
|
||||||
|
twist_cfg = env_cfg.commands["twist"]
|
||||||
|
assert isinstance(twist_cfg, UniformVelocityCommandCfg)
|
||||||
|
twist_cfg.heading_command = False
|
||||||
|
twist_cfg.ranges.heading = None
|
||||||
|
twist_cfg.rel_heading_envs = 0.0
|
||||||
|
twist_cfg.rel_standing_envs = 0.0
|
||||||
|
twist_cfg.resampling_time_range = (1.0e9, 1.0e9)
|
||||||
|
if cfg.reward_config:
|
||||||
|
reward_path = Path(cfg.reward_config).expanduser().resolve(strict=True)
|
||||||
|
with reward_path.open(encoding="utf-8") as stream:
|
||||||
|
apply_reward_configuration(env_cfg, validate_configuration(json.load(stream)))
|
||||||
|
|
||||||
|
env = ManagerBasedRlEnv(cfg=env_cfg, device=cfg.device or "cuda:0")
|
||||||
|
wrapped = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions)
|
||||||
|
try:
|
||||||
|
runner_cls = load_runner_cls(task_id) or MjlabOnPolicyRunner
|
||||||
|
runner = runner_cls(wrapped, asdict(agent_cfg), log_dir=None, device=wrapped.device)
|
||||||
|
runner.load(
|
||||||
|
str(Path(cfg.checkpoint).expanduser().resolve(strict=True)),
|
||||||
|
load_cfg={"actor": True},
|
||||||
|
strict=True,
|
||||||
|
map_location=str(wrapped.device),
|
||||||
|
)
|
||||||
|
policy = runner.get_inference_policy(device=str(wrapped.device))
|
||||||
|
twist = wrapped.unwrapped.command_manager.get_term("twist")
|
||||||
|
_fixed_commands(twist.command)
|
||||||
|
obs = wrapped.get_observations()
|
||||||
|
|
||||||
|
sums = {name: 0.0 for name in METRIC_NAMES}
|
||||||
|
samples = 0
|
||||||
|
terminations = 0
|
||||||
|
completions = 0
|
||||||
|
with torch.inference_mode():
|
||||||
|
for _ in range(cfg.steps_per_seed):
|
||||||
|
_fixed_commands(twist.command)
|
||||||
|
obs = wrapped.get_observations()
|
||||||
|
actions = policy(obs)
|
||||||
|
obs, _rewards, _dones, _extras = wrapped.step(actions)
|
||||||
|
manager = wrapped.unwrapped.metrics_manager
|
||||||
|
for index, name in enumerate(manager.active_terms):
|
||||||
|
if name in sums:
|
||||||
|
sums[name] += float(torch.sum(manager._step_values[:, index]).item())
|
||||||
|
samples += wrapped.num_envs
|
||||||
|
terminated = wrapped.unwrapped.reset_terminated
|
||||||
|
timed_out = wrapped.unwrapped.reset_time_outs
|
||||||
|
terminations += int(torch.count_nonzero(terminated).item())
|
||||||
|
completions += int(torch.count_nonzero(terminated | timed_out).item())
|
||||||
|
result = {name: sums[name] / max(samples, 1) for name in METRIC_NAMES}
|
||||||
|
result["fall_rate"] = terminations / max(completions, wrapped.num_envs)
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
wrapped.close()
|
||||||
|
|
||||||
|
|
||||||
|
def run_evaluation(task_id: str, cfg: EvaluateConfig) -> dict:
|
||||||
|
configure_torch_backends()
|
||||||
|
selected = cfg.gpu_ids
|
||||||
|
if selected is None:
|
||||||
|
os.environ["CUDA_VISIBLE_DEVICES"] = ""
|
||||||
|
device = "cpu"
|
||||||
|
else:
|
||||||
|
if selected == "all":
|
||||||
|
selected = [0]
|
||||||
|
os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(map(str, selected))
|
||||||
|
device = cfg.device or "cuda:0"
|
||||||
|
os.environ["MUJOCO_GL"] = "egl"
|
||||||
|
cfg = EvaluateConfig(**{**asdict(cfg), "device": device})
|
||||||
|
|
||||||
|
checkpoint = Path(cfg.checkpoint).expanduser().resolve(strict=True)
|
||||||
|
output = Path(cfg.output).expanduser().resolve()
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
per_seed = [_evaluate_seed(task_id, cfg, seed) for seed in cfg.seeds]
|
||||||
|
metrics = {
|
||||||
|
key: fmean(seed_metrics[key] for seed_metrics in per_seed)
|
||||||
|
for key in (*METRIC_NAMES, "fall_rate")
|
||||||
|
}
|
||||||
|
deviations = {
|
||||||
|
key: pstdev(seed_metrics[key] for seed_metrics in per_seed)
|
||||||
|
for key in (*METRIC_NAMES, "fall_rate")
|
||||||
|
}
|
||||||
|
result = {
|
||||||
|
"protocolVersion": 1,
|
||||||
|
"taskId": task_id,
|
||||||
|
"checkpoint": checkpoint.name,
|
||||||
|
"checkpointSha256": _sha256(checkpoint),
|
||||||
|
"seeds": list(cfg.seeds),
|
||||||
|
"numEnvs": cfg.num_envs,
|
||||||
|
"stepsPerSeed": cfg.steps_per_seed,
|
||||||
|
"scenarios": [list(value) for value in SCENARIOS],
|
||||||
|
"metrics": metrics,
|
||||||
|
"metricStd": deviations,
|
||||||
|
"seedMetrics": [
|
||||||
|
{"seed": seed, "metrics": values}
|
||||||
|
for seed, values in zip(cfg.seeds, per_seed, strict=True)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
writer = SummaryWriter(log_dir=str(output.parent / "evaluation-events"))
|
||||||
|
try:
|
||||||
|
for name, value in metrics.items():
|
||||||
|
writer.add_scalar(f"Evaluation/{name}", value, 0)
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
print("MUJOCO_EVALUATION " + json.dumps({"output": str(output), "metrics": metrics}))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
import mjlab.tasks # noqa: F401
|
||||||
|
import src.tasks # noqa: F401
|
||||||
|
|
||||||
|
chosen_task, remaining = tyro.cli(
|
||||||
|
tyro.extras.literal_type_from_choices(list_tasks()),
|
||||||
|
add_help=False,
|
||||||
|
return_unknown_args=True,
|
||||||
|
config=mjlab.TYRO_FLAGS,
|
||||||
|
)
|
||||||
|
args = tyro.cli(
|
||||||
|
EvaluateConfig,
|
||||||
|
args=remaining,
|
||||||
|
prog=sys.argv[0] + f" {chosen_task}",
|
||||||
|
config=mjlab.TYRO_FLAGS,
|
||||||
|
)
|
||||||
|
run_evaluation(chosen_task, args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Script to train RL agent with RSL-RL."""
|
"""Script to train RL agent with RSL-RL."""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -10,8 +11,10 @@ from typing import Literal, cast
|
|||||||
|
|
||||||
# 训练器作为仓库内置子集直接从 scripts/ 启动,不要求额外执行 pip install -e。
|
# 训练器作为仓库内置子集直接从 scripts/ 启动,不要求额外执行 pip install -e。
|
||||||
TRAINER_ROOT = Path(__file__).resolve().parents[1]
|
TRAINER_ROOT = Path(__file__).resolve().parents[1]
|
||||||
if str(TRAINER_ROOT) not in sys.path:
|
SERVICE_ROOT = TRAINER_ROOT.parent
|
||||||
sys.path.insert(0, str(TRAINER_ROOT))
|
for source_root in (TRAINER_ROOT, SERVICE_ROOT):
|
||||||
|
if str(source_root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(source_root))
|
||||||
|
|
||||||
import tyro
|
import tyro
|
||||||
import warp as wp
|
import warp as wp
|
||||||
@@ -32,6 +35,8 @@ from mjlab.utils.os import dump_yaml, get_checkpoint_path
|
|||||||
from mjlab.utils.torch import configure_torch_backends
|
from mjlab.utils.torch import configure_torch_backends
|
||||||
from mjlab.utils.wrappers import VideoRecorder
|
from mjlab.utils.wrappers import VideoRecorder
|
||||||
|
|
||||||
|
from tuning.schema import apply_reward_configuration, validate_configuration
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TrainConfig:
|
class TrainConfig:
|
||||||
@@ -44,6 +49,10 @@ class TrainConfig:
|
|||||||
enable_nan_guard: bool = False
|
enable_nan_guard: bool = False
|
||||||
torchrunx_log_dir: str | None = None
|
torchrunx_log_dir: str | None = None
|
||||||
gpu_ids: list[int] | Literal["all"] | None = field(default_factory=lambda: [0])
|
gpu_ids: list[int] | Literal["all"] | None = field(default_factory=lambda: [0])
|
||||||
|
output_dir: str | None = None
|
||||||
|
resume_checkpoint: str | None = None
|
||||||
|
reward_config: str | None = None
|
||||||
|
reward_config_json: str | None = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_task(task_id: str) -> "TrainConfig":
|
def from_task(task_id: str) -> "TrainConfig":
|
||||||
@@ -52,7 +61,27 @@ class TrainConfig:
|
|||||||
return TrainConfig(env=env_cfg, agent=agent_cfg)
|
return TrainConfig(env=env_cfg, agent=agent_cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_reward_config(path: str | None, inline: str | None) -> dict | None:
|
||||||
|
if path is not None and inline is not None:
|
||||||
|
raise ValueError("Use only one of reward_config and reward_config_json")
|
||||||
|
if inline is not None:
|
||||||
|
if len(inline.encode("utf-8")) > 64 * 1024:
|
||||||
|
raise ValueError("Reward configuration is larger than 64 KiB")
|
||||||
|
return validate_configuration(json.loads(inline))
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
source = Path(path).expanduser().resolve(strict=True)
|
||||||
|
if source.stat().st_size > 64 * 1024:
|
||||||
|
raise ValueError("Reward configuration is larger than 64 KiB")
|
||||||
|
with source.open(encoding="utf-8") as stream:
|
||||||
|
return validate_configuration(json.load(stream))
|
||||||
|
|
||||||
|
|
||||||
def run_train(task_id: str, cfg: TrainConfig, log_dir: Path) -> None:
|
def run_train(task_id: str, cfg: TrainConfig, log_dir: Path) -> None:
|
||||||
|
reward_config = _load_reward_config(cfg.reward_config, cfg.reward_config_json)
|
||||||
|
if reward_config is not None:
|
||||||
|
apply_reward_configuration(cfg.env, reward_config)
|
||||||
|
|
||||||
cuda_visible = os.environ.get("CUDA_VISIBLE_DEVICES", "")
|
cuda_visible = os.environ.get("CUDA_VISIBLE_DEVICES", "")
|
||||||
if cuda_visible == "":
|
if cuda_visible == "":
|
||||||
device = "cpu"
|
device = "cpu"
|
||||||
@@ -109,7 +138,10 @@ def run_train(task_id: str, cfg: TrainConfig, log_dir: Path) -> None:
|
|||||||
log_root_path = log_dir.parent # Go up from specific run dir to experiment dir.
|
log_root_path = log_dir.parent # Go up from specific run dir to experiment dir.
|
||||||
|
|
||||||
resume_path: Path | None = None
|
resume_path: Path | None = None
|
||||||
if cfg.agent.resume:
|
explicit_resume = cfg.resume_checkpoint is not None
|
||||||
|
if explicit_resume:
|
||||||
|
resume_path = Path(cfg.resume_checkpoint).expanduser().resolve(strict=True)
|
||||||
|
elif cfg.agent.resume:
|
||||||
# Load checkpoint from local filesystem.
|
# Load checkpoint from local filesystem.
|
||||||
resume_path = get_checkpoint_path(
|
resume_path = get_checkpoint_path(
|
||||||
log_root_path, cfg.agent.load_run, cfg.agent.load_checkpoint
|
log_root_path, cfg.agent.load_run, cfg.agent.load_checkpoint
|
||||||
@@ -141,17 +173,33 @@ def run_train(task_id: str, cfg: TrainConfig, log_dir: Path) -> None:
|
|||||||
runner.add_git_repo_to_log(__file__)
|
runner.add_git_repo_to_log(__file__)
|
||||||
if resume_path is not None:
|
if resume_path is not None:
|
||||||
print(f"[INFO]: Loading model checkpoint from: {resume_path}")
|
print(f"[INFO]: Loading model checkpoint from: {resume_path}")
|
||||||
runner.load(str(resume_path))
|
runner.load(str(resume_path), map_location=device)
|
||||||
|
if explicit_resume:
|
||||||
|
# RSL-RL stores the last completed zero-based iteration and otherwise
|
||||||
|
# repeats it after load. Explicit tuning promotion uses an absolute target.
|
||||||
|
runner.current_learning_iteration += 1
|
||||||
|
|
||||||
# Only write config files from rank 0 to avoid race conditions.
|
# Only write config files from rank 0 to avoid race conditions.
|
||||||
if rank == 0:
|
if rank == 0:
|
||||||
dump_yaml(log_dir / "params" / "env.yaml", env_cfg)
|
dump_yaml(log_dir / "params" / "env.yaml", env_cfg)
|
||||||
dump_yaml(log_dir / "params" / "agent.yaml", agent_cfg)
|
dump_yaml(log_dir / "params" / "agent.yaml", agent_cfg)
|
||||||
|
if reward_config is not None:
|
||||||
runner.learn(
|
reward_snapshot = log_dir / "params" / "reward_config.json"
|
||||||
num_learning_iterations=cfg.agent.max_iterations, init_at_random_ep_len=True
|
reward_snapshot.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
reward_snapshot.write_text(
|
||||||
|
json.dumps(reward_config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
iterations = cfg.agent.max_iterations
|
||||||
|
if explicit_resume:
|
||||||
|
iterations = max(0, cfg.agent.max_iterations - runner.current_learning_iteration)
|
||||||
|
print(
|
||||||
|
f"[INFO] Learning target: current={runner.current_learning_iteration}, "
|
||||||
|
f"additional={iterations}, target={cfg.agent.max_iterations}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
runner.learn(num_learning_iterations=iterations, init_at_random_ep_len=True)
|
||||||
|
|
||||||
env.close()
|
env.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -159,12 +207,15 @@ def launch_training(task_id: str, args: TrainConfig | None = None):
|
|||||||
args = args or TrainConfig.from_task(task_id)
|
args = args or TrainConfig.from_task(task_id)
|
||||||
|
|
||||||
# Create log directory once before launching workers.
|
# Create log directory once before launching workers.
|
||||||
log_root_path = Path("logs") / "rsl_rl" / args.agent.experiment_name
|
if args.output_dir:
|
||||||
log_root_path.resolve()
|
log_dir = Path(args.output_dir).expanduser().resolve()
|
||||||
|
else:
|
||||||
|
log_root_path = (Path("logs") / "rsl_rl" / args.agent.experiment_name).resolve()
|
||||||
log_dir_name = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
log_dir_name = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||||
if args.agent.run_name:
|
if args.agent.run_name:
|
||||||
log_dir_name += f"_{args.agent.run_name}"
|
log_dir_name += f"_{args.agent.run_name}"
|
||||||
log_dir = log_root_path / log_dir_name
|
log_dir = log_root_path / log_dir_name
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Select GPUs based on CUDA_VISIBLE_DEVICES and user specification.
|
# Select GPUs based on CUDA_VISIBLE_DEVICES and user specification.
|
||||||
selected_gpus, num_gpus = select_gpus(args.gpu_ids)
|
selected_gpus, num_gpus = select_gpus(args.gpu_ids)
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ def unitree_go2_rough_env_cfg(
|
|||||||
cfg.rewards["body_ang_vel"].params["asset_cfg"].body_names = ("base_link",)
|
cfg.rewards["body_ang_vel"].params["asset_cfg"].body_names = ("base_link",)
|
||||||
cfg.rewards["foot_clearance"].params["asset_cfg"].site_names = site_names
|
cfg.rewards["foot_clearance"].params["asset_cfg"].site_names = site_names
|
||||||
cfg.rewards["foot_slip"].params["asset_cfg"].site_names = site_names
|
cfg.rewards["foot_slip"].params["asset_cfg"].site_names = site_names
|
||||||
|
cfg.metrics["slip_velocity"].params["asset_cfg"].site_names = site_names
|
||||||
|
|
||||||
cfg.terminations["illegal_contact"] = TerminationTermCfg(
|
cfg.terminations["illegal_contact"] = TerminationTermCfg(
|
||||||
func=mdp.illegal_contact,
|
func=mdp.illegal_contact,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from mjlab.envs.mdp import * # noqa: F401, F403
|
from mjlab.envs.mdp import * # noqa: F401, F403
|
||||||
|
|
||||||
from .curriculums import * # noqa: F403
|
from .curriculums import * # noqa: F403
|
||||||
|
from .metrics import * # noqa: F403
|
||||||
from .observations import * # noqa: F403
|
from .observations import * # noqa: F403
|
||||||
from .rewards import * # noqa: F403
|
from .rewards import * # noqa: F403
|
||||||
from .terminations import * # noqa: F403
|
from .terminations import * # noqa: F403
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Reward-weight-independent quality metrics for velocity tasks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from mjlab.entity import Entity
|
||||||
|
from mjlab.managers.scene_entity_config import SceneEntityCfg
|
||||||
|
from mjlab.sensor import ContactSensor
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from mjlab.envs import ManagerBasedRlEnv
|
||||||
|
|
||||||
|
_DEFAULT_ASSET_CFG = SceneEntityCfg("robot")
|
||||||
|
|
||||||
|
|
||||||
|
def linear_velocity_rmse(
|
||||||
|
env: ManagerBasedRlEnv,
|
||||||
|
command_name: str,
|
||||||
|
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
|
||||||
|
) -> torch.Tensor:
|
||||||
|
"""Per-step commanded-vs-actual base linear velocity RMSE in body frame."""
|
||||||
|
asset: Entity = env.scene[asset_cfg.name]
|
||||||
|
command = env.command_manager.get_command(command_name)
|
||||||
|
assert command is not None
|
||||||
|
actual = asset.data.root_link_lin_vel_b
|
||||||
|
error = torch.cat((command[:, :2] - actual[:, :2], -actual[:, 2:3]), dim=1)
|
||||||
|
return torch.sqrt(torch.mean(torch.square(error), dim=1))
|
||||||
|
|
||||||
|
|
||||||
|
def angular_velocity_rmse(
|
||||||
|
env: ManagerBasedRlEnv,
|
||||||
|
command_name: str,
|
||||||
|
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
|
||||||
|
) -> torch.Tensor:
|
||||||
|
"""Per-step commanded-vs-actual base angular velocity RMSE in body frame."""
|
||||||
|
asset: Entity = env.scene[asset_cfg.name]
|
||||||
|
command = env.command_manager.get_command(command_name)
|
||||||
|
assert command is not None
|
||||||
|
actual = asset.data.root_link_ang_vel_b
|
||||||
|
desired = torch.zeros_like(actual)
|
||||||
|
desired[:, 2] = command[:, 2]
|
||||||
|
return torch.sqrt(torch.mean(torch.square(desired - actual), dim=1))
|
||||||
|
|
||||||
|
|
||||||
|
def orientation_error(
|
||||||
|
env: ManagerBasedRlEnv,
|
||||||
|
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
|
||||||
|
) -> torch.Tensor:
|
||||||
|
"""Magnitude of projected gravity in the base x/y plane; zero is upright."""
|
||||||
|
asset: Entity = env.scene[asset_cfg.name]
|
||||||
|
return torch.linalg.vector_norm(asset.data.projected_gravity_b[:, :2], dim=1)
|
||||||
|
|
||||||
|
|
||||||
|
def fall_indicator(env: ManagerBasedRlEnv) -> torch.Tensor:
|
||||||
|
"""One on non-timeout terminal steps, otherwise zero."""
|
||||||
|
return env.termination_manager.terminated.float()
|
||||||
|
|
||||||
|
|
||||||
|
def slip_velocity(
|
||||||
|
env: ManagerBasedRlEnv,
|
||||||
|
sensor_name: str,
|
||||||
|
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
|
||||||
|
) -> torch.Tensor:
|
||||||
|
"""Mean x/y velocity of feet currently touching the ground."""
|
||||||
|
asset: Entity = env.scene[asset_cfg.name]
|
||||||
|
sensor: ContactSensor = env.scene[sensor_name]
|
||||||
|
assert sensor.data.found is not None
|
||||||
|
contact = (sensor.data.found > 0).float()
|
||||||
|
speed = torch.linalg.vector_norm(
|
||||||
|
asset.data.site_lin_vel_w[:, asset_cfg.site_ids, :2], dim=-1
|
||||||
|
)
|
||||||
|
count = torch.clamp(torch.sum(contact, dim=1), min=1.0)
|
||||||
|
return torch.sum(speed * contact, dim=1) / count
|
||||||
|
|
||||||
|
|
||||||
|
def mechanical_power(
|
||||||
|
env: ManagerBasedRlEnv,
|
||||||
|
asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG,
|
||||||
|
) -> torch.Tensor:
|
||||||
|
"""Positive actuator mechanical power in watts (regeneration is ignored)."""
|
||||||
|
asset: Entity = env.scene[asset_cfg.name]
|
||||||
|
torque = asset.data.actuator_force[:, asset_cfg.actuator_ids]
|
||||||
|
velocity = asset.data.joint_vel[:, asset_cfg.joint_ids]
|
||||||
|
count = min(torque.shape[1], velocity.shape[1])
|
||||||
|
return torch.sum(torch.clamp(torque[:, :count] * velocity[:, :count], min=0.0), dim=1)
|
||||||
@@ -140,8 +140,27 @@ def make_velocity_env_cfg() -> ManagerBasedRlEnvCfg:
|
|||||||
##
|
##
|
||||||
|
|
||||||
metrics = {
|
metrics = {
|
||||||
"mean_action_acc": MetricsTermCfg(
|
"linear_velocity_rmse": MetricsTermCfg(
|
||||||
func=mdp.mean_action_acc,
|
func=mdp.linear_velocity_rmse,
|
||||||
|
params={"command_name": "twist"},
|
||||||
|
),
|
||||||
|
"angular_velocity_rmse": MetricsTermCfg(
|
||||||
|
func=mdp.angular_velocity_rmse,
|
||||||
|
params={"command_name": "twist"},
|
||||||
|
),
|
||||||
|
"mean_action_acc": MetricsTermCfg(func=mdp.mean_action_acc),
|
||||||
|
"orientation_error": MetricsTermCfg(func=mdp.orientation_error),
|
||||||
|
"fall_indicator": MetricsTermCfg(func=mdp.fall_indicator),
|
||||||
|
"slip_velocity": MetricsTermCfg(
|
||||||
|
func=mdp.slip_velocity,
|
||||||
|
params={
|
||||||
|
"sensor_name": "feet_ground_contact",
|
||||||
|
"asset_cfg": SceneEntityCfg("robot", site_names=()), # Set per-robot.
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"mechanical_power": MetricsTermCfg(
|
||||||
|
func=mdp.mechanical_power,
|
||||||
|
params={"asset_cfg": SceneEntityCfg("robot", joint_names=(".*",))},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +317,11 @@ def make_velocity_env_cfg() -> ManagerBasedRlEnvCfg:
|
|||||||
params={"sensor_name": "robot/root_angmom"},
|
params={"sensor_name": "robot/root_angmom"},
|
||||||
),
|
),
|
||||||
"is_terminated": RewardTermCfg(func=mdp.is_terminated, weight=-200.0),
|
"is_terminated": RewardTermCfg(func=mdp.is_terminated, weight=-200.0),
|
||||||
|
"electrical_power": RewardTermCfg(
|
||||||
|
func=mdp.electrical_power_cost,
|
||||||
|
weight=0.0,
|
||||||
|
params={"asset_cfg": SceneEntityCfg("robot", joint_names=(".*",))},
|
||||||
|
),
|
||||||
"joint_acc_l2": RewardTermCfg(func=mdp.joint_acc_l2, weight=-2.5e-7),
|
"joint_acc_l2": RewardTermCfg(func=mdp.joint_acc_l2, weight=-2.5e-7),
|
||||||
"joint_pos_limits": RewardTermCfg(func=mdp.joint_pos_limits, weight=-10.0),
|
"joint_pos_limits": RewardTermCfg(func=mdp.joint_pos_limits, weight=-10.0),
|
||||||
"action_rate_l2": RewardTermCfg(func=mdp.action_rate_l2, weight=-0.05),
|
"action_rate_l2": RewardTermCfg(func=mdp.action_rate_l2, weight=-0.05),
|
||||||
|
|||||||
+198
-21
@@ -23,9 +23,14 @@ from http import HTTPStatus
|
|||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import unquote, urlsplit
|
from urllib.parse import parse_qs, unquote, urlsplit
|
||||||
|
|
||||||
VERSION = "0.3.0"
|
from tuning.manager import TuningError, TuningManager
|
||||||
|
from tuning.process import GpuLease, ResourceBusyError
|
||||||
|
from tuning.schema import RewardConfigError
|
||||||
|
from tuning.scoring import EvaluationError
|
||||||
|
|
||||||
|
VERSION = "0.4.0"
|
||||||
# 浏览器当前 ONNX 运行时只实现 Go2 的 47→12 部署契约;其他任务须由服务启动参数显式放行。
|
# 浏览器当前 ONNX 运行时只实现 Go2 的 47→12 部署契约;其他任务须由服务启动参数显式放行。
|
||||||
DEFAULT_TASKS = ("Unitree-Go2-Flat",)
|
DEFAULT_TASKS = ("Unitree-Go2-Flat",)
|
||||||
ACTIVE_STATES = {"queued", "running"}
|
ACTIVE_STATES = {"queued", "running"}
|
||||||
@@ -65,6 +70,7 @@ class TrainingConfig:
|
|||||||
device: str
|
device: str
|
||||||
gpu_ids: list[int]
|
gpu_ids: list[int]
|
||||||
wandb_mode: str
|
wandb_mode: str
|
||||||
|
reward_config: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -110,6 +116,7 @@ class TrainingManager:
|
|||||||
python: str,
|
python: str,
|
||||||
tasks: tuple[str, ...],
|
tasks: tuple[str, ...],
|
||||||
check_environment: bool = True,
|
check_environment: bool = True,
|
||||||
|
lease: GpuLease | None = None,
|
||||||
):
|
):
|
||||||
self.trainer_root = trainer_root.expanduser().resolve()
|
self.trainer_root = trainer_root.expanduser().resolve()
|
||||||
self.python = str(Path(python).expanduser()) if os.sep in python else python
|
self.python = str(Path(python).expanduser()) if os.sep in python else python
|
||||||
@@ -118,6 +125,8 @@ class TrainingManager:
|
|||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self.check_environment = check_environment
|
self.check_environment = check_environment
|
||||||
self._environment_error: str | None | bool = False
|
self._environment_error: str | None | bool = False
|
||||||
|
self.lease = lease or GpuLease()
|
||||||
|
self.preset_resolver: Any = None
|
||||||
|
|
||||||
def readiness_error(self) -> str | None:
|
def readiness_error(self) -> str | None:
|
||||||
if not self.trainer_root.is_dir():
|
if not self.trainer_root.is_dir():
|
||||||
@@ -204,6 +213,17 @@ class TrainingManager:
|
|||||||
wandb_mode = payload.get("wandbMode", "offline")
|
wandb_mode = payload.get("wandbMode", "offline")
|
||||||
if wandb_mode not in ("offline", "disabled", "online"):
|
if wandb_mode not in ("offline", "disabled", "online"):
|
||||||
raise ApiError(HTTPStatus.BAD_REQUEST, "wandbMode 必须是 offline、disabled 或 online")
|
raise ApiError(HTTPStatus.BAD_REQUEST, "wandbMode 必须是 offline、disabled 或 online")
|
||||||
|
preset_id = payload.get("rewardPresetId")
|
||||||
|
reward_config = None
|
||||||
|
if preset_id is not None:
|
||||||
|
if not isinstance(preset_id, str) or not re.fullmatch(r"[0-9a-f]{32}", preset_id):
|
||||||
|
raise ApiError(HTTPStatus.BAD_REQUEST, "rewardPresetId 格式无效")
|
||||||
|
if self.preset_resolver is None:
|
||||||
|
raise ApiError(HTTPStatus.BAD_REQUEST, "奖励 preset 服务未就绪")
|
||||||
|
try:
|
||||||
|
reward_config = self.preset_resolver(preset_id)
|
||||||
|
except KeyError as error:
|
||||||
|
raise ApiError(HTTPStatus.BAD_REQUEST, "奖励 preset 不存在") from error
|
||||||
return TrainingConfig(
|
return TrainingConfig(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
num_envs=integer("numEnvs", 1, 16384),
|
num_envs=integer("numEnvs", 1, 16384),
|
||||||
@@ -213,6 +233,7 @@ class TrainingManager:
|
|||||||
device=device,
|
device=device,
|
||||||
gpu_ids=raw_gpu_ids,
|
gpu_ids=raw_gpu_ids,
|
||||||
wandb_mode=wandb_mode,
|
wandb_mode=wandb_mode,
|
||||||
|
reward_config=reward_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
def start(self, payload: Any) -> dict[str, Any]:
|
def start(self, payload: Any) -> dict[str, Any]:
|
||||||
@@ -232,10 +253,20 @@ class TrainingManager:
|
|||||||
raise ApiError(HTTPStatus.CONFLICT, "训练任务历史已满,请稍后重试")
|
raise ApiError(HTTPStatus.CONFLICT, "训练任务历史已满,请稍后重试")
|
||||||
del self.jobs[completed]
|
del self.jobs[completed]
|
||||||
job = TrainingJob(id=uuid.uuid4().hex, config=config)
|
job = TrainingJob(id=uuid.uuid4().hex, config=config)
|
||||||
|
owner = f"training:{job.id}"
|
||||||
|
try:
|
||||||
|
self.lease.acquire(owner)
|
||||||
|
except ResourceBusyError as error:
|
||||||
|
raise ApiError(HTTPStatus.CONFLICT, str(error)) from error
|
||||||
self.jobs[job.id] = job
|
self.jobs[job.id] = job
|
||||||
|
try:
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=self._run, args=(job,), name=f"training-{job.id[:8]}", daemon=True
|
target=self._run, args=(job,), name=f"training-{job.id[:8]}", daemon=True
|
||||||
).start()
|
).start()
|
||||||
|
except Exception:
|
||||||
|
self.jobs.pop(job.id, None)
|
||||||
|
self.lease.release(owner)
|
||||||
|
raise
|
||||||
return job.public()
|
return job.public()
|
||||||
|
|
||||||
def get(self, job_id: str) -> dict[str, Any]:
|
def get(self, job_id: str) -> dict[str, Any]:
|
||||||
@@ -305,6 +336,13 @@ class TrainingManager:
|
|||||||
f"--agent.seed={config.seed}",
|
f"--agent.seed={config.seed}",
|
||||||
f"--agent.run-name={config.run_name}",
|
f"--agent.run-name={config.run_name}",
|
||||||
]
|
]
|
||||||
|
if config.reward_config is not None:
|
||||||
|
command.extend(
|
||||||
|
(
|
||||||
|
"--reward-config-json",
|
||||||
|
json.dumps(config.reward_config, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
)
|
||||||
|
)
|
||||||
if config.device == "cpu":
|
if config.device == "cpu":
|
||||||
command.extend(("--gpu-ids", "None"))
|
command.extend(("--gpu-ids", "None"))
|
||||||
else:
|
else:
|
||||||
@@ -408,13 +446,16 @@ class TrainingManager:
|
|||||||
job.state = "cancelled" if job.cancel_requested else "failed"
|
job.state = "cancelled" if job.cancel_requested else "failed"
|
||||||
job.message = f"启动训练失败:{error}"
|
job.message = f"启动训练失败:{error}"
|
||||||
job.logs.append(job.message)
|
job.logs.append(job.message)
|
||||||
|
finally:
|
||||||
|
self.lease.release(f"training:{job.id}")
|
||||||
|
|
||||||
|
|
||||||
class TrainingRequestHandler(BaseHTTPRequestHandler):
|
class TrainingRequestHandler(BaseHTTPRequestHandler):
|
||||||
manager: TrainingManager
|
manager: TrainingManager
|
||||||
|
tuning_manager: TuningManager
|
||||||
allowed_origins: tuple[str, ...] = ()
|
allowed_origins: tuple[str, ...] = ()
|
||||||
access_token = ""
|
access_token = ""
|
||||||
server_version = "MuJoCoLocalTraining/0.3"
|
server_version = "MuJoCoLocalTraining/0.4"
|
||||||
|
|
||||||
def log_message(self, format: str, *args: Any) -> None:
|
def log_message(self, format: str, *args: Any) -> None:
|
||||||
sys.stderr.write(f"[{self.log_date_time_string()}] {format % args}\n")
|
sys.stderr.write(f"[{self.log_date_time_string()}] {format % args}\n")
|
||||||
@@ -455,6 +496,12 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
|
|||||||
def _error(self, error: Exception) -> None:
|
def _error(self, error: Exception) -> None:
|
||||||
if isinstance(error, ApiError):
|
if isinstance(error, ApiError):
|
||||||
self._json(error.status, {"error": str(error)})
|
self._json(error.status, {"error": str(error)})
|
||||||
|
elif isinstance(error, KeyError):
|
||||||
|
self._json(HTTPStatus.NOT_FOUND, {"error": "调参 session、trial 或 proposal 不存在"})
|
||||||
|
elif isinstance(error, ResourceBusyError):
|
||||||
|
self._json(HTTPStatus.CONFLICT, {"error": str(error)})
|
||||||
|
elif isinstance(error, (TuningError, RewardConfigError, EvaluationError)):
|
||||||
|
self._json(HTTPStatus.BAD_REQUEST, {"error": str(error)})
|
||||||
else:
|
else:
|
||||||
self._json(
|
self._json(
|
||||||
HTTPStatus.INTERNAL_SERVER_ERROR, {"error": f"本地训练服务内部错误:{error}"}
|
HTTPStatus.INTERNAL_SERVER_ERROR, {"error": f"本地训练服务内部错误:{error}"}
|
||||||
@@ -490,12 +537,24 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
|
|||||||
match = re.fullmatch(r"/api/training/jobs/([0-9a-f]{32})(/artifacts/policy\.onnx)?", path)
|
match = re.fullmatch(r"/api/training/jobs/([0-9a-f]{32})(/artifacts/policy\.onnx)?", path)
|
||||||
return (unquote(match.group(1)), bool(match.group(2))) if match else (None, False)
|
return (unquote(match.group(1)), bool(match.group(2))) if match else (None, False)
|
||||||
|
|
||||||
|
def _send_file(self, file_path: Path, filename: str) -> None:
|
||||||
|
size = file_path.stat().st_size
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self._cors()
|
||||||
|
self.send_header("Content-Type", "application/octet-stream")
|
||||||
|
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
|
||||||
|
self.send_header("Content-Length", str(size))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
with file_path.open("rb") as source:
|
||||||
|
shutil.copyfileobj(source, self.wfile)
|
||||||
|
|
||||||
def do_OPTIONS(self) -> None:
|
def do_OPTIONS(self) -> None:
|
||||||
try:
|
try:
|
||||||
self._ensure_origin()
|
self._ensure_origin()
|
||||||
self.send_response(HTTPStatus.NO_CONTENT)
|
self.send_response(HTTPStatus.NO_CONTENT)
|
||||||
self._cors()
|
self._cors()
|
||||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||||
self.send_header("Access-Control-Max-Age", "600")
|
self.send_header("Access-Control-Max-Age", "600")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
@@ -505,25 +564,61 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
|
|||||||
def do_GET(self) -> None:
|
def do_GET(self) -> None:
|
||||||
try:
|
try:
|
||||||
self._ensure_request()
|
self._ensure_request()
|
||||||
path = urlsplit(self.path).path
|
parsed = urlsplit(self.path)
|
||||||
|
path = parsed.path
|
||||||
if path == "/api/training/health":
|
if path == "/api/training/health":
|
||||||
self._json(HTTPStatus.OK, self.manager.health())
|
health = self.manager.health()
|
||||||
|
health["tuning"] = self.tuning_manager.capability()
|
||||||
|
health["resourceOwner"] = self.manager.lease.public()
|
||||||
|
self._json(HTTPStatus.OK, health)
|
||||||
|
return
|
||||||
|
if path == "/api/tuning/capabilities":
|
||||||
|
self._json(HTTPStatus.OK, self.tuning_manager.capability())
|
||||||
|
return
|
||||||
|
if path == "/api/tuning/sessions":
|
||||||
|
self._json(HTTPStatus.OK, {"sessions": self.tuning_manager.list()})
|
||||||
|
return
|
||||||
|
if path == "/api/tuning/presets":
|
||||||
|
self._json(HTTPStatus.OK, {"presets": self.tuning_manager.storage.list_presets()})
|
||||||
|
return
|
||||||
|
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})", path)
|
||||||
|
if match:
|
||||||
|
self._json(HTTPStatus.OK, self.tuning_manager.detail(match.group(1)))
|
||||||
|
return
|
||||||
|
match = re.fullmatch(
|
||||||
|
r"/api/tuning/sessions/([0-9a-f]{32})/trials/([0-9a-f]{32})/metrics", path
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
query = parse_qs(parsed.query)
|
||||||
|
tags = [tag for value in query.get("tags", []) for tag in value.split(",") if tag]
|
||||||
|
try:
|
||||||
|
max_points = int(query.get("maxPoints", ["1000"])[0])
|
||||||
|
after_raw = query.get("afterStep", [None])[0]
|
||||||
|
after_step = int(after_raw) if after_raw is not None else None
|
||||||
|
except ValueError as error:
|
||||||
|
raise TuningError("maxPoints/afterStep 必须是整数") from error
|
||||||
|
if not 10 <= max_points <= 5000:
|
||||||
|
raise TuningError("maxPoints 必须在 10–5000 之间")
|
||||||
|
if after_step is not None and after_step < -1:
|
||||||
|
raise TuningError("afterStep 不能小于 -1")
|
||||||
|
self._json(
|
||||||
|
HTTPStatus.OK,
|
||||||
|
self.tuning_manager.metrics(
|
||||||
|
match.group(1), match.group(2), tags or None, max_points, after_step
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
match = re.fullmatch(
|
||||||
|
r"/api/tuning/sessions/([0-9a-f]{32})/artifacts/best/policy\.onnx", path
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
self._send_file(self.tuning_manager.best_artifact(match.group(1)), "policy.onnx")
|
||||||
return
|
return
|
||||||
job_id, artifact = self._route(path)
|
job_id, artifact = self._route(path)
|
||||||
if not job_id:
|
if not job_id:
|
||||||
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
|
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
|
||||||
if artifact:
|
if artifact:
|
||||||
file_path = self.manager.artifact(job_id)
|
self._send_file(self.manager.artifact(job_id), "policy.onnx")
|
||||||
size = file_path.stat().st_size
|
|
||||||
self.send_response(HTTPStatus.OK)
|
|
||||||
self._cors()
|
|
||||||
self.send_header("Content-Type", "application/octet-stream")
|
|
||||||
self.send_header("Content-Disposition", 'attachment; filename="policy.onnx"')
|
|
||||||
self.send_header("Content-Length", str(size))
|
|
||||||
self.send_header("Cache-Control", "no-store")
|
|
||||||
self.end_headers()
|
|
||||||
with file_path.open("rb") as source:
|
|
||||||
shutil.copyfileobj(source, self.wfile)
|
|
||||||
else:
|
else:
|
||||||
self._json(HTTPStatus.OK, self.manager.get(job_id))
|
self._json(HTTPStatus.OK, self.manager.get(job_id))
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
@@ -532,16 +627,83 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
|
|||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
try:
|
try:
|
||||||
self._ensure_request()
|
self._ensure_request()
|
||||||
if urlsplit(self.path).path != "/api/training/jobs":
|
path = urlsplit(self.path).path
|
||||||
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
|
if path == "/api/training/jobs":
|
||||||
self._json(HTTPStatus.ACCEPTED, self.manager.start(self._payload()))
|
self._json(HTTPStatus.ACCEPTED, self.manager.start(self._payload()))
|
||||||
|
return
|
||||||
|
if path == "/api/tuning/agent/test":
|
||||||
|
self._json(HTTPStatus.OK, self.tuning_manager.test_agent())
|
||||||
|
return
|
||||||
|
if path == "/api/tuning/sessions":
|
||||||
|
self._json(HTTPStatus.ACCEPTED, self.tuning_manager.create(self._payload()))
|
||||||
|
return
|
||||||
|
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})/(pause|resume)", path)
|
||||||
|
if match:
|
||||||
|
action = (
|
||||||
|
self.tuning_manager.pause
|
||||||
|
if match.group(2) == "pause"
|
||||||
|
else self.tuning_manager.resume
|
||||||
|
)
|
||||||
|
self._json(HTTPStatus.ACCEPTED, action(match.group(1)))
|
||||||
|
return
|
||||||
|
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})/(step|rollback)", path)
|
||||||
|
if match:
|
||||||
|
action = (
|
||||||
|
self.tuning_manager.step
|
||||||
|
if match.group(2) == "step"
|
||||||
|
else self.tuning_manager.rollback
|
||||||
|
)
|
||||||
|
self._json(HTTPStatus.ACCEPTED, action(match.group(1), self._payload()))
|
||||||
|
return
|
||||||
|
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})/mode", path)
|
||||||
|
if match:
|
||||||
|
self._json(
|
||||||
|
HTTPStatus.ACCEPTED,
|
||||||
|
self.tuning_manager.set_mode(match.group(1), self._payload()),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
match = re.fullmatch(
|
||||||
|
r"/api/tuning/sessions/([0-9a-f]{32})/proposals/([0-9a-f]{32})/(approve|reject)",
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
action = (
|
||||||
|
self.tuning_manager.approve
|
||||||
|
if match.group(3) == "approve"
|
||||||
|
else self.tuning_manager.reject
|
||||||
|
)
|
||||||
|
self._json(
|
||||||
|
HTTPStatus.ACCEPTED, action(match.group(1), match.group(2), self._payload())
|
||||||
|
)
|
||||||
|
return
|
||||||
|
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
|
||||||
|
except Exception as error:
|
||||||
|
self._error(error)
|
||||||
|
|
||||||
|
def do_PUT(self) -> None:
|
||||||
|
try:
|
||||||
|
self._ensure_request()
|
||||||
|
path = urlsplit(self.path).path
|
||||||
|
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})/constraints", path)
|
||||||
|
if match:
|
||||||
|
self._json(
|
||||||
|
HTTPStatus.ACCEPTED,
|
||||||
|
self.tuning_manager.set_constraints(match.group(1), self._payload()),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
self._error(error)
|
self._error(error)
|
||||||
|
|
||||||
def do_DELETE(self) -> None:
|
def do_DELETE(self) -> None:
|
||||||
try:
|
try:
|
||||||
self._ensure_request()
|
self._ensure_request()
|
||||||
job_id, artifact = self._route(urlsplit(self.path).path)
|
path = urlsplit(self.path).path
|
||||||
|
match = re.fullmatch(r"/api/tuning/sessions/([0-9a-f]{32})", path)
|
||||||
|
if match:
|
||||||
|
self._json(HTTPStatus.ACCEPTED, self.tuning_manager.cancel(match.group(1)))
|
||||||
|
return
|
||||||
|
job_id, artifact = self._route(path)
|
||||||
if not job_id or artifact:
|
if not job_id or artifact:
|
||||||
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
|
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
|
||||||
self._json(HTTPStatus.ACCEPTED, self.manager.cancel(job_id))
|
self._json(HTTPStatus.ACCEPTED, self.manager.cancel(job_id))
|
||||||
@@ -574,6 +736,12 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--trainer-python", default=sys.executable, help="已安装 mjlab/torch 的 Python 解释器"
|
"--trainer-python", default=sys.executable, help="已安装 mjlab/torch 的 Python 解释器"
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--tuning-data-root",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="调参 SQLite 与 trial 产物目录;默认位于训练工程 logs/auto_tuning",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--task", action="append", dest="tasks", help="允许前端启动的任务 ID;可重复"
|
"--task", action="append", dest="tasks", help="允许前端启动的任务 ID;可重复"
|
||||||
)
|
)
|
||||||
@@ -593,10 +761,18 @@ def main() -> None:
|
|||||||
token = args.token or secrets.token_urlsafe(24)
|
token = args.token or secrets.token_urlsafe(24)
|
||||||
if len(token) < 16:
|
if len(token) < 16:
|
||||||
raise SystemExit("训练服务访问令牌至少需要 16 个字符")
|
raise SystemExit("训练服务访问令牌至少需要 16 个字符")
|
||||||
|
lease = GpuLease()
|
||||||
manager = TrainingManager(
|
manager = TrainingManager(
|
||||||
args.trainer_root, args.trainer_python, tuple(args.tasks or DEFAULT_TASKS)
|
args.trainer_root,
|
||||||
|
args.trainer_python,
|
||||||
|
tuple(args.tasks or DEFAULT_TASKS),
|
||||||
|
lease=lease,
|
||||||
)
|
)
|
||||||
|
tuning_root = args.tuning_data_root or (Path(args.trainer_root) / "logs" / "auto_tuning")
|
||||||
|
tuning_manager = TuningManager(args.trainer_root, args.trainer_python, tuning_root, lease)
|
||||||
|
manager.preset_resolver = tuning_manager.preset_config
|
||||||
TrainingRequestHandler.manager = manager
|
TrainingRequestHandler.manager = manager
|
||||||
|
TrainingRequestHandler.tuning_manager = tuning_manager
|
||||||
TrainingRequestHandler.allowed_origins = tuple(args.allow_origin)
|
TrainingRequestHandler.allowed_origins = tuple(args.allow_origin)
|
||||||
TrainingRequestHandler.access_token = token
|
TrainingRequestHandler.access_token = token
|
||||||
server = ThreadingHTTPServer((args.host, args.port), TrainingRequestHandler)
|
server = ThreadingHTTPServer((args.host, args.port), TrainingRequestHandler)
|
||||||
@@ -612,6 +788,7 @@ def main() -> None:
|
|||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\n正在停止本地训练服务…")
|
print("\n正在停止本地训练服务…")
|
||||||
finally:
|
finally:
|
||||||
|
tuning_manager.shutdown()
|
||||||
manager.shutdown()
|
manager.shutdown()
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -64,13 +65,7 @@ out.write_bytes(b'onnx')
|
|||||||
self.assertTrue((trainer_root / "scripts" / "train.py").is_file())
|
self.assertTrue((trainer_root / "scripts" / "train.py").is_file())
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
(
|
(
|
||||||
trainer_root
|
trainer_root / "src" / "assets" / "robots" / "unitree_go2" / "xmls" / "go2.xml"
|
||||||
/ "src"
|
|
||||||
/ "assets"
|
|
||||||
/ "robots"
|
|
||||||
/ "unitree_go2"
|
|
||||||
/ "xmls"
|
|
||||||
/ "go2.xml"
|
|
||||||
).is_file()
|
).is_file()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,6 +87,15 @@ out.write_bytes(b'onnx')
|
|||||||
)
|
)
|
||||||
self.assertEqual(command[-2:], ["--gpu-ids", "[0,2]"])
|
self.assertEqual(command[-2:], ["--gpu-ids", "[0,2]"])
|
||||||
|
|
||||||
|
def test_resolves_reward_preset_to_inline_validated_trainer_argument(self):
|
||||||
|
preset_id = "f" * 32
|
||||||
|
reward_config = {"weights": {"pose": 1.2}, "params": {}}
|
||||||
|
self.manager.preset_resolver = lambda value: reward_config if value == preset_id else None
|
||||||
|
config = self.manager.parse_config(self.payload(rewardPresetId=preset_id))
|
||||||
|
command = self.manager.command_for(config)
|
||||||
|
index = command.index("--reward-config-json")
|
||||||
|
self.assertEqual(json.loads(command[index + 1]), reward_config)
|
||||||
|
|
||||||
def test_requires_local_host_origin_and_bearer_token(self):
|
def test_requires_local_host_origin_and_bearer_token(self):
|
||||||
handler = object.__new__(TrainingRequestHandler)
|
handler = object.__new__(TrainingRequestHandler)
|
||||||
handler.access_token = "secret-token-1234"
|
handler.access_token = "secret-token-1234"
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import math
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from tuning.advisor import AdvisorConfig, DeepSeekAdvisor # noqa: E402
|
||||||
|
from tuning.schema import ( # noqa: E402
|
||||||
|
BASE_REWARD_CONFIGURATION,
|
||||||
|
RewardConfigError,
|
||||||
|
merge_proposal,
|
||||||
|
validate_configuration,
|
||||||
|
validate_constraints,
|
||||||
|
validate_proposal,
|
||||||
|
)
|
||||||
|
from tuning.scoring import ( # noqa: E402
|
||||||
|
DEFAULT_OBJECTIVE_WEIGHTS,
|
||||||
|
EvaluationError,
|
||||||
|
score_evaluation,
|
||||||
|
)
|
||||||
|
from tuning.storage import StorageConflict, TuningStorage # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class RewardSchemaTest(unittest.TestCase):
|
||||||
|
def test_baseline_is_complete_and_energy_is_disabled(self):
|
||||||
|
config = validate_configuration(BASE_REWARD_CONFIGURATION)
|
||||||
|
self.assertEqual(len(config["weights"]), 16)
|
||||||
|
self.assertEqual(config["weights"]["electrical_power"], 0.0)
|
||||||
|
|
||||||
|
def test_sparse_proposal_constraints(self):
|
||||||
|
patch = validate_proposal(
|
||||||
|
{
|
||||||
|
"weights": {"track_linear_velocity": 1.2, "foot_slip": -0.3},
|
||||||
|
"params": {"foot_gait.period": 0.65},
|
||||||
|
},
|
||||||
|
BASE_REWARD_CONFIGURATION,
|
||||||
|
)
|
||||||
|
merged = merge_proposal(BASE_REWARD_CONFIGURATION, patch)
|
||||||
|
self.assertEqual(merged["weights"]["track_linear_velocity"], 1.2)
|
||||||
|
with self.assertRaises(RewardConfigError):
|
||||||
|
validate_proposal({"weights": {"track_linear_velocity": 0}}, BASE_REWARD_CONFIGURATION)
|
||||||
|
with self.assertRaises(RewardConfigError):
|
||||||
|
validate_proposal({"weights": {"foot_slip": 0.2}}, BASE_REWARD_CONFIGURATION)
|
||||||
|
with self.assertRaises(RewardConfigError):
|
||||||
|
validate_proposal(
|
||||||
|
{"weights": {"track_linear_velocity": 3.0}}, BASE_REWARD_CONFIGURATION
|
||||||
|
)
|
||||||
|
with self.assertRaises(RewardConfigError):
|
||||||
|
validate_proposal({"params": {"foot_gait.period": math.nan}}, BASE_REWARD_CONFIGURATION)
|
||||||
|
with self.assertRaises(RewardConfigError):
|
||||||
|
validate_proposal(
|
||||||
|
{
|
||||||
|
"weights": {
|
||||||
|
"pose": 1.1,
|
||||||
|
"foot_gait": 0.6,
|
||||||
|
"foot_slip": -0.3,
|
||||||
|
"soft_landing": -0.002,
|
||||||
|
},
|
||||||
|
"params": {"foot_gait.period": 0.65},
|
||||||
|
},
|
||||||
|
BASE_REWARD_CONFIGURATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cross_parameter_order(self):
|
||||||
|
with self.assertRaises(RewardConfigError):
|
||||||
|
validate_proposal(
|
||||||
|
{"params": {"pose.walking_threshold": 0.5, "pose.running_threshold": 0.4}},
|
||||||
|
BASE_REWARD_CONFIGURATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_session_constraints_reject_unknown_out_of_range_and_fixed_changes(self):
|
||||||
|
constraints = validate_constraints(
|
||||||
|
{
|
||||||
|
"weights.track_linear_velocity": {"kind": "fixed", "value": 1.0},
|
||||||
|
"params.foot_gait.period": {"kind": "range", "min": 0.5, "max": 0.7},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
validate_proposal(
|
||||||
|
{"params": {"foot_gait.period": 0.65}},
|
||||||
|
BASE_REWARD_CONFIGURATION,
|
||||||
|
constraints,
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RewardConfigError, "已固定"):
|
||||||
|
validate_proposal(
|
||||||
|
{"weights": {"track_linear_velocity": 1.1}},
|
||||||
|
BASE_REWARD_CONFIGURATION,
|
||||||
|
constraints,
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RewardConfigError, "工程锁定范围"):
|
||||||
|
validate_proposal(
|
||||||
|
{"params": {"foot_gait.period": 0.75}},
|
||||||
|
BASE_REWARD_CONFIGURATION,
|
||||||
|
constraints,
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RewardConfigError, "未知参数约束"):
|
||||||
|
validate_constraints({"weights.not_allowed": {"kind": "fixed", "value": 1.0}})
|
||||||
|
|
||||||
|
|
||||||
|
class ScoringTest(unittest.TestCase):
|
||||||
|
baseline = {
|
||||||
|
"linear_velocity_rmse": 0.3,
|
||||||
|
"angular_velocity_rmse": 0.2,
|
||||||
|
"mean_action_acc": 0.1,
|
||||||
|
"orientation_error": 0.2,
|
||||||
|
"fall_rate": 0.1,
|
||||||
|
"slip_velocity": 0.2,
|
||||||
|
"mechanical_power": 100.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_improvement_and_safety_gate(self):
|
||||||
|
better = {key: value * 0.8 for key, value in self.baseline.items()}
|
||||||
|
scored = score_evaluation(self.baseline, better, DEFAULT_OBJECTIVE_WEIGHTS)
|
||||||
|
self.assertTrue(scored["eligible"])
|
||||||
|
self.assertGreater(scored["score"], 0)
|
||||||
|
unsafe = dict(better, fall_rate=0.2)
|
||||||
|
scored = score_evaluation(self.baseline, unsafe)
|
||||||
|
self.assertFalse(scored["eligible"])
|
||||||
|
self.assertEqual(scored["score"], -1.0)
|
||||||
|
|
||||||
|
def test_rejects_missing_and_nonfinite_metrics(self):
|
||||||
|
with self.assertRaises(EvaluationError):
|
||||||
|
score_evaluation(self.baseline, {"fall_rate": 0.1})
|
||||||
|
bad = dict(self.baseline, mechanical_power=math.inf)
|
||||||
|
with self.assertRaises(EvaluationError):
|
||||||
|
score_evaluation(self.baseline, bad)
|
||||||
|
|
||||||
|
|
||||||
|
class AdvisorTest(unittest.TestCase):
|
||||||
|
class Output:
|
||||||
|
weights = {"pose": 1.1}
|
||||||
|
params = {}
|
||||||
|
rationale = "improve posture"
|
||||||
|
expected_impact = {"posture": "better"}
|
||||||
|
confidence = 0.7
|
||||||
|
|
||||||
|
class Result:
|
||||||
|
output = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def usage():
|
||||||
|
return type("Usage", (), {"requests": 1, "input_tokens": 10, "output_tokens": 5})()
|
||||||
|
|
||||||
|
class Agent:
|
||||||
|
def __init__(self, output):
|
||||||
|
self.output = output
|
||||||
|
|
||||||
|
def run_sync(self, _prompt):
|
||||||
|
result = AdvisorTest.Result()
|
||||||
|
result.output = self.output
|
||||||
|
return result
|
||||||
|
|
||||||
|
def test_structured_result_is_revalidated_locally(self):
|
||||||
|
advisor = DeepSeekAdvisor(AdvisorConfig("fake"))
|
||||||
|
advisor._cached_agent = self.Agent(self.Output())
|
||||||
|
proposal = advisor.propose({"trials": []}, BASE_REWARD_CONFIGURATION)
|
||||||
|
self.assertEqual(proposal["patch"]["weights"]["pose"], 1.1)
|
||||||
|
self.assertEqual(proposal["usage"]["input_tokens"], 10)
|
||||||
|
|
||||||
|
def test_invalid_model_patch_is_rejected(self):
|
||||||
|
output = self.Output()
|
||||||
|
output.weights = {"track_linear_velocity": -1.0}
|
||||||
|
advisor = DeepSeekAdvisor(AdvisorConfig("fake"))
|
||||||
|
advisor._cached_agent = self.Agent(output)
|
||||||
|
with self.assertRaises(RewardConfigError):
|
||||||
|
advisor.propose({"trials": []}, BASE_REWARD_CONFIGURATION)
|
||||||
|
|
||||||
|
|
||||||
|
class StorageTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.storage = TuningStorage(Path(self.temporary.name) / "state.sqlite3")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temporary.cleanup()
|
||||||
|
|
||||||
|
def test_persists_session_trial_proposal_and_metrics(self):
|
||||||
|
session = self.storage.create_session(
|
||||||
|
"approval", {"taskId": "Unitree-Go2-Flat"}, DEFAULT_OBJECTIVE_WEIGHTS, False
|
||||||
|
)
|
||||||
|
trial = self.storage.create_trial(
|
||||||
|
session["id"], 0, 0, 10, BASE_REWARD_CONFIGURATION, None, "trial-000-rung-0"
|
||||||
|
)
|
||||||
|
proposal = self.storage.create_proposal(
|
||||||
|
session["id"],
|
||||||
|
trial["id"],
|
||||||
|
{"weights": {"pose": 1.1}, "params": {}},
|
||||||
|
"test",
|
||||||
|
{},
|
||||||
|
0.8,
|
||||||
|
)
|
||||||
|
self.assertTrue(self.storage.decide_proposal(proposal["id"], "approved", None))
|
||||||
|
self.assertFalse(self.storage.decide_proposal(proposal["id"], "approved", None))
|
||||||
|
points = [
|
||||||
|
("Train/reward", step, float(step), 50.0 if step == 50 else float(step % 7))
|
||||||
|
for step in range(100)
|
||||||
|
]
|
||||||
|
self.storage.insert_metrics(trial["id"], points)
|
||||||
|
sampled = self.storage.metrics(trial["id"], max_points=10)[0]["points"]
|
||||||
|
self.assertEqual(len(sampled), 10)
|
||||||
|
self.assertEqual(sampled[0]["step"], 0)
|
||||||
|
self.assertEqual(sampled[-1]["step"], 99)
|
||||||
|
self.assertIn(50.0, [point["value"] for point in sampled])
|
||||||
|
control = self.storage.replace_constraints(
|
||||||
|
session["id"],
|
||||||
|
0,
|
||||||
|
{"weights.pose": {"kind": "range", "min": 0.5, "max": 1.5}},
|
||||||
|
)
|
||||||
|
self.assertEqual(control["constraintsRevision"], 1)
|
||||||
|
with self.assertRaises(StorageConflict):
|
||||||
|
self.storage.replace_constraints(session["id"], 0, {})
|
||||||
|
self.storage.grant_dispatch_token(session["id"])
|
||||||
|
with self.assertRaises(StorageConflict):
|
||||||
|
self.storage.grant_dispatch_token(session["id"])
|
||||||
|
self.assertTrue(self.storage.use_dispatch_token(session["id"]))
|
||||||
|
self.assertFalse(self.storage.use_dispatch_token(session["id"]))
|
||||||
|
incremental = self.storage.metrics(trial["id"], max_points=100, after_step=90)[0]
|
||||||
|
self.assertEqual(incremental["points"][0]["step"], 91)
|
||||||
|
reopened = TuningStorage(self.storage.path)
|
||||||
|
self.assertEqual(reopened.get_session(session["id"])["mode"], "approval")
|
||||||
|
self.assertEqual(reopened.get_control(session["id"])["constraintsRevision"], 1)
|
||||||
|
|
||||||
|
def test_recovery_marks_inflight_records(self):
|
||||||
|
session = self.storage.create_session(
|
||||||
|
"automatic", {"taskId": "Unitree-Go2-Flat"}, DEFAULT_OBJECTIVE_WEIGHTS, True
|
||||||
|
)
|
||||||
|
trial = self.storage.create_trial(
|
||||||
|
session["id"], 0, 0, 10, BASE_REWARD_CONFIGURATION, None, "trial-000-rung-0"
|
||||||
|
)
|
||||||
|
self.storage.update_session(session["id"], state="running")
|
||||||
|
self.storage.update_trial(trial["id"], state="training")
|
||||||
|
self.storage.recover_interrupted()
|
||||||
|
self.assertEqual(self.storage.get_session(session["id"])["state"], "interrupted")
|
||||||
|
self.assertEqual(self.storage.get_trial(trial["id"])["state"], "interrupted")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from tuning.manager import TuningManager # noqa: E402
|
||||||
|
from tuning.process import GpuLease # noqa: E402
|
||||||
|
from tuning.schema import BASE_REWARD_CONFIGURATION # noqa: E402
|
||||||
|
from tuning.scoring import score_evaluation # noqa: E402
|
||||||
|
from tuning.storage import now_iso # noqa: E402
|
||||||
|
|
||||||
|
BASE_METRICS = {
|
||||||
|
"linear_velocity_rmse": 0.3,
|
||||||
|
"angular_velocity_rmse": 0.2,
|
||||||
|
"mean_action_acc": 0.1,
|
||||||
|
"orientation_error": 0.2,
|
||||||
|
"fall_rate": 0.1,
|
||||||
|
"slip_velocity": 0.2,
|
||||||
|
"mechanical_power": 100.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAdvisor:
|
||||||
|
def capability(self):
|
||||||
|
return {
|
||||||
|
"configured": True,
|
||||||
|
"apiKeyConfigured": True,
|
||||||
|
"frameworkInstalled": True,
|
||||||
|
"model": "fake",
|
||||||
|
"baseUrl": "https://example.invalid",
|
||||||
|
}
|
||||||
|
|
||||||
|
def propose(self, _context, previous):
|
||||||
|
value = min(2.4, previous["weights"]["pose"] * 1.05)
|
||||||
|
return {
|
||||||
|
"patch": {"weights": {"pose": value}, "params": {}},
|
||||||
|
"rationale": "fake",
|
||||||
|
"expectedImpact": {},
|
||||||
|
"confidence": 0.8,
|
||||||
|
"promptHash": "abc",
|
||||||
|
"usage": {},
|
||||||
|
"model": "fake",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_connection(self):
|
||||||
|
return {"ok": True, "model": "fake", "outputType": "fake"}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTuningManager(TuningManager):
|
||||||
|
def _execute_trial(self, session, trial, resume_checkpoint=None):
|
||||||
|
del resume_checkpoint
|
||||||
|
factor = max(0.5, 1.0 - 0.03 * trial["number"] - 0.01 * trial["rung"])
|
||||||
|
metrics = {key: value * factor for key, value in BASE_METRICS.items()}
|
||||||
|
trials = self.storage.list_trials(session["id"])
|
||||||
|
baseline = next(
|
||||||
|
(item for item in trials if item["number"] == 0 and item["rung"] == 0), None
|
||||||
|
)
|
||||||
|
if baseline and baseline["evaluation"]:
|
||||||
|
scored = score_evaluation(
|
||||||
|
baseline["evaluation"]["metrics"], metrics, session["objectiveWeights"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
scored = {"score": 0.0, "eligible": True, "components": {}}
|
||||||
|
root = self._session_root(session["id"])
|
||||||
|
run = root / trial["runDir"]
|
||||||
|
run.mkdir(parents=True, exist_ok=True)
|
||||||
|
(run / "model_1.pt").write_bytes(b"checkpoint")
|
||||||
|
(run / "policy.onnx").write_bytes(b"onnx")
|
||||||
|
evaluation = {"metrics": metrics, "score": scored}
|
||||||
|
self.storage.update_trial(
|
||||||
|
trial["id"],
|
||||||
|
state="completed",
|
||||||
|
started_at=now_iso(),
|
||||||
|
ended_at=now_iso(),
|
||||||
|
message="fake complete",
|
||||||
|
checkpoint_path=str((run / "model_1.pt").relative_to(root)),
|
||||||
|
policy_path=str((run / "policy.onnx").relative_to(root)),
|
||||||
|
evaluation=evaluation,
|
||||||
|
score=scored["score"],
|
||||||
|
eligible=scored["eligible"],
|
||||||
|
)
|
||||||
|
return self.storage.get_trial(trial["id"])
|
||||||
|
|
||||||
|
|
||||||
|
class TuningManagerTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temporary.name)
|
||||||
|
(self.root / "trainer" / "scripts").mkdir(parents=True)
|
||||||
|
(self.root / "trainer" / "scripts" / "evaluate.py").write_text("", encoding="utf-8")
|
||||||
|
self.manager = FakeTuningManager(
|
||||||
|
self.root / "trainer",
|
||||||
|
sys.executable,
|
||||||
|
self.root / "data",
|
||||||
|
GpuLease(),
|
||||||
|
advisor=FakeAdvisor(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.manager.shutdown()
|
||||||
|
self.temporary.cleanup()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def payload(mode="automatic"):
|
||||||
|
return {
|
||||||
|
"taskId": "Unitree-Go2-Flat",
|
||||||
|
"mode": mode,
|
||||||
|
"runName": "test",
|
||||||
|
"numEnvs": 16,
|
||||||
|
"gpuIds": [0],
|
||||||
|
"trialCount": 4,
|
||||||
|
"initialIterations": 1,
|
||||||
|
"middleIterations": 2,
|
||||||
|
"finalIterations": 3,
|
||||||
|
"evalNumEnvs": 8,
|
||||||
|
"evalSteps": 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
def wait_terminal(self, session_id, timeout=5):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
session = self.manager.detail(session_id)
|
||||||
|
if session["state"] in {"succeeded", "failed", "cancelled"}:
|
||||||
|
return session
|
||||||
|
time.sleep(0.01)
|
||||||
|
self.fail("session did not finish")
|
||||||
|
|
||||||
|
def test_automatic_session_runs_rungs_and_persists_best_artifact(self):
|
||||||
|
session = self.manager.create(self.payload())
|
||||||
|
completed = self.wait_terminal(session["id"])
|
||||||
|
self.assertEqual(completed["state"], "succeeded", completed["message"])
|
||||||
|
self.assertGreaterEqual(len(completed["trials"]), 7)
|
||||||
|
self.assertTrue(self.manager.best_artifact(session["id"]).is_file())
|
||||||
|
self.assertEqual(len(self.manager.storage.list_presets()), 1)
|
||||||
|
|
||||||
|
def test_approval_session_waits_and_accepts_modified_patch(self):
|
||||||
|
session = self.manager.create(self.payload("approval"))
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
detail = self.manager.detail(session["id"])
|
||||||
|
if detail["state"] == "awaiting_approval":
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
else:
|
||||||
|
self.fail("session did not wait for approval")
|
||||||
|
proposal = detail["proposals"][-1]
|
||||||
|
patch = {"weights": {"pose": 1.1}, "params": {}}
|
||||||
|
with self.assertRaisesRegex(Exception, "feedback"):
|
||||||
|
self.manager.approve(session["id"], proposal["id"], {"feedback": {}})
|
||||||
|
approved = self.manager.approve(
|
||||||
|
session["id"], proposal["id"], {"feedback": "ok", "patch": patch}
|
||||||
|
)
|
||||||
|
self.assertEqual(approved["proposals"][-1]["state"], "approved")
|
||||||
|
self.manager.cancel(session["id"])
|
||||||
|
self.assertEqual(self.wait_terminal(session["id"])["state"], "cancelled")
|
||||||
|
|
||||||
|
def test_runtime_mode_switch_auto_approves_pending_proposal(self):
|
||||||
|
session = self.manager.create(self.payload("approval"))
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
detail = self.manager.detail(session["id"])
|
||||||
|
if detail["state"] == "awaiting_approval":
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
else:
|
||||||
|
self.fail("session did not wait for approval")
|
||||||
|
changed = self.manager.set_mode(session["id"], {"mode": "automatic"})
|
||||||
|
self.assertEqual(changed["mode"], "automatic")
|
||||||
|
self.assertEqual(changed["proposals"][-1]["state"], "approved")
|
||||||
|
completed = self.wait_terminal(session["id"])
|
||||||
|
self.assertEqual(completed["state"], "succeeded", completed["message"])
|
||||||
|
|
||||||
|
def test_trial_count_is_user_configurable(self):
|
||||||
|
payload = self.payload()
|
||||||
|
payload["trialCount"] = 1
|
||||||
|
_, config, _, _ = self.manager.parse_create(payload)
|
||||||
|
self.assertEqual(config["trialCount"], 1)
|
||||||
|
payload["trialCount"] = 100
|
||||||
|
_, config, _, _ = self.manager.parse_create(payload)
|
||||||
|
self.assertEqual(config["trialCount"], 100)
|
||||||
|
payload["trialCount"] = 101
|
||||||
|
with self.assertRaisesRegex(Exception, "trialCount"):
|
||||||
|
self.manager.parse_create(payload)
|
||||||
|
|
||||||
|
def test_resume_discards_only_interrupted_trial_and_continues(self):
|
||||||
|
mode, config, objective, fallback = self.manager.parse_create(self.payload())
|
||||||
|
session = self.manager.storage.create_session(mode, config, objective, fallback)
|
||||||
|
baseline = self.manager.storage.create_trial(
|
||||||
|
session["id"],
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
BASE_REWARD_CONFIGURATION,
|
||||||
|
None,
|
||||||
|
"trial-000-rung-0",
|
||||||
|
)
|
||||||
|
self.manager._execute_trial(self.manager.storage.get_session(session["id"]), baseline)
|
||||||
|
interrupted = self.manager.storage.create_trial(
|
||||||
|
session["id"],
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
baseline["rewardConfig"],
|
||||||
|
None,
|
||||||
|
"trial-001-rung-0",
|
||||||
|
)
|
||||||
|
self.manager.storage.update_trial(interrupted["id"], state="interrupted")
|
||||||
|
self.manager.storage.reset_step_gate(session["id"])
|
||||||
|
self.manager.storage.update_session(session["id"], state="interrupted")
|
||||||
|
self.manager.resume(session["id"])
|
||||||
|
self.assertEqual(self.manager.storage.get_control(session["id"])["runPolicy"], "continuous")
|
||||||
|
completed = self.wait_terminal(session["id"])
|
||||||
|
self.assertEqual(completed["state"], "succeeded", completed["message"])
|
||||||
|
self.assertNotIn(interrupted["id"], [trial["id"] for trial in completed["trials"]])
|
||||||
|
|
||||||
|
def test_create_validation_and_agent_capability(self):
|
||||||
|
self.assertTrue(self.manager.capability()["configured"])
|
||||||
|
with self.assertRaisesRegex(Exception, "只支持"):
|
||||||
|
self.manager.parse_create({"taskId": "Other"})
|
||||||
|
self.assertEqual(self.manager.test_agent()["model"], "fake")
|
||||||
|
|
||||||
|
def test_pause_closes_persistent_dispatch_gate_at_trial_boundary(self):
|
||||||
|
mode, config, objective, fallback = self.manager.parse_create(self.payload())
|
||||||
|
session = self.manager.storage.create_session(mode, config, objective, fallback)
|
||||||
|
self.manager.storage.update_session(session["id"], state="running")
|
||||||
|
self.manager.storage.grant_dispatch_token(session["id"])
|
||||||
|
|
||||||
|
paused = self.manager.pause(session["id"])
|
||||||
|
|
||||||
|
self.assertEqual(paused["state"], "paused")
|
||||||
|
self.assertEqual(paused["control"]["runPolicy"], "step")
|
||||||
|
self.assertEqual(paused["control"]["dispatchTokens"], 0)
|
||||||
|
|
||||||
|
def test_step_token_executes_exactly_one_trial_then_pauses(self):
|
||||||
|
session = self.manager.create(self.payload("approval"))
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
detail = self.manager.detail(session["id"])
|
||||||
|
if detail["state"] == "awaiting_approval":
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
else:
|
||||||
|
self.fail("session did not wait for approval")
|
||||||
|
baseline_count = len([trial for trial in detail["trials"] if trial["state"] == "completed"])
|
||||||
|
stepped = self.manager.step(session["id"], {"count": 1})
|
||||||
|
self.assertEqual(stepped["control"]["runPolicy"], "step")
|
||||||
|
self.assertEqual(stepped["control"]["dispatchTokens"], 1)
|
||||||
|
proposal = stepped["proposals"][-1]
|
||||||
|
self.manager.approve(session["id"], proposal["id"], {})
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
detail = self.manager.detail(session["id"])
|
||||||
|
completed_count = len(
|
||||||
|
[trial for trial in detail["trials"] if trial["state"] == "completed"]
|
||||||
|
)
|
||||||
|
if detail["state"] == "paused" and completed_count == baseline_count + 1:
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
else:
|
||||||
|
self.fail("single-step trial did not pause at the next boundary")
|
||||||
|
time.sleep(0.05)
|
||||||
|
self.assertEqual(
|
||||||
|
len(
|
||||||
|
[
|
||||||
|
trial
|
||||||
|
for trial in self.manager.detail(session["id"])["trials"]
|
||||||
|
if trial["state"] == "completed"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
baseline_count + 1,
|
||||||
|
)
|
||||||
|
self.manager.cancel(session["id"])
|
||||||
|
self.assertEqual(self.wait_terminal(session["id"])["state"], "cancelled")
|
||||||
|
|
||||||
|
def test_constraints_are_revisioned_and_enforced_during_approval(self):
|
||||||
|
session = self.manager.create(self.payload("approval"))
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
detail = self.manager.detail(session["id"])
|
||||||
|
if detail["state"] == "awaiting_approval":
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
else:
|
||||||
|
self.fail("session did not wait for approval")
|
||||||
|
proposal = detail["proposals"][-1]
|
||||||
|
constrained = self.manager.set_constraints(
|
||||||
|
session["id"],
|
||||||
|
{
|
||||||
|
"revision": 0,
|
||||||
|
"constraints": {"weights.track_linear_velocity": {"kind": "fixed", "value": 1.0}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(constrained["control"]["constraintsRevision"], 1)
|
||||||
|
with self.assertRaisesRegex(Exception, "已固定"):
|
||||||
|
self.manager.approve(
|
||||||
|
session["id"],
|
||||||
|
proposal["id"],
|
||||||
|
{"patch": {"weights": {"track_linear_velocity": 1.1}, "params": {}}},
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(Exception, "revision"):
|
||||||
|
self.manager.set_constraints(session["id"], {"revision": 0, "constraints": {}})
|
||||||
|
self.manager.approve(session["id"], proposal["id"], {})
|
||||||
|
self.manager.cancel(session["id"])
|
||||||
|
self.assertEqual(self.wait_terminal(session["id"])["state"], "cancelled")
|
||||||
|
|
||||||
|
def test_rollback_uses_safe_completed_trial_as_next_proposal_base(self):
|
||||||
|
session = self.manager.create(self.payload("approval"))
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
detail = self.manager.detail(session["id"])
|
||||||
|
if detail["state"] == "awaiting_approval":
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
else:
|
||||||
|
self.fail("session did not wait for approval")
|
||||||
|
baseline = detail["trials"][0]
|
||||||
|
old_proposal = detail["proposals"][-1]
|
||||||
|
rolled_back = self.manager.rollback(
|
||||||
|
session["id"], {"trialId": baseline["id"], "checkpoint": True}
|
||||||
|
)
|
||||||
|
self.assertEqual(rolled_back["state"], "paused")
|
||||||
|
self.assertEqual(rolled_back["control"]["activeBaseTrialId"], baseline["id"])
|
||||||
|
self.assertEqual(
|
||||||
|
next(item for item in rolled_back["proposals"] if item["id"] == old_proposal["id"])[
|
||||||
|
"state"
|
||||||
|
],
|
||||||
|
"rejected",
|
||||||
|
)
|
||||||
|
self.manager.step(session["id"], {"count": 1})
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
detail = self.manager.detail(session["id"])
|
||||||
|
if (
|
||||||
|
detail["state"] == "awaiting_approval"
|
||||||
|
and detail["proposals"][-1]["id"] != old_proposal["id"]
|
||||||
|
):
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
else:
|
||||||
|
self.fail("rollback base did not produce a replacement proposal")
|
||||||
|
self.assertEqual(detail["proposals"][-1]["baseTrialId"], baseline["id"])
|
||||||
|
self.manager.cancel(session["id"])
|
||||||
|
self.assertEqual(self.wait_terminal(session["id"])["state"], "cancelled")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Reward auto-tuning support for the local training service."""
|
||||||
|
|
||||||
|
from .schema import (
|
||||||
|
BASE_REWARD_CONFIGURATION,
|
||||||
|
PARAMETER_SPECS,
|
||||||
|
WEIGHT_SPECS,
|
||||||
|
RewardConfigError,
|
||||||
|
apply_reward_configuration,
|
||||||
|
merge_proposal,
|
||||||
|
validate_configuration,
|
||||||
|
validate_proposal,
|
||||||
|
)
|
||||||
|
from .scoring import DEFAULT_OBJECTIVE_WEIGHTS, score_evaluation
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BASE_REWARD_CONFIGURATION",
|
||||||
|
"DEFAULT_OBJECTIVE_WEIGHTS",
|
||||||
|
"PARAMETER_SPECS",
|
||||||
|
"WEIGHT_SPECS",
|
||||||
|
"RewardConfigError",
|
||||||
|
"apply_reward_configuration",
|
||||||
|
"merge_proposal",
|
||||||
|
"score_evaluation",
|
||||||
|
"validate_configuration",
|
||||||
|
"validate_proposal",
|
||||||
|
]
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""PydanticAI adapter for the DeepSeek reward-tuning advisor."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .schema import validate_proposal
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """你是 Unitree Go2 强化学习奖励调参专家。
|
||||||
|
只根据提供的数值配置、训练曲线摘要和固定评估结果提出下一轮稀疏修改。
|
||||||
|
必须优先保持速度跟踪与跌倒安全门槛;每轮最多修改四个白名单标量,不得改变符号、函数、传感器或结构。
|
||||||
|
不要建议 Python 代码、命令、文件路径或白名单外参数。输出必须符合 RewardProposal schema。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class AdvisorUnavailable(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AdvisorConfig:
|
||||||
|
api_key: str | None
|
||||||
|
base_url: str = "https://api.deepseek.com"
|
||||||
|
model: str = "deepseek-v4-flash"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_environment(cls) -> AdvisorConfig:
|
||||||
|
return cls(
|
||||||
|
api_key=os.environ.get("DEEPSEEK_API_KEY"),
|
||||||
|
base_url=os.environ.get("MUJOCO_TUNING_AGENT_BASE_URL", "https://api.deepseek.com"),
|
||||||
|
model=os.environ.get("MUJOCO_TUNING_AGENT_MODEL", "deepseek-v4-flash"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeepSeekAdvisor:
|
||||||
|
def __init__(self, config: AdvisorConfig | None = None):
|
||||||
|
self.config = config or AdvisorConfig.from_environment()
|
||||||
|
self._cached_agent = None
|
||||||
|
|
||||||
|
def capability(self) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
import pydantic_ai # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
installed = False
|
||||||
|
else:
|
||||||
|
installed = True
|
||||||
|
return {
|
||||||
|
"configured": bool(self.config.api_key) and installed,
|
||||||
|
"apiKeyConfigured": bool(self.config.api_key),
|
||||||
|
"frameworkInstalled": installed,
|
||||||
|
"model": self.config.model,
|
||||||
|
"baseUrl": self.config.base_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _agent(self):
|
||||||
|
if self._cached_agent is not None:
|
||||||
|
return self._cached_agent
|
||||||
|
if not self.config.api_key:
|
||||||
|
raise AdvisorUnavailable("未配置 DEEPSEEK_API_KEY")
|
||||||
|
try:
|
||||||
|
import httpx2
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from pydantic_ai import Agent, PromptedOutput
|
||||||
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
|
from pydantic_ai.providers.openai import OpenAIProvider
|
||||||
|
except ImportError as error:
|
||||||
|
raise AdvisorUnavailable(
|
||||||
|
"缺少 PydanticAI,请安装 training_server/requirements.txt"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
class RewardProposalOutput(BaseModel):
|
||||||
|
weights: dict[str, float] = Field(default_factory=dict)
|
||||||
|
params: dict[str, float] = Field(default_factory=dict)
|
||||||
|
rationale: str = Field(min_length=1, max_length=2000)
|
||||||
|
expected_impact: dict[str, str] = Field(default_factory=dict)
|
||||||
|
confidence: float = Field(ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("ALL_PROXY")
|
||||||
|
if proxy and proxy.startswith("socks://"):
|
||||||
|
proxy = "socks5://" + proxy.removeprefix("socks://")
|
||||||
|
http_client = httpx2.AsyncClient(proxy=proxy, trust_env=False, timeout=60.0)
|
||||||
|
provider = OpenAIProvider(
|
||||||
|
base_url=self.config.base_url, api_key=self.config.api_key, http_client=http_client
|
||||||
|
)
|
||||||
|
model = OpenAIChatModel(self.config.model, provider=provider) # type: ignore[arg-type]
|
||||||
|
self._cached_agent = Agent(
|
||||||
|
model,
|
||||||
|
output_type=PromptedOutput(RewardProposalOutput),
|
||||||
|
system_prompt=SYSTEM_PROMPT,
|
||||||
|
retries=2,
|
||||||
|
model_settings={"temperature": 0.2},
|
||||||
|
)
|
||||||
|
return self._cached_agent
|
||||||
|
|
||||||
|
def propose(self, context: dict[str, Any], previous: dict) -> dict[str, Any]:
|
||||||
|
prompt = json.dumps(context, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
||||||
|
result = self._agent().run_sync(prompt)
|
||||||
|
output = result.output
|
||||||
|
patch = validate_proposal(
|
||||||
|
{"weights": dict(output.weights), "params": dict(output.params)}, previous
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
usage = result.usage()
|
||||||
|
usage_value = {
|
||||||
|
key: getattr(usage, key)
|
||||||
|
for key in ("requests", "input_tokens", "output_tokens", "total_tokens")
|
||||||
|
if getattr(usage, key, None) is not None
|
||||||
|
}
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
usage_value = {}
|
||||||
|
return {
|
||||||
|
"patch": patch,
|
||||||
|
"rationale": output.rationale,
|
||||||
|
"expectedImpact": dict(output.expected_impact),
|
||||||
|
"confidence": float(output.confidence),
|
||||||
|
"promptHash": hashlib.sha256(prompt.encode()).hexdigest(),
|
||||||
|
"usage": usage_value,
|
||||||
|
"model": self.config.model,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_connection(self) -> dict[str, Any]:
|
||||||
|
base = {
|
||||||
|
"weights": {"track_linear_velocity": 1.0},
|
||||||
|
"params": {},
|
||||||
|
"instruction": "仅返回一个合法示例:把 track_linear_velocity 改为 1.1。",
|
||||||
|
}
|
||||||
|
# A minimal full previous config is supplied by callers for actual proposals;
|
||||||
|
# connectivity probing only verifies the provider and structured response path.
|
||||||
|
result = self._agent().run_sync(json.dumps(base, ensure_ascii=False))
|
||||||
|
return {"ok": True, "model": self.config.model, "outputType": type(result.output).__name__}
|
||||||
@@ -0,0 +1,999 @@
|
|||||||
|
"""Persistent reward tuning session orchestrator."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from contextlib import suppress
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .advisor import AdvisorUnavailable, DeepSeekAdvisor
|
||||||
|
from .process import GpuLease, ResourceBusyError, terminate_process
|
||||||
|
from .schema import (
|
||||||
|
BASE_REWARD_CONFIGURATION,
|
||||||
|
merge_proposal,
|
||||||
|
validate_configuration_constraints,
|
||||||
|
validate_constraints,
|
||||||
|
validate_proposal,
|
||||||
|
)
|
||||||
|
from .scoring import DEFAULT_OBJECTIVE_WEIGHTS, score_evaluation, validate_objective_weights
|
||||||
|
from .storage import StorageConflict, TuningStorage, now_iso
|
||||||
|
from .study import OptunaStudies
|
||||||
|
from .tensorboard import ingest_scalars
|
||||||
|
|
||||||
|
ACTIVE_SESSION_STATES = {
|
||||||
|
"queued",
|
||||||
|
"running",
|
||||||
|
"evaluating",
|
||||||
|
"awaiting_approval",
|
||||||
|
"paused",
|
||||||
|
"interrupted",
|
||||||
|
}
|
||||||
|
RUNNING_STATES = {"queued", "running", "evaluating"}
|
||||||
|
RUN_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
||||||
|
|
||||||
|
|
||||||
|
class TuningError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TuningManager:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
trainer_root: Path,
|
||||||
|
python: str,
|
||||||
|
data_root: Path,
|
||||||
|
lease: GpuLease,
|
||||||
|
advisor: Any | None = None,
|
||||||
|
):
|
||||||
|
self.trainer_root = trainer_root.expanduser().resolve()
|
||||||
|
self.python = python
|
||||||
|
self.data_root = data_root.expanduser().resolve()
|
||||||
|
self.data_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.storage = TuningStorage(self.data_root / "tuning.sqlite3")
|
||||||
|
self.storage.recover_interrupted()
|
||||||
|
self.studies = OptunaStudies(self.data_root)
|
||||||
|
self.lease = lease
|
||||||
|
self.advisor = advisor or DeepSeekAdvisor()
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.condition = threading.Condition(self.lock)
|
||||||
|
self.workers: dict[str, threading.Thread] = {}
|
||||||
|
self.processes: dict[str, subprocess.Popen[str]] = {}
|
||||||
|
self.cancel_events: dict[str, threading.Event] = {}
|
||||||
|
|
||||||
|
def capability(self) -> dict[str, Any]:
|
||||||
|
capability = self.advisor.capability()
|
||||||
|
capability.update({"ready": (self.trainer_root / "scripts" / "evaluate.py").is_file()})
|
||||||
|
return capability
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _integer(payload: dict, name: str, default: int, minimum: int, maximum: int) -> int:
|
||||||
|
value = payload.get(name, default)
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
|
||||||
|
raise TuningError(f"{name} 必须在 {minimum}–{maximum} 之间")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def parse_create(self, payload: Any) -> tuple[str, dict, dict, bool]:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise TuningError("请求体必须是 JSON 对象")
|
||||||
|
mode = payload.get("mode", "automatic")
|
||||||
|
if mode not in ("automatic", "approval"):
|
||||||
|
raise TuningError("mode 必须是 automatic 或 approval")
|
||||||
|
if payload.get("taskId", "Unitree-Go2-Flat") != "Unitree-Go2-Flat":
|
||||||
|
raise TuningError("第一版只支持 Unitree-Go2-Flat")
|
||||||
|
run_name = payload.get("runName", "auto-tune")
|
||||||
|
if not isinstance(run_name, str) or not RUN_NAME.fullmatch(run_name):
|
||||||
|
raise TuningError("runName 格式无效")
|
||||||
|
gpu_ids = payload.get("gpuIds", [0])
|
||||||
|
if (
|
||||||
|
not isinstance(gpu_ids, list)
|
||||||
|
or not gpu_ids
|
||||||
|
or any(
|
||||||
|
isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 255
|
||||||
|
for value in gpu_ids
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise TuningError("gpuIds 必须是非空非负整数数组")
|
||||||
|
trial_count = self._integer(payload, "trialCount", 12, 1, 100)
|
||||||
|
rung0 = self._integer(payload, "initialIterations", 300, 1, 1000000)
|
||||||
|
rung1 = self._integer(payload, "middleIterations", 900, rung0, 1000000)
|
||||||
|
rung2 = self._integer(payload, "finalIterations", 2000, rung1, 1000000)
|
||||||
|
config = {
|
||||||
|
"taskId": "Unitree-Go2-Flat",
|
||||||
|
"numEnvs": self._integer(payload, "numEnvs", 4096, 1, 16384),
|
||||||
|
"seed": self._integer(payload, "seed", 42, 0, 2147483647),
|
||||||
|
"runName": run_name,
|
||||||
|
"gpuIds": gpu_ids,
|
||||||
|
"trialCount": trial_count,
|
||||||
|
"rungs": [rung0, rung1, rung2],
|
||||||
|
"promote": [trial_count, min(4, trial_count), min(2, trial_count)],
|
||||||
|
"evalNumEnvs": self._integer(payload, "evalNumEnvs", 256, 1, 4096),
|
||||||
|
"evalSteps": self._integer(payload, "evalSteps", 1000, 10, 100000),
|
||||||
|
"earlyStopPatience": self._integer(payload, "earlyStopPatience", 4, 1, 20),
|
||||||
|
}
|
||||||
|
objective = validate_objective_weights(
|
||||||
|
payload.get("objectiveWeights", DEFAULT_OBJECTIVE_WEIGHTS)
|
||||||
|
)
|
||||||
|
fallback = payload.get("fallbackEnabled", False)
|
||||||
|
if not isinstance(fallback, bool):
|
||||||
|
raise TuningError("fallbackEnabled 必须是布尔值")
|
||||||
|
return mode, config, objective, fallback
|
||||||
|
|
||||||
|
def create(self, payload: Any) -> dict:
|
||||||
|
mode, config, objective, fallback = self.parse_create(payload)
|
||||||
|
capability = self.capability()
|
||||||
|
if not capability["ready"]:
|
||||||
|
raise TuningError("评估入口未就绪")
|
||||||
|
if not capability["configured"] and not fallback:
|
||||||
|
raise TuningError("DeepSeek Agent 未配置;设置 DEEPSEEK_API_KEY 或显式启用 fallback")
|
||||||
|
with self.lock:
|
||||||
|
active = [
|
||||||
|
session
|
||||||
|
for session in self.storage.list_sessions()
|
||||||
|
if session["state"] in ACTIVE_SESSION_STATES
|
||||||
|
]
|
||||||
|
if active:
|
||||||
|
raise ResourceBusyError("已有调参 session 未结束")
|
||||||
|
session = self.storage.create_session(mode, config, objective, fallback)
|
||||||
|
self._start_worker(session["id"], resume=False)
|
||||||
|
return self.detail(session["id"])
|
||||||
|
|
||||||
|
def _start_worker(self, session_id: str, resume: bool) -> None:
|
||||||
|
cancel = threading.Event()
|
||||||
|
self.cancel_events[session_id] = cancel
|
||||||
|
worker = threading.Thread(
|
||||||
|
target=self._run_session,
|
||||||
|
args=(session_id, resume, cancel),
|
||||||
|
name=f"tuning-{session_id[:8]}",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self.workers[session_id] = worker
|
||||||
|
worker.start()
|
||||||
|
|
||||||
|
def detail(self, session_id: str) -> dict:
|
||||||
|
session = self.storage.get_session(session_id)
|
||||||
|
session["trials"] = self.storage.list_trials(session_id)
|
||||||
|
session["proposals"] = self.storage.list_proposals(session_id)
|
||||||
|
session["audit"] = self.storage.audit_events(session_id)
|
||||||
|
control = self.storage.get_control(session_id)
|
||||||
|
current = next(
|
||||||
|
(trial for trial in session["trials"] if trial["id"] == session["currentTrialId"]),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
control["effectiveAfterCurrent"] = bool(
|
||||||
|
current and current["state"] in {"training", "evaluating"}
|
||||||
|
)
|
||||||
|
session["control"] = control
|
||||||
|
return session
|
||||||
|
|
||||||
|
def list(self) -> list[dict]:
|
||||||
|
return self.storage.list_sessions()
|
||||||
|
|
||||||
|
def _session_root(self, session_id: str) -> Path:
|
||||||
|
root = (self.data_root / "sessions" / session_id).resolve()
|
||||||
|
if not root.is_relative_to(self.data_root):
|
||||||
|
raise TuningError("非法 session 路径")
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
return root
|
||||||
|
|
||||||
|
def _run_command(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
command: list[str],
|
||||||
|
cwd: Path,
|
||||||
|
environment: dict[str, str],
|
||||||
|
log_path: Path,
|
||||||
|
) -> int:
|
||||||
|
owner = f"tuning:{session_id}"
|
||||||
|
self.lease.acquire(owner)
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
command,
|
||||||
|
cwd=cwd,
|
||||||
|
env=environment,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
bufsize=1,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
with self.lock:
|
||||||
|
self.processes[session_id] = process
|
||||||
|
assert process.stdout is not None
|
||||||
|
with log_path.open("a", encoding="utf-8") as log:
|
||||||
|
for line in process.stdout:
|
||||||
|
log.write(line)
|
||||||
|
log.flush()
|
||||||
|
if self.cancel_events[session_id].is_set():
|
||||||
|
terminate_process(process)
|
||||||
|
break
|
||||||
|
return process.wait()
|
||||||
|
finally:
|
||||||
|
with self.lock:
|
||||||
|
self.processes.pop(session_id, None)
|
||||||
|
self.lease.release(owner)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _latest_checkpoint(run_dir: Path) -> Path | None:
|
||||||
|
values = []
|
||||||
|
for path in run_dir.glob("model_*.pt"):
|
||||||
|
match = re.fullmatch(r"model_(\d+)\.pt", path.name)
|
||||||
|
if match:
|
||||||
|
values.append((int(match.group(1)), path))
|
||||||
|
return max(values, default=(0, None), key=lambda value: value[0])[1]
|
||||||
|
|
||||||
|
def _execute_trial(
|
||||||
|
self, session: dict, trial: dict, resume_checkpoint: Path | None = None
|
||||||
|
) -> dict:
|
||||||
|
session_id, trial_id = session["id"], trial["id"]
|
||||||
|
root = self._session_root(session_id)
|
||||||
|
run_dir = (root / trial["runDir"]).resolve()
|
||||||
|
if not run_dir.is_relative_to(root):
|
||||||
|
raise TuningError("trial 目录越界")
|
||||||
|
run_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
reward_path = run_dir / "reward_config.json"
|
||||||
|
reward_path.write_text(
|
||||||
|
json.dumps(trial["rewardConfig"], ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
config = session["config"]
|
||||||
|
command = [
|
||||||
|
self.python,
|
||||||
|
"-u",
|
||||||
|
"scripts/train.py",
|
||||||
|
config["taskId"],
|
||||||
|
f"--env.scene.num-envs={config['numEnvs']}",
|
||||||
|
f"--agent.max-iterations={trial['targetIterations']}",
|
||||||
|
f"--agent.seed={config['seed']}",
|
||||||
|
f"--agent.run-name={config['runName']}-t{trial['number']}-r{trial['rung']}",
|
||||||
|
"--agent.logger=tensorboard",
|
||||||
|
"--agent.upload-model=False",
|
||||||
|
"--gpu-ids",
|
||||||
|
json.dumps(config["gpuIds"], separators=(",", ":")),
|
||||||
|
"--output-dir",
|
||||||
|
str(run_dir),
|
||||||
|
"--reward-config",
|
||||||
|
str(reward_path),
|
||||||
|
]
|
||||||
|
if resume_checkpoint is not None:
|
||||||
|
command.extend(("--resume-checkpoint", str(resume_checkpoint)))
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["WANDB_MODE"] = "disabled"
|
||||||
|
environment["WANDB_SILENT"] = "true"
|
||||||
|
self.storage.update_trial(
|
||||||
|
trial_id, state="training", started_at=now_iso(), message="正在训练"
|
||||||
|
)
|
||||||
|
self.storage.update_session(
|
||||||
|
session_id,
|
||||||
|
state="running",
|
||||||
|
message=f"正在训练 trial {trial['number']} / rung {trial['rung']}",
|
||||||
|
)
|
||||||
|
return_code = self._run_command(
|
||||||
|
session_id, command, self.trainer_root, environment, run_dir / "train.log"
|
||||||
|
)
|
||||||
|
ingest_scalars(self.storage, trial_id, run_dir)
|
||||||
|
if self.cancel_events[session_id].is_set():
|
||||||
|
raise TuningError("session 已取消")
|
||||||
|
checkpoint = self._latest_checkpoint(run_dir)
|
||||||
|
policy = run_dir / "policy.onnx"
|
||||||
|
if return_code != 0 or checkpoint is None or not policy.is_file():
|
||||||
|
raise TuningError(f"训练失败(返回码 {return_code})或缺少 checkpoint/policy.onnx")
|
||||||
|
# 暂停只关闭后续 Trial 调度门;已经开始的 Trial 必须连同固定评估一起
|
||||||
|
# 完成,避免把一次单步令牌错误地消耗在半个 Trial 上。
|
||||||
|
eval_output = run_dir / "evaluation.json"
|
||||||
|
eval_command = [
|
||||||
|
self.python,
|
||||||
|
"-u",
|
||||||
|
"scripts/evaluate.py",
|
||||||
|
config["taskId"],
|
||||||
|
"--checkpoint",
|
||||||
|
str(checkpoint),
|
||||||
|
"--output",
|
||||||
|
str(eval_output),
|
||||||
|
"--reward-config",
|
||||||
|
str(reward_path),
|
||||||
|
f"--num-envs={config['evalNumEnvs']}",
|
||||||
|
f"--steps-per-seed={config['evalSteps']}",
|
||||||
|
"--gpu-ids",
|
||||||
|
json.dumps(config["gpuIds"], separators=(",", ":")),
|
||||||
|
]
|
||||||
|
self.storage.update_trial(trial_id, state="evaluating", message="正在固定协议评估")
|
||||||
|
self.storage.update_session(
|
||||||
|
session_id, state="evaluating", message=f"正在评估 trial {trial['number']}"
|
||||||
|
)
|
||||||
|
return_code = self._run_command(
|
||||||
|
session_id, eval_command, self.trainer_root, environment, run_dir / "evaluate.log"
|
||||||
|
)
|
||||||
|
ingest_scalars(self.storage, trial_id, run_dir / "evaluation-events")
|
||||||
|
if return_code != 0 or not eval_output.is_file():
|
||||||
|
raise TuningError(f"评估失败(返回码 {return_code})")
|
||||||
|
evaluation = json.loads(eval_output.read_text(encoding="utf-8"))
|
||||||
|
baseline_trial = self.storage.list_trials(session_id)[0]
|
||||||
|
if baseline_trial["evaluation"] is None:
|
||||||
|
scored = {
|
||||||
|
"eligible": True,
|
||||||
|
"score": 0.0,
|
||||||
|
"components": {},
|
||||||
|
"metrics": evaluation["metrics"],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
scored = score_evaluation(
|
||||||
|
baseline_trial["evaluation"]["metrics"],
|
||||||
|
evaluation["metrics"],
|
||||||
|
session["objectiveWeights"],
|
||||||
|
)
|
||||||
|
evaluation["score"] = scored
|
||||||
|
rel_checkpoint = str(checkpoint.relative_to(root))
|
||||||
|
rel_policy = str(policy.relative_to(root))
|
||||||
|
self.storage.update_trial(
|
||||||
|
trial_id,
|
||||||
|
state="completed",
|
||||||
|
ended_at=now_iso(),
|
||||||
|
message="训练与评估完成",
|
||||||
|
checkpoint_path=rel_checkpoint,
|
||||||
|
policy_path=rel_policy,
|
||||||
|
evaluation=evaluation,
|
||||||
|
score=scored["score"],
|
||||||
|
eligible=scored["eligible"],
|
||||||
|
)
|
||||||
|
best = self._best_highest_rung(session_id)
|
||||||
|
if best is not None:
|
||||||
|
self.storage.update_session(session_id, best_trial_id=best["id"])
|
||||||
|
try:
|
||||||
|
optuna_number = self.studies.record(
|
||||||
|
session_id,
|
||||||
|
trial["rewardConfig"],
|
||||||
|
scored["score"],
|
||||||
|
scored["eligible"],
|
||||||
|
trial["rung"],
|
||||||
|
)
|
||||||
|
self.storage.audit(
|
||||||
|
session_id, "optuna_trial_recorded", {"trialId": trial_id, "number": optuna_number}
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
self.storage.audit(
|
||||||
|
session_id, "optuna_record_failed", {"trialId": trial_id, "error": str(error)[:500]}
|
||||||
|
)
|
||||||
|
return self.storage.get_trial(trial_id)
|
||||||
|
|
||||||
|
def _best(self, session_id: str, rung: int | None = None) -> dict | None:
|
||||||
|
trials = [
|
||||||
|
trial
|
||||||
|
for trial in self.storage.list_trials(session_id)
|
||||||
|
if trial["state"] == "completed" and trial["eligible"]
|
||||||
|
]
|
||||||
|
if rung is not None:
|
||||||
|
trials = [trial for trial in trials if trial["rung"] == rung]
|
||||||
|
return max(
|
||||||
|
trials,
|
||||||
|
key=lambda trial: trial["score"] if trial["score"] is not None else -999,
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _best_highest_rung(self, session_id: str) -> dict | None:
|
||||||
|
for rung in (2, 1, 0):
|
||||||
|
best = self._best(session_id, rung=rung)
|
||||||
|
if best is not None:
|
||||||
|
return best
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _proposal_context(self, session: dict) -> dict:
|
||||||
|
trials = self.storage.list_trials(session["id"])[-12:]
|
||||||
|
rejected_feedback = [
|
||||||
|
proposal["feedback"]
|
||||||
|
for proposal in self.storage.list_proposals(session["id"])
|
||||||
|
if proposal["state"] == "rejected" and proposal["feedback"]
|
||||||
|
][-4:]
|
||||||
|
return {
|
||||||
|
"task": session["config"]["taskId"],
|
||||||
|
"objectiveWeights": session["objectiveWeights"],
|
||||||
|
"allowlist": "服务端将验证固定 schema;最多四项修改",
|
||||||
|
"parameterConstraints": self.storage.get_control(session["id"])["constraints"],
|
||||||
|
"rejectedFeedback": rejected_feedback,
|
||||||
|
"trials": [
|
||||||
|
{
|
||||||
|
"number": t["number"],
|
||||||
|
"rung": t["rung"],
|
||||||
|
"score": t["score"],
|
||||||
|
"eligible": t["eligible"],
|
||||||
|
"rewardConfig": t["rewardConfig"],
|
||||||
|
"evaluation": t["evaluation"] and t["evaluation"].get("metrics"),
|
||||||
|
}
|
||||||
|
for t in trials
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fallback_patch(self, previous: dict, index: int) -> dict:
|
||||||
|
names = ("track_linear_velocity", "action_rate_l2", "body_orientation_l2", "foot_slip")
|
||||||
|
name = names[index % len(names)]
|
||||||
|
old = previous["weights"][name]
|
||||||
|
factor = 1.1 if index % 2 == 0 else 0.9
|
||||||
|
return validate_proposal({"weights": {name: old * factor}}, previous)
|
||||||
|
|
||||||
|
def _request_proposal(
|
||||||
|
self, session: dict, previous: dict, base_trial_id: str, index: int
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
result = self.advisor.propose(self._proposal_context(session), previous)
|
||||||
|
source = "agent"
|
||||||
|
except Exception as error:
|
||||||
|
if not session["fallbackEnabled"]:
|
||||||
|
raise AdvisorUnavailable(str(error)) from error
|
||||||
|
result = {
|
||||||
|
"patch": self._fallback_patch(previous, index),
|
||||||
|
"rationale": f"Agent 不可用,显式 fallback:{error}",
|
||||||
|
"expectedImpact": {},
|
||||||
|
"confidence": 0.2,
|
||||||
|
"promptHash": None,
|
||||||
|
"usage": {},
|
||||||
|
"model": "optuna-fallback",
|
||||||
|
}
|
||||||
|
source = "fallback"
|
||||||
|
constraints = self.storage.get_control(session["id"])["constraints"]
|
||||||
|
result["patch"] = validate_proposal(result["patch"], previous, constraints)
|
||||||
|
proposal = self.storage.create_proposal(
|
||||||
|
session["id"],
|
||||||
|
base_trial_id,
|
||||||
|
result["patch"],
|
||||||
|
result["rationale"],
|
||||||
|
result.get("expectedImpact", {}),
|
||||||
|
result["confidence"],
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.storage.audit(
|
||||||
|
session["id"],
|
||||||
|
"proposal_created",
|
||||||
|
{
|
||||||
|
"proposalId": proposal["id"],
|
||||||
|
"source": source,
|
||||||
|
"promptHash": result.get("promptHash"),
|
||||||
|
"usage": result.get("usage", {}),
|
||||||
|
"model": result.get("model"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return proposal
|
||||||
|
|
||||||
|
def _wait_for_approval(
|
||||||
|
self, session_id: str, proposal_id: str, cancel: threading.Event
|
||||||
|
) -> dict:
|
||||||
|
with self.condition:
|
||||||
|
while not cancel.is_set():
|
||||||
|
proposal = self.storage.get_proposal(proposal_id)
|
||||||
|
if proposal["state"] != "pending":
|
||||||
|
return proposal
|
||||||
|
self.condition.wait(timeout=1.0)
|
||||||
|
raise TuningError("session 已取消")
|
||||||
|
|
||||||
|
def _run_session(self, session_id: str, resume: bool, cancel: threading.Event) -> None:
|
||||||
|
try:
|
||||||
|
session = self.storage.get_session(session_id)
|
||||||
|
trials = self.storage.list_trials(session_id)
|
||||||
|
if resume:
|
||||||
|
root = self._session_root(session_id)
|
||||||
|
for interrupted in [trial for trial in trials if trial["state"] == "interrupted"]:
|
||||||
|
run_dir = (root / interrupted["runDir"]).resolve()
|
||||||
|
if run_dir.is_relative_to(root):
|
||||||
|
shutil.rmtree(run_dir, ignore_errors=True)
|
||||||
|
self.storage.delete_trial(interrupted["id"])
|
||||||
|
self.storage.audit(
|
||||||
|
session_id,
|
||||||
|
"session_resumed",
|
||||||
|
{
|
||||||
|
"discardedInterruptedTrials": [
|
||||||
|
t["id"] for t in trials if t["state"] == "interrupted"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
trials = self.storage.list_trials(session_id)
|
||||||
|
if not trials:
|
||||||
|
baseline_dir = "trial-000-rung-0"
|
||||||
|
trial = self.storage.create_trial(
|
||||||
|
session_id,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
session["config"]["rungs"][0],
|
||||||
|
deepcopy(BASE_REWARD_CONFIGURATION),
|
||||||
|
None,
|
||||||
|
baseline_dir,
|
||||||
|
)
|
||||||
|
self._execute_trial(session, trial)
|
||||||
|
session = self.storage.get_session(session_id)
|
||||||
|
completed_rung0 = [
|
||||||
|
t
|
||||||
|
for t in self.storage.list_trials(session_id)
|
||||||
|
if t["rung"] == 0 and t["state"] == "completed"
|
||||||
|
]
|
||||||
|
next_number = len({t["number"] for t in completed_rung0})
|
||||||
|
best_score = max(
|
||||||
|
(t["score"] if t["score"] is not None else float("-inf") for t in completed_rung0),
|
||||||
|
default=0.0,
|
||||||
|
)
|
||||||
|
no_improve = 0
|
||||||
|
while (
|
||||||
|
next_number < session["config"]["trialCount"]
|
||||||
|
and no_improve < session["config"]["earlyStopPatience"]
|
||||||
|
):
|
||||||
|
if cancel.is_set():
|
||||||
|
raise TuningError("session 已取消")
|
||||||
|
self._wait_for_dispatch(session_id, cancel)
|
||||||
|
control = self.storage.get_control(session_id)
|
||||||
|
base = (
|
||||||
|
self.storage.get_trial(control["activeBaseTrialId"])
|
||||||
|
if control["activeBaseTrialId"]
|
||||||
|
else self._best(session_id, rung=0) or completed_rung0[0]
|
||||||
|
)
|
||||||
|
proposal = self._request_proposal(
|
||||||
|
session, base["rewardConfig"], base["id"], next_number
|
||||||
|
)
|
||||||
|
# 模式允许在 session 运行期间切换,因此每次决策都读取最新持久化值。
|
||||||
|
current_mode = self.storage.get_session(session_id)["mode"]
|
||||||
|
if current_mode == "approval":
|
||||||
|
self.storage.update_session(
|
||||||
|
session_id, state="awaiting_approval", message="等待批准 Agent 建议"
|
||||||
|
)
|
||||||
|
proposal = self._wait_for_approval(session_id, proposal["id"], cancel)
|
||||||
|
if proposal["state"] == "rejected":
|
||||||
|
self.storage.audit(
|
||||||
|
session_id,
|
||||||
|
"proposal_rejected",
|
||||||
|
{"proposalId": proposal["id"], "feedback": proposal["feedback"]},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
self.storage.decide_proposal(proposal["id"], "approved", "自动模式")
|
||||||
|
proposal = self.storage.get_proposal(proposal["id"])
|
||||||
|
self._claim_dispatch(session_id, cancel)
|
||||||
|
constraints = self.storage.get_control(session_id)["constraints"]
|
||||||
|
reward_config = merge_proposal(base["rewardConfig"], proposal["patch"], constraints)
|
||||||
|
trial = self.storage.create_trial(
|
||||||
|
session_id,
|
||||||
|
next_number,
|
||||||
|
0,
|
||||||
|
session["config"]["rungs"][0],
|
||||||
|
reward_config,
|
||||||
|
proposal["id"],
|
||||||
|
f"trial-{next_number:03d}-rung-0",
|
||||||
|
)
|
||||||
|
result = self._execute_trial(session, trial)
|
||||||
|
if control["activeBaseTrialId"]:
|
||||||
|
self.storage.set_active_base(session_id, None)
|
||||||
|
self._pause_after_step(session_id)
|
||||||
|
result_score = result["score"]
|
||||||
|
if (
|
||||||
|
result["eligible"]
|
||||||
|
and result_score is not None
|
||||||
|
and result_score > best_score + 0.01
|
||||||
|
):
|
||||||
|
best_score = result_score
|
||||||
|
no_improve = 0
|
||||||
|
else:
|
||||||
|
no_improve += 1
|
||||||
|
self.storage.update_session(session_id, consecutive_no_improve=no_improve)
|
||||||
|
next_number += 1
|
||||||
|
|
||||||
|
# Promote top configurations; each new rung resumes its own previous checkpoint.
|
||||||
|
for rung in (1, 2):
|
||||||
|
previous = [
|
||||||
|
t
|
||||||
|
for t in self.storage.list_trials(session_id)
|
||||||
|
if t["rung"] == rung - 1 and t["state"] == "completed" and t["eligible"]
|
||||||
|
]
|
||||||
|
previous.sort(
|
||||||
|
key=lambda trial: (
|
||||||
|
trial["score"] if trial["score"] is not None else float("-inf")
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
promoted_numbers = {
|
||||||
|
trial["number"]
|
||||||
|
for trial in self.storage.list_trials(session_id)
|
||||||
|
if trial["rung"] == rung and trial["state"] == "completed"
|
||||||
|
}
|
||||||
|
for parent in previous[: session["config"]["promote"][rung]]:
|
||||||
|
if parent["number"] in promoted_numbers:
|
||||||
|
continue
|
||||||
|
if cancel.is_set():
|
||||||
|
raise TuningError("session 已取消")
|
||||||
|
self._claim_dispatch(session_id, cancel)
|
||||||
|
root = self._session_root(session_id)
|
||||||
|
checkpoint = root / parent["checkpointPath"]
|
||||||
|
trial = self.storage.create_trial(
|
||||||
|
session_id,
|
||||||
|
parent["number"],
|
||||||
|
rung,
|
||||||
|
session["config"]["rungs"][rung],
|
||||||
|
parent["rewardConfig"],
|
||||||
|
parent["proposalId"],
|
||||||
|
f"trial-{parent['number']:03d}-rung-{rung}",
|
||||||
|
)
|
||||||
|
self._execute_trial(session, trial, checkpoint)
|
||||||
|
self._pause_after_step(session_id)
|
||||||
|
|
||||||
|
best = self._best_highest_rung(session_id)
|
||||||
|
if best is None:
|
||||||
|
raise TuningError("没有通过安全门槛的 trial")
|
||||||
|
preset_name = f"{session['config']['runName']}-{session_id[:8]}"
|
||||||
|
self.storage.save_preset(preset_name, session_id, best["id"], best["rewardConfig"])
|
||||||
|
self.storage.update_session(
|
||||||
|
session_id,
|
||||||
|
state="succeeded",
|
||||||
|
best_trial_id=best["id"],
|
||||||
|
current_trial_id=None,
|
||||||
|
message="调参完成",
|
||||||
|
)
|
||||||
|
self.storage.audit(
|
||||||
|
session_id, "session_completed", {"bestTrialId": best["id"], "preset": preset_name}
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
state = self.storage.get_session(session_id)["state"]
|
||||||
|
if cancel.is_set() or state == "cancelled":
|
||||||
|
self.storage.update_session(
|
||||||
|
session_id, state="cancelled", message="调参已取消", current_trial_id=None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.storage.update_session(
|
||||||
|
session_id, state="failed", message=str(error), current_trial_id=None
|
||||||
|
)
|
||||||
|
self.storage.audit(session_id, "session_failed", {"error": str(error)[:1000]})
|
||||||
|
finally:
|
||||||
|
with self.lock:
|
||||||
|
self.workers.pop(session_id, None)
|
||||||
|
self.processes.pop(session_id, None)
|
||||||
|
|
||||||
|
def _wait_for_dispatch(self, session_id: str, cancel: threading.Event) -> None:
|
||||||
|
"""Wait at a scheduler boundary without consuming a one-shot token."""
|
||||||
|
with self.condition:
|
||||||
|
while not cancel.is_set():
|
||||||
|
session = self.storage.get_session(session_id)
|
||||||
|
control = self.storage.get_control(session_id)
|
||||||
|
if session["state"] == "paused":
|
||||||
|
self.condition.wait(timeout=1.0)
|
||||||
|
continue
|
||||||
|
if control["runPolicy"] == "continuous" or control["dispatchTokens"] > 0:
|
||||||
|
return
|
||||||
|
self.storage.update_session(
|
||||||
|
session_id,
|
||||||
|
state="paused",
|
||||||
|
message="单步 Trial 已完成;等待下一个调度令牌",
|
||||||
|
)
|
||||||
|
self.storage.audit(session_id, "step_gate_waiting", {})
|
||||||
|
self.condition.wait(timeout=1.0)
|
||||||
|
raise TuningError("session 已取消")
|
||||||
|
|
||||||
|
def _claim_dispatch(self, session_id: str, cancel: threading.Event) -> None:
|
||||||
|
while not cancel.is_set():
|
||||||
|
self._wait_for_dispatch(session_id, cancel)
|
||||||
|
if self.storage.use_dispatch_token(session_id):
|
||||||
|
return
|
||||||
|
raise TuningError("session 已取消")
|
||||||
|
|
||||||
|
def _pause_after_step(self, session_id: str) -> None:
|
||||||
|
control = self.storage.get_control(session_id)
|
||||||
|
if control["runPolicy"] == "step" and control["dispatchTokens"] == 0:
|
||||||
|
self.storage.update_session(
|
||||||
|
session_id,
|
||||||
|
state="paused",
|
||||||
|
message="单步 Trial 已完成;后续调度已暂停",
|
||||||
|
)
|
||||||
|
self.storage.audit(session_id, "step_trial_completed", {})
|
||||||
|
|
||||||
|
def approve(self, session_id: str, proposal_id: str, payload: Any) -> dict:
|
||||||
|
proposal = self.storage.get_proposal(proposal_id)
|
||||||
|
if proposal["sessionId"] != session_id:
|
||||||
|
raise TuningError("proposal 不属于该 session")
|
||||||
|
patch = proposal["patch"]
|
||||||
|
feedback = None
|
||||||
|
base = self.storage.get_trial(proposal["baseTrialId"])
|
||||||
|
constraints = self.storage.get_control(session_id)["constraints"]
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
feedback = payload.get("feedback")
|
||||||
|
if "patch" in payload:
|
||||||
|
patch = payload["patch"]
|
||||||
|
if feedback is not None and (not isinstance(feedback, str) or len(feedback) > 2000):
|
||||||
|
raise TuningError("feedback 无效")
|
||||||
|
patch = validate_proposal(patch, base["rewardConfig"], constraints)
|
||||||
|
if not self.storage.decide_proposal(proposal_id, "approved", feedback, patch):
|
||||||
|
raise TuningError("proposal 已处理")
|
||||||
|
self.storage.audit(
|
||||||
|
session_id,
|
||||||
|
"proposal_approved",
|
||||||
|
{"proposalId": proposal_id, "modified": patch != proposal["patch"]},
|
||||||
|
)
|
||||||
|
with self.condition:
|
||||||
|
self.condition.notify_all()
|
||||||
|
return self.detail(session_id)
|
||||||
|
|
||||||
|
def reject(self, session_id: str, proposal_id: str, payload: Any) -> dict:
|
||||||
|
feedback = payload.get("feedback", "") if isinstance(payload, dict) else ""
|
||||||
|
if not isinstance(feedback, str) or len(feedback) > 2000:
|
||||||
|
raise TuningError("feedback 无效")
|
||||||
|
proposal = self.storage.get_proposal(proposal_id)
|
||||||
|
if proposal["sessionId"] != session_id:
|
||||||
|
raise TuningError("proposal 不属于该 session")
|
||||||
|
if not self.storage.decide_proposal(proposal_id, "rejected", feedback):
|
||||||
|
raise TuningError("proposal 已处理")
|
||||||
|
with self.condition:
|
||||||
|
self.condition.notify_all()
|
||||||
|
return self.detail(session_id)
|
||||||
|
|
||||||
|
def set_mode(self, session_id: str, payload: Any) -> dict:
|
||||||
|
if not isinstance(payload, dict) or payload.get("mode") not in ("automatic", "approval"):
|
||||||
|
raise TuningError("mode 必须是 automatic 或 approval")
|
||||||
|
mode = payload["mode"]
|
||||||
|
with self.condition:
|
||||||
|
session = self.storage.get_session(session_id)
|
||||||
|
if session["state"] not in RUNNING_STATES | {"awaiting_approval", "paused"}:
|
||||||
|
raise TuningError("当前状态不能切换运行模式")
|
||||||
|
previous = session["mode"]
|
||||||
|
if previous == mode:
|
||||||
|
return self.detail(session_id)
|
||||||
|
self.storage.update_session(session_id, mode=mode)
|
||||||
|
approved_ids = []
|
||||||
|
if mode == "automatic":
|
||||||
|
constraints = self.storage.get_control(session_id)["constraints"]
|
||||||
|
for proposal in self.storage.list_proposals(session_id):
|
||||||
|
if proposal["state"] != "pending":
|
||||||
|
continue
|
||||||
|
base = self.storage.get_trial(proposal["baseTrialId"])
|
||||||
|
try:
|
||||||
|
validate_proposal(proposal["patch"], base["rewardConfig"], constraints)
|
||||||
|
except Exception as error:
|
||||||
|
self.storage.decide_proposal(
|
||||||
|
proposal["id"], "rejected", f"参数护栏已变化:{error}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if self.storage.decide_proposal(
|
||||||
|
proposal["id"], "approved", "运行时切换为全自动模式"
|
||||||
|
):
|
||||||
|
approved_ids.append(proposal["id"])
|
||||||
|
if session["state"] == "awaiting_approval":
|
||||||
|
self.storage.update_session(
|
||||||
|
session_id, state="running", message="已切换为全自动模式,继续调参"
|
||||||
|
)
|
||||||
|
self.storage.audit(
|
||||||
|
session_id,
|
||||||
|
"session_mode_changed",
|
||||||
|
{"from": previous, "to": mode, "autoApprovedProposalIds": approved_ids},
|
||||||
|
)
|
||||||
|
self.condition.notify_all()
|
||||||
|
return self.detail(session_id)
|
||||||
|
|
||||||
|
def set_constraints(self, session_id: str, payload: Any) -> dict:
|
||||||
|
if not isinstance(payload, dict) or set(payload) != {"revision", "constraints"}:
|
||||||
|
raise TuningError("参数护栏请求必须包含 revision 与 constraints")
|
||||||
|
revision = payload["revision"]
|
||||||
|
if isinstance(revision, bool) or not isinstance(revision, int) or revision < 0:
|
||||||
|
raise TuningError("constraints revision 必须是非负整数")
|
||||||
|
constraints = validate_constraints(payload["constraints"])
|
||||||
|
session = self.storage.get_session(session_id)
|
||||||
|
if session["state"] not in ACTIVE_SESSION_STATES:
|
||||||
|
raise TuningError("终态 session 不能修改参数护栏")
|
||||||
|
control = self.storage.get_control(session_id)
|
||||||
|
base = None
|
||||||
|
if control["activeBaseTrialId"]:
|
||||||
|
base = self.storage.get_trial(control["activeBaseTrialId"])
|
||||||
|
elif session["currentTrialId"]:
|
||||||
|
base = self.storage.get_trial(session["currentTrialId"])
|
||||||
|
else:
|
||||||
|
base = self._best_highest_rung(session_id)
|
||||||
|
if base is not None:
|
||||||
|
validate_configuration_constraints(base["rewardConfig"], constraints)
|
||||||
|
try:
|
||||||
|
updated = self.storage.replace_constraints(session_id, revision, constraints)
|
||||||
|
except StorageConflict as error:
|
||||||
|
raise ResourceBusyError(str(error)) from error
|
||||||
|
|
||||||
|
rejected = []
|
||||||
|
for proposal in self.storage.list_proposals(session_id):
|
||||||
|
if proposal["state"] != "pending":
|
||||||
|
continue
|
||||||
|
proposal_base = self.storage.get_trial(proposal["baseTrialId"])
|
||||||
|
try:
|
||||||
|
validate_proposal(proposal["patch"], proposal_base["rewardConfig"], constraints)
|
||||||
|
except Exception as error:
|
||||||
|
message = f"参数护栏 revision {updated['constraintsRevision']}:{error}"
|
||||||
|
if self.storage.decide_proposal(proposal["id"], "rejected", message):
|
||||||
|
rejected.append(proposal["id"])
|
||||||
|
self.storage.audit(
|
||||||
|
session_id,
|
||||||
|
"constraints_updated",
|
||||||
|
{
|
||||||
|
"revision": updated["constraintsRevision"],
|
||||||
|
"paths": sorted(constraints),
|
||||||
|
"rejectedProposalIds": rejected,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if rejected:
|
||||||
|
with self.condition:
|
||||||
|
self.condition.notify_all()
|
||||||
|
return self.detail(session_id)
|
||||||
|
|
||||||
|
def step(self, session_id: str, payload: Any) -> dict:
|
||||||
|
if not isinstance(payload, dict) or payload.get("count", 1) != 1:
|
||||||
|
raise TuningError("单步调度一次只能发放 1 个 Trial 令牌")
|
||||||
|
session = self.storage.get_session(session_id)
|
||||||
|
if session["state"] not in {"paused", "awaiting_approval"}:
|
||||||
|
raise TuningError("请先暂停或等待 Proposal 审批,再执行单步 Trial")
|
||||||
|
control = self.storage.get_control(session_id)
|
||||||
|
if control["dispatchTokens"] > 0:
|
||||||
|
raise TuningError("已有未消费的单步 Trial 令牌")
|
||||||
|
try:
|
||||||
|
self.storage.grant_dispatch_token(session_id)
|
||||||
|
except StorageConflict as error:
|
||||||
|
raise ResourceBusyError(str(error)) from error
|
||||||
|
if session["state"] == "paused":
|
||||||
|
has_pending = any(
|
||||||
|
proposal["state"] == "pending"
|
||||||
|
for proposal in self.storage.list_proposals(session_id)
|
||||||
|
)
|
||||||
|
self.storage.update_session(
|
||||||
|
session_id,
|
||||||
|
state="awaiting_approval" if has_pending else "running",
|
||||||
|
message="单步令牌已就绪;请审批 Proposal"
|
||||||
|
if has_pending
|
||||||
|
else "已授权执行一个 Trial",
|
||||||
|
)
|
||||||
|
self.storage.audit(session_id, "step_token_granted", {"count": 1})
|
||||||
|
with self.condition:
|
||||||
|
self.condition.notify_all()
|
||||||
|
return self.detail(session_id)
|
||||||
|
|
||||||
|
def rollback(self, session_id: str, payload: Any) -> dict:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise TuningError("rollback 请求体必须是对象")
|
||||||
|
session = self.storage.get_session(session_id)
|
||||||
|
if session["state"] not in {"paused", "awaiting_approval"}:
|
||||||
|
raise TuningError("回滚只能在安全暂停或等待审批时执行")
|
||||||
|
target_id = payload.get("trialId")
|
||||||
|
if payload.get("target") == "best":
|
||||||
|
target_id = session["bestTrialId"] or (self._best_highest_rung(session_id) or {}).get(
|
||||||
|
"id"
|
||||||
|
)
|
||||||
|
if not isinstance(target_id, str):
|
||||||
|
raise TuningError("rollback 必须指定 trialId 或 target=best")
|
||||||
|
target = self.storage.get_trial(target_id)
|
||||||
|
if target["sessionId"] != session_id:
|
||||||
|
raise TuningError("rollback Trial 不属于该 session")
|
||||||
|
if target["state"] != "completed" or not target["eligible"]:
|
||||||
|
raise TuningError("只能回滚到已完成且通过安全门槛的 Trial")
|
||||||
|
checkpoint = payload.get("checkpoint", False)
|
||||||
|
if not isinstance(checkpoint, bool):
|
||||||
|
raise TuningError("checkpoint 必须是布尔值")
|
||||||
|
if checkpoint:
|
||||||
|
path_value = target.get("checkpointPath")
|
||||||
|
if not path_value:
|
||||||
|
raise TuningError("目标 Trial 没有可用 checkpoint")
|
||||||
|
root = self._session_root(session_id)
|
||||||
|
path = (root / path_value).resolve()
|
||||||
|
if not path.is_relative_to(root) or not path.is_file():
|
||||||
|
raise TuningError("目标 checkpoint 不存在或路径非法")
|
||||||
|
|
||||||
|
superseded = []
|
||||||
|
for proposal in self.storage.list_proposals(session_id):
|
||||||
|
if proposal["state"] == "pending" and self.storage.decide_proposal(
|
||||||
|
proposal["id"], "rejected", f"由回滚到 Trial {target_id[:8]} 取代"
|
||||||
|
):
|
||||||
|
superseded.append(proposal["id"])
|
||||||
|
self.storage.set_active_base(session_id, target_id)
|
||||||
|
self.storage.reset_step_gate(session_id)
|
||||||
|
self.storage.update_session(
|
||||||
|
session_id,
|
||||||
|
state="paused",
|
||||||
|
message=f"已回滚到 Trial {target['number']} / Rung {target['rung']};等待单步或继续",
|
||||||
|
)
|
||||||
|
self.storage.audit(
|
||||||
|
session_id,
|
||||||
|
"rollback_selected",
|
||||||
|
{
|
||||||
|
"trialId": target_id,
|
||||||
|
"checkpoint": checkpoint,
|
||||||
|
"supersededProposalIds": superseded,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with self.condition:
|
||||||
|
self.condition.notify_all()
|
||||||
|
return self.detail(session_id)
|
||||||
|
|
||||||
|
def pause(self, session_id: str) -> dict:
|
||||||
|
session = self.storage.get_session(session_id)
|
||||||
|
if session["state"] not in RUNNING_STATES | {"awaiting_approval"}:
|
||||||
|
raise TuningError("当前状态不能暂停")
|
||||||
|
# 以持久化调度门记录暂停意图,而不是只依赖 session.state。当前 Trial
|
||||||
|
# 的训练/评估会继续完成,下一次 _claim_dispatch 必须等待显式继续或单步。
|
||||||
|
self.storage.reset_step_gate(session_id)
|
||||||
|
self.storage.update_session(
|
||||||
|
session_id, state="paused", message="已暂停后续调度;在途 Trial(如有)将完整结束"
|
||||||
|
)
|
||||||
|
return self.detail(session_id)
|
||||||
|
|
||||||
|
def resume(self, session_id: str) -> dict:
|
||||||
|
session = self.storage.get_session(session_id)
|
||||||
|
if session["state"] == "paused":
|
||||||
|
self.storage.set_run_policy(session_id, "continuous")
|
||||||
|
has_pending = any(
|
||||||
|
proposal["state"] == "pending"
|
||||||
|
for proposal in self.storage.list_proposals(session_id)
|
||||||
|
)
|
||||||
|
self.storage.update_session(
|
||||||
|
session_id,
|
||||||
|
state="awaiting_approval" if has_pending else "running",
|
||||||
|
message="请审批待处理 Proposal" if has_pending else "连续调参已恢复",
|
||||||
|
)
|
||||||
|
with self.condition:
|
||||||
|
self.condition.notify_all()
|
||||||
|
elif session["state"] == "interrupted":
|
||||||
|
self.storage.set_run_policy(session_id, "continuous")
|
||||||
|
self.storage.update_session(session_id, state="queued", message="从最近完整结果恢复")
|
||||||
|
self._start_worker(session_id, resume=True)
|
||||||
|
else:
|
||||||
|
raise TuningError("当前状态不能恢复")
|
||||||
|
return self.detail(session_id)
|
||||||
|
|
||||||
|
def cancel(self, session_id: str) -> dict:
|
||||||
|
session = self.storage.get_session(session_id)
|
||||||
|
if session["state"] not in ACTIVE_SESSION_STATES:
|
||||||
|
return self.detail(session_id)
|
||||||
|
self.storage.update_session(session_id, state="cancelled", message="正在取消")
|
||||||
|
event = self.cancel_events.get(session_id)
|
||||||
|
if event:
|
||||||
|
event.set()
|
||||||
|
process = self.processes.get(session_id)
|
||||||
|
if process:
|
||||||
|
terminate_process(process)
|
||||||
|
with self.condition:
|
||||||
|
self.condition.notify_all()
|
||||||
|
return self.detail(session_id)
|
||||||
|
|
||||||
|
def metrics(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
trial_id: str,
|
||||||
|
tags: list[str] | None,
|
||||||
|
max_points: int,
|
||||||
|
after_step: int | None = None,
|
||||||
|
) -> dict:
|
||||||
|
trial = self.storage.get_trial(trial_id)
|
||||||
|
if trial["sessionId"] != session_id:
|
||||||
|
raise TuningError("trial 不属于该 session")
|
||||||
|
series = self.storage.metrics(trial_id, tags, max_points, after_step)
|
||||||
|
next_step = max(
|
||||||
|
(point["step"] for item in series for point in item["points"]),
|
||||||
|
default=after_step,
|
||||||
|
)
|
||||||
|
return {"trialId": trial_id, "series": series, "nextStep": next_step}
|
||||||
|
|
||||||
|
def best_artifact(self, session_id: str) -> Path:
|
||||||
|
session = self.storage.get_session(session_id)
|
||||||
|
if not session["bestTrialId"]:
|
||||||
|
raise TuningError("尚无最佳策略")
|
||||||
|
trial = self.storage.get_trial(session["bestTrialId"])
|
||||||
|
if not trial["policyPath"]:
|
||||||
|
raise TuningError("最佳策略文件不存在")
|
||||||
|
root = self._session_root(session_id)
|
||||||
|
path = (root / trial["policyPath"]).resolve()
|
||||||
|
if not path.is_relative_to(root) or not path.is_file():
|
||||||
|
raise TuningError("最佳策略文件不存在")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def preset_config(self, preset_id: str) -> dict:
|
||||||
|
return self.storage.get_preset(preset_id)["rewardConfig"]
|
||||||
|
|
||||||
|
def test_agent(self) -> dict:
|
||||||
|
try:
|
||||||
|
return self.advisor.test_connection()
|
||||||
|
except Exception as error:
|
||||||
|
raise TuningError(f"Agent 连接测试失败:{error}") from error
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
for session_id in list(self.workers):
|
||||||
|
with suppress(KeyError, TuningError):
|
||||||
|
self.cancel(session_id)
|
||||||
|
for worker in list(self.workers.values()):
|
||||||
|
worker.join(timeout=7)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Shared GPU lease and process-group lifecycle helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceBusyError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GpuLease:
|
||||||
|
def __init__(self):
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.owner: str | None = None
|
||||||
|
|
||||||
|
def acquire(self, owner: str) -> None:
|
||||||
|
with self.lock:
|
||||||
|
if self.owner is not None and self.owner != owner:
|
||||||
|
raise ResourceBusyError(f"计算资源正由 {self.owner} 使用")
|
||||||
|
self.owner = owner
|
||||||
|
|
||||||
|
def release(self, owner: str) -> None:
|
||||||
|
with self.lock:
|
||||||
|
if self.owner == owner:
|
||||||
|
self.owner = None
|
||||||
|
|
||||||
|
def public(self) -> str | None:
|
||||||
|
with self.lock:
|
||||||
|
return self.owner
|
||||||
|
|
||||||
|
|
||||||
|
def terminate_process(process: subprocess.Popen[str], grace_seconds: float = 5.0) -> None:
|
||||||
|
if process.poll() is not None:
|
||||||
|
return
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
os.killpg(process.pid, signal.SIGTERM)
|
||||||
|
try:
|
||||||
|
process.wait(timeout=grace_seconds)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""Pure-Python reward tuning schema shared by the service and trainer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from copy import deepcopy
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
MAX_PROPOSAL_CHANGES = 4
|
||||||
|
MIN_CHANGE_RATIO = 0.5
|
||||||
|
MAX_CHANGE_RATIO = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
class RewardConfigError(ValueError):
|
||||||
|
"""A reward configuration or proposal violated the allowlist."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NumericSpec:
|
||||||
|
minimum: float
|
||||||
|
maximum: float
|
||||||
|
default: float
|
||||||
|
allow_zero: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
WEIGHT_SPECS: dict[str, NumericSpec] = {
|
||||||
|
"track_linear_velocity": NumericSpec(0.5, 3.0, 1.0, False),
|
||||||
|
"track_angular_velocity": NumericSpec(0.25, 2.0, 1.0, False),
|
||||||
|
"body_orientation_l2": NumericSpec(-3.0, -0.1, -1.0, False),
|
||||||
|
"pose": NumericSpec(0.0, 2.5, 1.0),
|
||||||
|
"body_ang_vel": NumericSpec(-0.2, 0.0, -0.05),
|
||||||
|
"angular_momentum": NumericSpec(-0.1, 0.0, -0.025),
|
||||||
|
"is_terminated": NumericSpec(-400.0, -50.0, -200.0, False),
|
||||||
|
"joint_acc_l2": NumericSpec(-2.0e-6, 0.0, -2.5e-7),
|
||||||
|
"joint_pos_limits": NumericSpec(-30.0, -2.0, -10.0, False),
|
||||||
|
"action_rate_l2": NumericSpec(-0.2, -0.005, -0.05, False),
|
||||||
|
"foot_gait": NumericSpec(0.0, 1.5, 0.5),
|
||||||
|
"foot_clearance": NumericSpec(-3.0, 0.0, -1.0),
|
||||||
|
"foot_slip": NumericSpec(-1.0, 0.0, -0.25),
|
||||||
|
"soft_landing": NumericSpec(-5.0e-3, 0.0, -1.0e-3),
|
||||||
|
"stand_still": NumericSpec(-3.0, 0.0, -1.0),
|
||||||
|
"electrical_power": NumericSpec(-5.0e-3, 0.0, 0.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
PARAMETER_SPECS: dict[str, NumericSpec] = {
|
||||||
|
"track_linear_velocity.std": NumericSpec(0.25, 1.0, math.sqrt(0.25), False),
|
||||||
|
"track_angular_velocity.std": NumericSpec(0.35, 1.2, math.sqrt(0.5), False),
|
||||||
|
"pose.std_standing_scale": NumericSpec(0.5, 2.0, 1.0, False),
|
||||||
|
"pose.std_walking_scale": NumericSpec(0.5, 2.0, 1.0, False),
|
||||||
|
"pose.std_running_scale": NumericSpec(0.5, 2.0, 1.0, False),
|
||||||
|
"pose.walking_threshold": NumericSpec(0.05, 0.5, 0.1, False),
|
||||||
|
"pose.running_threshold": NumericSpec(1.0, 2.5, 1.5, False),
|
||||||
|
"foot_gait.period": NumericSpec(0.4, 0.8, 0.6, False),
|
||||||
|
"foot_gait.threshold": NumericSpec(0.45, 0.65, 0.56, False),
|
||||||
|
"foot_gait.command_threshold": NumericSpec(0.02, 0.3, 0.1, False),
|
||||||
|
"foot_clearance.target_height": NumericSpec(0.06, 0.16, 0.1, False),
|
||||||
|
"foot_clearance.command_threshold": NumericSpec(0.02, 0.3, 0.1, False),
|
||||||
|
"foot_slip.command_threshold": NumericSpec(0.02, 0.3, 0.1, False),
|
||||||
|
"soft_landing.command_threshold": NumericSpec(0.02, 0.3, 0.1, False),
|
||||||
|
"stand_still.command_threshold": NumericSpec(0.02, 0.3, 0.1, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
BASE_REWARD_CONFIGURATION: dict[str, dict[str, float]] = {
|
||||||
|
"weights": {name: spec.default for name, spec in WEIGHT_SPECS.items()},
|
||||||
|
"params": {name: spec.default for name, spec in PARAMETER_SPECS.items()},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _number(name: str, value: Any, spec: NumericSpec) -> float:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
raise RewardConfigError(f"{name} 必须是数值")
|
||||||
|
result = float(value)
|
||||||
|
if not math.isfinite(result):
|
||||||
|
raise RewardConfigError(f"{name} 必须是有限数值")
|
||||||
|
if result == 0.0 and not spec.allow_zero:
|
||||||
|
raise RewardConfigError(f"{name} 不允许关闭")
|
||||||
|
if result < spec.minimum or result > spec.maximum:
|
||||||
|
raise RewardConfigError(f"{name} 必须在 {spec.minimum}–{spec.maximum} 之间")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: Any, name: str) -> Mapping[str, Any]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise RewardConfigError(f"{name} 必须是对象")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _cross_validate(config: Mapping[str, Mapping[str, float]]) -> None:
|
||||||
|
params = config["params"]
|
||||||
|
if params["pose.walking_threshold"] >= params["pose.running_threshold"]:
|
||||||
|
raise RewardConfigError("pose.walking_threshold 必须小于 pose.running_threshold")
|
||||||
|
|
||||||
|
|
||||||
|
def _path_spec(path: str) -> tuple[str, str, NumericSpec]:
|
||||||
|
if path.startswith("weights."):
|
||||||
|
section, name = "weights", path.removeprefix("weights.")
|
||||||
|
spec = WEIGHT_SPECS.get(name)
|
||||||
|
elif path.startswith("params."):
|
||||||
|
section, name = "params", path.removeprefix("params.")
|
||||||
|
spec = PARAMETER_SPECS.get(name)
|
||||||
|
else:
|
||||||
|
section, name, spec = "", "", None
|
||||||
|
if spec is None:
|
||||||
|
raise RewardConfigError(f"未知参数约束:{path}")
|
||||||
|
return section, name, spec
|
||||||
|
|
||||||
|
|
||||||
|
def validate_constraints(value: Any) -> dict[str, dict[str, float | str]]:
|
||||||
|
"""Validate sparse per-session range/fixed safety constraints."""
|
||||||
|
root = _mapping(value, "constraints")
|
||||||
|
if len(root) > len(WEIGHT_SPECS) + len(PARAMETER_SPECS):
|
||||||
|
raise RewardConfigError("constraints 数量超过白名单参数总数")
|
||||||
|
result: dict[str, dict[str, float | str]] = {}
|
||||||
|
for raw_path, raw_constraint in root.items():
|
||||||
|
if not isinstance(raw_path, str):
|
||||||
|
raise RewardConfigError("constraint path 必须是字符串")
|
||||||
|
_, _, spec = _path_spec(raw_path)
|
||||||
|
constraint = _mapping(raw_constraint, raw_path)
|
||||||
|
kind = constraint.get("kind")
|
||||||
|
if kind == "fixed":
|
||||||
|
if set(constraint) != {"kind", "value"}:
|
||||||
|
raise RewardConfigError(f"{raw_path} fixed 约束只能包含 kind/value")
|
||||||
|
fixed = _number(f"{raw_path}.value", constraint["value"], spec)
|
||||||
|
result[raw_path] = {"kind": "fixed", "value": fixed}
|
||||||
|
elif kind == "range":
|
||||||
|
if set(constraint) != {"kind", "min", "max"}:
|
||||||
|
raise RewardConfigError(f"{raw_path} range 约束只能包含 kind/min/max")
|
||||||
|
minimum = _number(f"{raw_path}.min", constraint["min"], spec)
|
||||||
|
maximum = _number(f"{raw_path}.max", constraint["max"], spec)
|
||||||
|
if minimum > maximum:
|
||||||
|
raise RewardConfigError(f"{raw_path} 下限不能大于上限")
|
||||||
|
result[raw_path] = {"kind": "range", "min": minimum, "max": maximum}
|
||||||
|
else:
|
||||||
|
raise RewardConfigError(f"{raw_path}.kind 必须是 range 或 fixed")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def validate_configuration_constraints(value: Any, constraints: Any) -> None:
|
||||||
|
"""Ensure a complete reward configuration satisfies every session constraint."""
|
||||||
|
config = validate_configuration(value)
|
||||||
|
checked = validate_constraints(constraints)
|
||||||
|
for path, constraint in checked.items():
|
||||||
|
section, name, _ = _path_spec(path)
|
||||||
|
current = config[section][name]
|
||||||
|
if constraint["kind"] == "fixed":
|
||||||
|
if current != constraint["value"]:
|
||||||
|
raise RewardConfigError(
|
||||||
|
f"{path} 已固定为 {constraint['value']},不能设为 {current}"
|
||||||
|
)
|
||||||
|
elif current < constraint["min"] or current > constraint["max"]:
|
||||||
|
raise RewardConfigError(
|
||||||
|
f"{path}={current} 超出工程锁定范围 {constraint['min']}–{constraint['max']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_configuration(value: Any) -> dict[str, dict[str, float]]:
|
||||||
|
"""Validate a complete configuration and reject missing/unknown fields."""
|
||||||
|
root = _mapping(value, "rewardConfig")
|
||||||
|
if set(root) != {"weights", "params"}:
|
||||||
|
raise RewardConfigError("rewardConfig 只能包含 weights 和 params")
|
||||||
|
raw_weights = _mapping(root["weights"], "weights")
|
||||||
|
raw_params = _mapping(root["params"], "params")
|
||||||
|
if set(raw_weights) != set(WEIGHT_SPECS):
|
||||||
|
raise RewardConfigError("weights 必须完整且不能包含未知奖励项")
|
||||||
|
if set(raw_params) != set(PARAMETER_SPECS):
|
||||||
|
raise RewardConfigError("params 必须完整且不能包含未知参数")
|
||||||
|
config = {
|
||||||
|
"weights": {
|
||||||
|
name: _number(f"weights.{name}", raw_weights[name], spec)
|
||||||
|
for name, spec in WEIGHT_SPECS.items()
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
name: _number(f"params.{name}", raw_params[name], spec)
|
||||||
|
for name, spec in PARAMETER_SPECS.items()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_cross_validate(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def validate_proposal(
|
||||||
|
value: Any, previous: Any, constraints: Any | None = None
|
||||||
|
) -> dict[str, dict[str, float]]:
|
||||||
|
"""Validate a sparse Agent patch relative to a complete previous config and guardrails."""
|
||||||
|
current = validate_configuration(previous)
|
||||||
|
root = _mapping(value, "proposal")
|
||||||
|
if not set(root).issubset({"weights", "params"}):
|
||||||
|
raise RewardConfigError("proposal 只能包含 weights 和 params")
|
||||||
|
raw_weights = _mapping(root.get("weights", {}), "weights")
|
||||||
|
raw_params = _mapping(root.get("params", {}), "params")
|
||||||
|
if len(raw_weights) + len(raw_params) == 0:
|
||||||
|
raise RewardConfigError("proposal 至少需要一项修改")
|
||||||
|
if len(raw_weights) + len(raw_params) > MAX_PROPOSAL_CHANGES:
|
||||||
|
raise RewardConfigError(f"proposal 每轮最多修改 {MAX_PROPOSAL_CHANGES} 项")
|
||||||
|
unknown_weights = set(raw_weights) - set(WEIGHT_SPECS)
|
||||||
|
unknown_params = set(raw_params) - set(PARAMETER_SPECS)
|
||||||
|
if unknown_weights:
|
||||||
|
raise RewardConfigError(f"未知奖励项:{', '.join(sorted(unknown_weights))}")
|
||||||
|
if unknown_params:
|
||||||
|
raise RewardConfigError(f"未知奖励参数:{', '.join(sorted(unknown_params))}")
|
||||||
|
|
||||||
|
patch: dict[str, dict[str, float]] = {"weights": {}, "params": {}}
|
||||||
|
for name, raw in raw_weights.items():
|
||||||
|
value_number = _number(f"weights.{name}", raw, WEIGHT_SPECS[name])
|
||||||
|
old = current["weights"][name]
|
||||||
|
if old != 0.0 and value_number != 0.0:
|
||||||
|
ratio = abs(value_number / old)
|
||||||
|
if ratio < MIN_CHANGE_RATIO or ratio > MAX_CHANGE_RATIO:
|
||||||
|
raise RewardConfigError(
|
||||||
|
f"weights.{name} 单轮变化必须在旧值幅度的 "
|
||||||
|
f"{MIN_CHANGE_RATIO}×–{MAX_CHANGE_RATIO}×"
|
||||||
|
)
|
||||||
|
if value_number == old:
|
||||||
|
raise RewardConfigError(f"weights.{name} 没有发生变化")
|
||||||
|
patch["weights"][name] = value_number
|
||||||
|
for name, raw in raw_params.items():
|
||||||
|
value_number = _number(f"params.{name}", raw, PARAMETER_SPECS[name])
|
||||||
|
old = current["params"][name]
|
||||||
|
ratio = abs(value_number / old)
|
||||||
|
if ratio < MIN_CHANGE_RATIO or ratio > MAX_CHANGE_RATIO:
|
||||||
|
raise RewardConfigError(
|
||||||
|
f"params.{name} 单轮变化必须在旧值的 {MIN_CHANGE_RATIO}×–{MAX_CHANGE_RATIO}×"
|
||||||
|
)
|
||||||
|
if value_number == old:
|
||||||
|
raise RewardConfigError(f"params.{name} 没有发生变化")
|
||||||
|
patch["params"][name] = value_number
|
||||||
|
|
||||||
|
candidate = deepcopy(current)
|
||||||
|
candidate["weights"].update(patch["weights"])
|
||||||
|
candidate["params"].update(patch["params"])
|
||||||
|
_cross_validate(candidate)
|
||||||
|
if constraints is not None:
|
||||||
|
validate_configuration_constraints(candidate, constraints)
|
||||||
|
return patch
|
||||||
|
|
||||||
|
|
||||||
|
def merge_proposal(
|
||||||
|
previous: Any, proposal: Any, constraints: Any | None = None
|
||||||
|
) -> dict[str, dict[str, float]]:
|
||||||
|
current = validate_configuration(previous)
|
||||||
|
patch = validate_proposal(proposal, current, constraints)
|
||||||
|
merged = deepcopy(current)
|
||||||
|
merged["weights"].update(patch["weights"])
|
||||||
|
merged["params"].update(patch["params"])
|
||||||
|
return validate_configuration(merged)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_reward_configuration(env_cfg: Any, value: Any) -> None:
|
||||||
|
"""Apply a validated full config to a fresh mjlab environment config."""
|
||||||
|
config = validate_configuration(value)
|
||||||
|
for name, weight in config["weights"].items():
|
||||||
|
if name not in env_cfg.rewards:
|
||||||
|
raise RewardConfigError(f"环境缺少奖励项:{name}")
|
||||||
|
env_cfg.rewards[name].weight = weight
|
||||||
|
|
||||||
|
params = config["params"]
|
||||||
|
direct = {
|
||||||
|
"track_linear_velocity.std": ("track_linear_velocity", "std"),
|
||||||
|
"track_angular_velocity.std": ("track_angular_velocity", "std"),
|
||||||
|
"pose.walking_threshold": ("pose", "walking_threshold"),
|
||||||
|
"pose.running_threshold": ("pose", "running_threshold"),
|
||||||
|
"foot_gait.period": ("foot_gait", "period"),
|
||||||
|
"foot_gait.threshold": ("foot_gait", "threshold"),
|
||||||
|
"foot_gait.command_threshold": ("foot_gait", "command_threshold"),
|
||||||
|
"foot_clearance.target_height": ("foot_clearance", "target_height"),
|
||||||
|
"foot_clearance.command_threshold": ("foot_clearance", "command_threshold"),
|
||||||
|
"foot_slip.command_threshold": ("foot_slip", "command_threshold"),
|
||||||
|
"soft_landing.command_threshold": ("soft_landing", "command_threshold"),
|
||||||
|
"stand_still.command_threshold": ("stand_still", "command_threshold"),
|
||||||
|
}
|
||||||
|
for path, (term, parameter) in direct.items():
|
||||||
|
env_cfg.rewards[term].params[parameter] = params[path]
|
||||||
|
for regime in ("standing", "walking", "running"):
|
||||||
|
key = f"std_{regime}"
|
||||||
|
scale = params[f"pose.{key}_scale"]
|
||||||
|
baseline = env_cfg.rewards["pose"].params[key]
|
||||||
|
env_cfg.rewards["pose"].params[key] = {
|
||||||
|
pattern: float(std) * scale for pattern, std in baseline.items()
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Stable, reward-weight-independent evaluation scoring."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
DEFAULT_OBJECTIVE_WEIGHTS = {
|
||||||
|
"velocity_tracking": 0.35,
|
||||||
|
"action_smoothness": 0.20,
|
||||||
|
"posture_stability": 0.15,
|
||||||
|
"fall_avoidance": 0.15,
|
||||||
|
"foot_slip": 0.10,
|
||||||
|
"energy": 0.05,
|
||||||
|
}
|
||||||
|
|
||||||
|
REQUIRED_METRICS = {
|
||||||
|
"linear_velocity_rmse",
|
||||||
|
"angular_velocity_rmse",
|
||||||
|
"mean_action_acc",
|
||||||
|
"orientation_error",
|
||||||
|
"fall_rate",
|
||||||
|
"slip_velocity",
|
||||||
|
"mechanical_power",
|
||||||
|
}
|
||||||
|
|
||||||
|
PHYSICAL_FLOORS = {
|
||||||
|
"linear_velocity_rmse": 0.10,
|
||||||
|
"angular_velocity_rmse": 0.10,
|
||||||
|
"mean_action_acc": 0.01,
|
||||||
|
"orientation_error": 0.05,
|
||||||
|
"fall_rate": 0.02,
|
||||||
|
"slip_velocity": 0.05,
|
||||||
|
"mechanical_power": 10.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EvaluationError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def validate_objective_weights(value: Any) -> dict[str, float]:
|
||||||
|
if not isinstance(value, Mapping) or set(value) != set(DEFAULT_OBJECTIVE_WEIGHTS):
|
||||||
|
raise EvaluationError("objectiveWeights 必须完整包含六个目标")
|
||||||
|
result: dict[str, float] = {}
|
||||||
|
for key in DEFAULT_OBJECTIVE_WEIGHTS:
|
||||||
|
raw = value[key]
|
||||||
|
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
|
||||||
|
raise EvaluationError(f"objectiveWeights.{key} 必须是数值")
|
||||||
|
number = float(raw)
|
||||||
|
if not math.isfinite(number) or number < 0.0 or number > 1.0:
|
||||||
|
raise EvaluationError(f"objectiveWeights.{key} 必须在 0–1 之间")
|
||||||
|
result[key] = number
|
||||||
|
if not math.isclose(sum(result.values()), 1.0, abs_tol=1.0e-6):
|
||||||
|
raise EvaluationError("objectiveWeights 总和必须为 1")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def validate_metrics(value: Any) -> dict[str, float]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise EvaluationError("metrics 必须是对象")
|
||||||
|
missing = REQUIRED_METRICS - set(value)
|
||||||
|
if missing:
|
||||||
|
raise EvaluationError(f"metrics 缺少:{', '.join(sorted(missing))}")
|
||||||
|
result: dict[str, float] = {}
|
||||||
|
for key in REQUIRED_METRICS:
|
||||||
|
raw = value[key]
|
||||||
|
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
|
||||||
|
raise EvaluationError(f"metrics.{key} 必须是数值")
|
||||||
|
number = float(raw)
|
||||||
|
if not math.isfinite(number) or number < 0.0:
|
||||||
|
raise EvaluationError(f"metrics.{key} 必须是非负有限数值")
|
||||||
|
result[key] = number
|
||||||
|
if result["fall_rate"] > 1.0:
|
||||||
|
raise EvaluationError("metrics.fall_rate 必须在 0–1 之间")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _improvement(baseline: Mapping[str, float], current: Mapping[str, float], key: str) -> float:
|
||||||
|
scale = max(abs(baseline[key]), PHYSICAL_FLOORS[key])
|
||||||
|
return max(-1.0, min(1.0, (baseline[key] - current[key]) / scale))
|
||||||
|
|
||||||
|
|
||||||
|
def score_evaluation(
|
||||||
|
baseline_value: Any,
|
||||||
|
current_value: Any,
|
||||||
|
objective_weights: Any = DEFAULT_OBJECTIVE_WEIGHTS,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
baseline = validate_metrics(baseline_value)
|
||||||
|
current = validate_metrics(current_value)
|
||||||
|
weights = validate_objective_weights(objective_weights)
|
||||||
|
components = {
|
||||||
|
"velocity_tracking": 0.8 * _improvement(baseline, current, "linear_velocity_rmse")
|
||||||
|
+ 0.2 * _improvement(baseline, current, "angular_velocity_rmse"),
|
||||||
|
"action_smoothness": _improvement(baseline, current, "mean_action_acc"),
|
||||||
|
"posture_stability": _improvement(baseline, current, "orientation_error"),
|
||||||
|
"fall_avoidance": _improvement(baseline, current, "fall_rate"),
|
||||||
|
"foot_slip": _improvement(baseline, current, "slip_velocity"),
|
||||||
|
"energy": _improvement(baseline, current, "mechanical_power"),
|
||||||
|
}
|
||||||
|
tracking_limit = max(
|
||||||
|
baseline["linear_velocity_rmse"] * 1.05, baseline["linear_velocity_rmse"] + 1.0e-6
|
||||||
|
)
|
||||||
|
eligible = (
|
||||||
|
current["fall_rate"] <= baseline["fall_rate"] + 0.02
|
||||||
|
and current["linear_velocity_rmse"] <= tracking_limit
|
||||||
|
)
|
||||||
|
total = sum(weights[key] * components[key] for key in weights)
|
||||||
|
if not eligible:
|
||||||
|
total = min(total, -1.0)
|
||||||
|
return {
|
||||||
|
"eligible": eligible,
|
||||||
|
"score": total,
|
||||||
|
"components": components,
|
||||||
|
"metrics": current,
|
||||||
|
"baselineMetrics": baseline,
|
||||||
|
}
|
||||||
@@ -0,0 +1,689 @@
|
|||||||
|
"""SQLite persistence for tuning sessions, trials, proposals and scalar data."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 2
|
||||||
|
|
||||||
|
|
||||||
|
class StorageConflict(RuntimeError):
|
||||||
|
"""Optimistic-concurrency revision mismatch."""
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso() -> str:
|
||||||
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _json(value: Any) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(value: str | None) -> Any:
|
||||||
|
return json.loads(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _lttb(points: list[dict], threshold: int) -> list[dict]:
|
||||||
|
"""Largest-Triangle-Three-Buckets downsampling preserving peaks and endpoints."""
|
||||||
|
if threshold >= len(points) or threshold < 3:
|
||||||
|
return points[:threshold]
|
||||||
|
sampled = [points[0]]
|
||||||
|
bucket_width = (len(points) - 2) / (threshold - 2)
|
||||||
|
anchor_index = 0
|
||||||
|
for bucket in range(threshold - 2):
|
||||||
|
average_start = int((bucket + 1) * bucket_width) + 1
|
||||||
|
average_end = min(int((bucket + 2) * bucket_width) + 1, len(points))
|
||||||
|
average_bucket = points[average_start:average_end] or [points[-1]]
|
||||||
|
average_x = sum(point["step"] for point in average_bucket) / len(average_bucket)
|
||||||
|
average_y = sum(point["value"] for point in average_bucket) / len(average_bucket)
|
||||||
|
range_start = int(bucket * bucket_width) + 1
|
||||||
|
range_end = min(int((bucket + 1) * bucket_width) + 1, len(points) - 1)
|
||||||
|
anchor = points[anchor_index]
|
||||||
|
selected_index = range_start
|
||||||
|
maximum_area = -1.0
|
||||||
|
for index in range(range_start, max(range_start + 1, range_end)):
|
||||||
|
point = points[index]
|
||||||
|
area = abs(
|
||||||
|
(anchor["step"] - average_x) * (point["value"] - anchor["value"])
|
||||||
|
- (anchor["step"] - point["step"]) * (average_y - anchor["value"])
|
||||||
|
)
|
||||||
|
if area > maximum_area:
|
||||||
|
maximum_area = area
|
||||||
|
selected_index = index
|
||||||
|
sampled.append(points[selected_index])
|
||||||
|
anchor_index = selected_index
|
||||||
|
sampled.append(points[-1])
|
||||||
|
return sampled
|
||||||
|
|
||||||
|
|
||||||
|
class TuningStorage:
|
||||||
|
def __init__(self, path: Path):
|
||||||
|
self.path = path.expanduser().resolve()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.local = threading.local()
|
||||||
|
self._migrate()
|
||||||
|
|
||||||
|
def connection(self) -> sqlite3.Connection:
|
||||||
|
connection = getattr(self.local, "connection", None)
|
||||||
|
if connection is None:
|
||||||
|
connection = sqlite3.connect(self.path, timeout=10, isolation_level=None)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute("PRAGMA busy_timeout=10000")
|
||||||
|
self.local.connection = connection
|
||||||
|
return connection
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def transaction(self) -> Iterator[sqlite3.Connection]:
|
||||||
|
connection = self.connection()
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
yield connection
|
||||||
|
connection.execute("COMMIT")
|
||||||
|
except Exception:
|
||||||
|
connection.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _migrate(self) -> None:
|
||||||
|
connection = self.connection()
|
||||||
|
connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations(version INTEGER PRIMARY KEY);
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions(
|
||||||
|
id TEXT PRIMARY KEY, state TEXT NOT NULL, mode TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||||
|
config_json TEXT NOT NULL, objective_json TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL, current_trial_id TEXT, best_trial_id TEXT,
|
||||||
|
consecutive_no_improve INTEGER NOT NULL DEFAULT 0,
|
||||||
|
fallback_enabled INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS trials(
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
number INTEGER NOT NULL, state TEXT NOT NULL, rung INTEGER NOT NULL,
|
||||||
|
target_iterations INTEGER NOT NULL, reward_config_json TEXT NOT NULL,
|
||||||
|
proposal_id TEXT, run_dir TEXT NOT NULL, checkpoint_path TEXT,
|
||||||
|
policy_path TEXT, evaluation_json TEXT, score REAL, eligible INTEGER,
|
||||||
|
created_at TEXT NOT NULL, started_at TEXT, ended_at TEXT, message TEXT NOT NULL,
|
||||||
|
UNIQUE(session_id, number, rung)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS proposals(
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
base_trial_id TEXT, state TEXT NOT NULL, source TEXT NOT NULL,
|
||||||
|
patch_json TEXT NOT NULL, rationale TEXT NOT NULL,
|
||||||
|
expected_json TEXT, confidence REAL NOT NULL,
|
||||||
|
created_at TEXT NOT NULL, decided_at TEXT, feedback TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS metric_points(
|
||||||
|
trial_id TEXT NOT NULL REFERENCES trials(id) ON DELETE CASCADE,
|
||||||
|
tag TEXT NOT NULL, step INTEGER NOT NULL,
|
||||||
|
wall_time REAL NOT NULL, value REAL NOT NULL,
|
||||||
|
PRIMARY KEY(trial_id, tag, step)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_events(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
event_type TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS presets(
|
||||||
|
id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, session_id TEXT NOT NULL,
|
||||||
|
trial_id TEXT NOT NULL, reward_config_json TEXT NOT NULL, created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS session_controls(
|
||||||
|
session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
run_policy TEXT NOT NULL DEFAULT 'continuous'
|
||||||
|
CHECK(run_policy IN ('continuous','step')),
|
||||||
|
dispatch_tokens INTEGER NOT NULL DEFAULT 0 CHECK(dispatch_tokens >= 0),
|
||||||
|
active_base_trial_id TEXT,
|
||||||
|
revision INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS session_constraints(
|
||||||
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
path TEXT NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('range','fixed')),
|
||||||
|
min_value REAL, max_value REAL, fixed_value REAL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(session_id, path)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trials_session ON trials(session_id, number, rung);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_proposals_session ON proposals(session_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_metrics_trial_tag ON metric_points(trial_id, tag, step);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT OR IGNORE INTO session_controls(session_id) SELECT id FROM sessions"
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)", (SCHEMA_VERSION,)
|
||||||
|
)
|
||||||
|
|
||||||
|
def recover_interrupted(self) -> None:
|
||||||
|
at = now_iso()
|
||||||
|
with self.transaction() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE trials SET state='interrupted', ended_at=?, "
|
||||||
|
"message='服务重启中断,等待显式恢复' "
|
||||||
|
"WHERE state IN ('training','evaluating')",
|
||||||
|
(at,),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE sessions SET state='interrupted', updated_at=?, "
|
||||||
|
"message='服务重启中断,可从完整 checkpoint 恢复' "
|
||||||
|
"WHERE state IN ('running','evaluating')",
|
||||||
|
(at,),
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_session(self, mode: str, config: dict, objective: dict, fallback: bool) -> dict:
|
||||||
|
session_id, at = uuid.uuid4().hex, now_iso()
|
||||||
|
with self.transaction() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO sessions("
|
||||||
|
"id,state,mode,created_at,updated_at,config_json,objective_json,"
|
||||||
|
"message,fallback_enabled) "
|
||||||
|
"VALUES (?, 'queued', ?, ?, ?, ?, ?, '等待基线训练', ?)",
|
||||||
|
(session_id, mode, at, at, _json(config), _json(objective), int(fallback)),
|
||||||
|
)
|
||||||
|
connection.execute("INSERT INTO session_controls(session_id) VALUES (?)", (session_id,))
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO audit_events(session_id,event_type,payload_json,created_at) "
|
||||||
|
"VALUES (?,?,?,?)",
|
||||||
|
(session_id, "session_created", _json({"mode": mode}), at),
|
||||||
|
)
|
||||||
|
return self.get_session(session_id)
|
||||||
|
|
||||||
|
def _session(self, row: sqlite3.Row) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"state": row["state"],
|
||||||
|
"mode": row["mode"],
|
||||||
|
"createdAt": row["created_at"],
|
||||||
|
"updatedAt": row["updated_at"],
|
||||||
|
"config": _decode(row["config_json"]),
|
||||||
|
"objectiveWeights": _decode(row["objective_json"]),
|
||||||
|
"message": row["message"],
|
||||||
|
"currentTrialId": row["current_trial_id"],
|
||||||
|
"bestTrialId": row["best_trial_id"],
|
||||||
|
"consecutiveNoImprove": row["consecutive_no_improve"],
|
||||||
|
"fallbackEnabled": bool(row["fallback_enabled"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_session(self, session_id: str) -> dict:
|
||||||
|
row = (
|
||||||
|
self.connection().execute("SELECT * FROM sessions WHERE id=?", (session_id,)).fetchone()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(session_id)
|
||||||
|
return self._session(row)
|
||||||
|
|
||||||
|
def list_sessions(self, limit: int = 50) -> list[dict]:
|
||||||
|
rows = (
|
||||||
|
self.connection()
|
||||||
|
.execute("SELECT * FROM sessions ORDER BY created_at DESC LIMIT ?", (limit,))
|
||||||
|
.fetchall()
|
||||||
|
)
|
||||||
|
return [self._session(row) for row in rows]
|
||||||
|
|
||||||
|
def update_session(self, session_id: str, **changes: Any) -> bool:
|
||||||
|
columns = {
|
||||||
|
"state": "state",
|
||||||
|
"mode": "mode",
|
||||||
|
"message": "message",
|
||||||
|
"current_trial_id": "current_trial_id",
|
||||||
|
"best_trial_id": "best_trial_id",
|
||||||
|
"consecutive_no_improve": "consecutive_no_improve",
|
||||||
|
}
|
||||||
|
values, assignments = [], []
|
||||||
|
for key, value in changes.items():
|
||||||
|
if key not in columns:
|
||||||
|
raise ValueError(key)
|
||||||
|
assignments.append(f"{columns[key]}=?")
|
||||||
|
values.append(value)
|
||||||
|
assignments.append("updated_at=?")
|
||||||
|
values.extend((now_iso(), session_id))
|
||||||
|
cursor = self.connection().execute(
|
||||||
|
f"UPDATE sessions SET {', '.join(assignments)} WHERE id=?", values
|
||||||
|
)
|
||||||
|
return cursor.rowcount == 1
|
||||||
|
|
||||||
|
def get_control(self, session_id: str) -> dict:
|
||||||
|
row = (
|
||||||
|
self.connection()
|
||||||
|
.execute("SELECT * FROM session_controls WHERE session_id=?", (session_id,))
|
||||||
|
.fetchone()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(session_id)
|
||||||
|
constraint_rows = (
|
||||||
|
self.connection()
|
||||||
|
.execute(
|
||||||
|
"SELECT * FROM session_constraints WHERE session_id=? ORDER BY path", (session_id,)
|
||||||
|
)
|
||||||
|
.fetchall()
|
||||||
|
)
|
||||||
|
constraints = {}
|
||||||
|
for constraint in constraint_rows:
|
||||||
|
if constraint["kind"] == "fixed":
|
||||||
|
value = {"kind": "fixed", "value": constraint["fixed_value"]}
|
||||||
|
else:
|
||||||
|
value = {
|
||||||
|
"kind": "range",
|
||||||
|
"min": constraint["min_value"],
|
||||||
|
"max": constraint["max_value"],
|
||||||
|
}
|
||||||
|
constraints[constraint["path"]] = value
|
||||||
|
return {
|
||||||
|
"runPolicy": row["run_policy"],
|
||||||
|
"dispatchTokens": row["dispatch_tokens"],
|
||||||
|
"constraintsRevision": row["revision"],
|
||||||
|
"constraints": constraints,
|
||||||
|
"activeBaseTrialId": row["active_base_trial_id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def replace_constraints(
|
||||||
|
self, session_id: str, expected_revision: int, constraints: dict
|
||||||
|
) -> dict:
|
||||||
|
at = now_iso()
|
||||||
|
with self.transaction() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT revision FROM session_controls WHERE session_id=?", (session_id,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(session_id)
|
||||||
|
if row["revision"] != expected_revision:
|
||||||
|
raise StorageConflict(
|
||||||
|
f"参数护栏 revision 已变化(当前 {row['revision']},请求 {expected_revision})"
|
||||||
|
)
|
||||||
|
connection.execute("DELETE FROM session_constraints WHERE session_id=?", (session_id,))
|
||||||
|
for path, constraint in constraints.items():
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO session_constraints("
|
||||||
|
"session_id,path,kind,min_value,max_value,fixed_value,updated_at) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
session_id,
|
||||||
|
path,
|
||||||
|
constraint["kind"],
|
||||||
|
constraint.get("min"),
|
||||||
|
constraint.get("max"),
|
||||||
|
constraint.get("value"),
|
||||||
|
at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE session_controls SET revision=revision+1 WHERE session_id=?",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
return self.get_control(session_id)
|
||||||
|
|
||||||
|
def grant_dispatch_token(self, session_id: str) -> dict:
|
||||||
|
"""Atomically grant the sole outstanding one-Trial token."""
|
||||||
|
with self.transaction() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"UPDATE session_controls SET run_policy='step',dispatch_tokens=1 "
|
||||||
|
"WHERE session_id=? AND dispatch_tokens=0",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
exists = connection.execute(
|
||||||
|
"SELECT 1 FROM session_controls WHERE session_id=?", (session_id,)
|
||||||
|
).fetchone()
|
||||||
|
if exists is None:
|
||||||
|
raise KeyError(session_id)
|
||||||
|
raise StorageConflict("已有未消费的单步 Trial 令牌")
|
||||||
|
return self.get_control(session_id)
|
||||||
|
|
||||||
|
def use_dispatch_token(self, session_id: str) -> bool:
|
||||||
|
"""Atomically consume one step token; continuous mode never needs a token."""
|
||||||
|
with self.transaction() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT run_policy,dispatch_tokens FROM session_controls WHERE session_id=?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(session_id)
|
||||||
|
if row["run_policy"] == "continuous":
|
||||||
|
return True
|
||||||
|
if row["dispatch_tokens"] <= 0:
|
||||||
|
return False
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE session_controls SET dispatch_tokens=dispatch_tokens-1 WHERE session_id=?",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_run_policy(self, session_id: str, policy: str) -> dict:
|
||||||
|
if policy not in {"continuous", "step"}:
|
||||||
|
raise ValueError(policy)
|
||||||
|
cursor = self.connection().execute(
|
||||||
|
"UPDATE session_controls SET run_policy=?,"
|
||||||
|
"dispatch_tokens=CASE WHEN ?='continuous' THEN 0 ELSE dispatch_tokens END "
|
||||||
|
"WHERE session_id=?",
|
||||||
|
(policy, policy, session_id),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise KeyError(session_id)
|
||||||
|
return self.get_control(session_id)
|
||||||
|
|
||||||
|
def reset_step_gate(self, session_id: str) -> dict:
|
||||||
|
cursor = self.connection().execute(
|
||||||
|
"UPDATE session_controls SET run_policy='step',dispatch_tokens=0 WHERE session_id=?",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise KeyError(session_id)
|
||||||
|
return self.get_control(session_id)
|
||||||
|
|
||||||
|
def set_active_base(self, session_id: str, trial_id: str | None) -> dict:
|
||||||
|
cursor = self.connection().execute(
|
||||||
|
"UPDATE session_controls SET active_base_trial_id=? WHERE session_id=?",
|
||||||
|
(trial_id, session_id),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise KeyError(session_id)
|
||||||
|
return self.get_control(session_id)
|
||||||
|
|
||||||
|
def create_trial(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
number: int,
|
||||||
|
rung: int,
|
||||||
|
target: int,
|
||||||
|
reward_config: dict,
|
||||||
|
proposal_id: str | None,
|
||||||
|
run_dir: str,
|
||||||
|
) -> dict:
|
||||||
|
trial_id, at = uuid.uuid4().hex, now_iso()
|
||||||
|
with self.transaction() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO trials("
|
||||||
|
"id,session_id,number,state,rung,target_iterations,reward_config_json,"
|
||||||
|
"proposal_id,run_dir,created_at,message) "
|
||||||
|
"VALUES (?,?,?,'queued',?,?,?,?,?,?,'等待训练')",
|
||||||
|
(
|
||||||
|
trial_id,
|
||||||
|
session_id,
|
||||||
|
number,
|
||||||
|
rung,
|
||||||
|
target,
|
||||||
|
_json(reward_config),
|
||||||
|
proposal_id,
|
||||||
|
run_dir,
|
||||||
|
at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE sessions SET current_trial_id=?,updated_at=? WHERE id=?",
|
||||||
|
(trial_id, at, session_id),
|
||||||
|
)
|
||||||
|
return self.get_trial(trial_id)
|
||||||
|
|
||||||
|
def _trial(self, row: sqlite3.Row) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"sessionId": row["session_id"],
|
||||||
|
"number": row["number"],
|
||||||
|
"state": row["state"],
|
||||||
|
"rung": row["rung"],
|
||||||
|
"targetIterations": row["target_iterations"],
|
||||||
|
"rewardConfig": _decode(row["reward_config_json"]),
|
||||||
|
"proposalId": row["proposal_id"],
|
||||||
|
"runDir": row["run_dir"],
|
||||||
|
"checkpointPath": row["checkpoint_path"],
|
||||||
|
"policyPath": row["policy_path"],
|
||||||
|
"evaluation": _decode(row["evaluation_json"]),
|
||||||
|
"score": row["score"],
|
||||||
|
"eligible": None if row["eligible"] is None else bool(row["eligible"]),
|
||||||
|
"createdAt": row["created_at"],
|
||||||
|
"startedAt": row["started_at"],
|
||||||
|
"endedAt": row["ended_at"],
|
||||||
|
"message": row["message"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_trial(self, trial_id: str) -> dict:
|
||||||
|
row = self.connection().execute("SELECT * FROM trials WHERE id=?", (trial_id,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(trial_id)
|
||||||
|
return self._trial(row)
|
||||||
|
|
||||||
|
def list_trials(self, session_id: str) -> list[dict]:
|
||||||
|
rows = (
|
||||||
|
self.connection()
|
||||||
|
.execute("SELECT * FROM trials WHERE session_id=? ORDER BY number,rung", (session_id,))
|
||||||
|
.fetchall()
|
||||||
|
)
|
||||||
|
return [self._trial(row) for row in rows]
|
||||||
|
|
||||||
|
def delete_trial(self, trial_id: str) -> bool:
|
||||||
|
cursor = self.connection().execute(
|
||||||
|
"DELETE FROM trials WHERE id=? AND state='interrupted'", (trial_id,)
|
||||||
|
)
|
||||||
|
return cursor.rowcount == 1
|
||||||
|
|
||||||
|
def update_trial(self, trial_id: str, **changes: Any) -> bool:
|
||||||
|
columns = {
|
||||||
|
"state": "state",
|
||||||
|
"message": "message",
|
||||||
|
"checkpoint_path": "checkpoint_path",
|
||||||
|
"policy_path": "policy_path",
|
||||||
|
"score": "score",
|
||||||
|
"eligible": "eligible",
|
||||||
|
"started_at": "started_at",
|
||||||
|
"ended_at": "ended_at",
|
||||||
|
"evaluation": "evaluation_json",
|
||||||
|
}
|
||||||
|
values, assignments = [], []
|
||||||
|
for key, value in changes.items():
|
||||||
|
if key not in columns:
|
||||||
|
raise ValueError(key)
|
||||||
|
if key == "evaluation":
|
||||||
|
value = _json(value)
|
||||||
|
if key == "eligible":
|
||||||
|
value = int(value)
|
||||||
|
assignments.append(f"{columns[key]}=?")
|
||||||
|
values.append(value)
|
||||||
|
values.append(trial_id)
|
||||||
|
cursor = self.connection().execute(
|
||||||
|
f"UPDATE trials SET {', '.join(assignments)} WHERE id=?", values
|
||||||
|
)
|
||||||
|
return cursor.rowcount == 1
|
||||||
|
|
||||||
|
def create_proposal(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
base_trial_id: str | None,
|
||||||
|
patch: dict,
|
||||||
|
rationale: str,
|
||||||
|
expected: Any,
|
||||||
|
confidence: float,
|
||||||
|
source: str = "agent",
|
||||||
|
) -> dict:
|
||||||
|
proposal_id, at = uuid.uuid4().hex, now_iso()
|
||||||
|
self.connection().execute(
|
||||||
|
"INSERT INTO proposals("
|
||||||
|
"id,session_id,base_trial_id,state,source,patch_json,rationale,"
|
||||||
|
"expected_json,confidence,created_at) "
|
||||||
|
"VALUES (?,?,?,'pending',?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
proposal_id,
|
||||||
|
session_id,
|
||||||
|
base_trial_id,
|
||||||
|
source,
|
||||||
|
_json(patch),
|
||||||
|
rationale,
|
||||||
|
_json(expected),
|
||||||
|
confidence,
|
||||||
|
at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return self.get_proposal(proposal_id)
|
||||||
|
|
||||||
|
def _proposal(self, row: sqlite3.Row) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"sessionId": row["session_id"],
|
||||||
|
"baseTrialId": row["base_trial_id"],
|
||||||
|
"state": row["state"],
|
||||||
|
"source": row["source"],
|
||||||
|
"patch": _decode(row["patch_json"]),
|
||||||
|
"rationale": row["rationale"],
|
||||||
|
"expectedImpact": _decode(row["expected_json"]),
|
||||||
|
"confidence": row["confidence"],
|
||||||
|
"createdAt": row["created_at"],
|
||||||
|
"decidedAt": row["decided_at"],
|
||||||
|
"feedback": row["feedback"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_proposal(self, proposal_id: str) -> dict:
|
||||||
|
row = (
|
||||||
|
self.connection()
|
||||||
|
.execute("SELECT * FROM proposals WHERE id=?", (proposal_id,))
|
||||||
|
.fetchone()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(proposal_id)
|
||||||
|
return self._proposal(row)
|
||||||
|
|
||||||
|
def list_proposals(self, session_id: str) -> list[dict]:
|
||||||
|
rows = (
|
||||||
|
self.connection()
|
||||||
|
.execute(
|
||||||
|
"SELECT * FROM proposals WHERE session_id=? ORDER BY created_at", (session_id,)
|
||||||
|
)
|
||||||
|
.fetchall()
|
||||||
|
)
|
||||||
|
return [self._proposal(row) for row in rows]
|
||||||
|
|
||||||
|
def decide_proposal(
|
||||||
|
self, proposal_id: str, state: str, feedback: str | None, patch: dict | None = None
|
||||||
|
) -> bool:
|
||||||
|
at = now_iso()
|
||||||
|
assignments, values = ["state=?", "feedback=?", "decided_at=?"], [state, feedback, at]
|
||||||
|
if patch is not None:
|
||||||
|
assignments.append("patch_json=?")
|
||||||
|
values.append(_json(patch))
|
||||||
|
values.extend((proposal_id,))
|
||||||
|
cursor = self.connection().execute(
|
||||||
|
f"UPDATE proposals SET {', '.join(assignments)} WHERE id=? AND state='pending'", values
|
||||||
|
)
|
||||||
|
return cursor.rowcount == 1
|
||||||
|
|
||||||
|
def insert_metrics(self, trial_id: str, points: list[tuple[str, int, float, float]]) -> None:
|
||||||
|
self.connection().executemany(
|
||||||
|
"INSERT INTO metric_points(trial_id,tag,step,wall_time,value) "
|
||||||
|
"VALUES (?,?,?,?,?) ON CONFLICT(trial_id,tag,step) DO UPDATE SET "
|
||||||
|
"wall_time=excluded.wall_time,value=excluded.value",
|
||||||
|
[(trial_id, *point) for point in points],
|
||||||
|
)
|
||||||
|
|
||||||
|
def metrics(
|
||||||
|
self,
|
||||||
|
trial_id: str,
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
max_points: int = 1000,
|
||||||
|
after_step: int | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
parameters: list[Any] = [trial_id]
|
||||||
|
clause = "trial_id=?"
|
||||||
|
if tags:
|
||||||
|
clause += f" AND tag IN ({','.join('?' for _ in tags)})"
|
||||||
|
parameters.extend(tags)
|
||||||
|
if after_step is not None:
|
||||||
|
clause += " AND step>?"
|
||||||
|
parameters.append(after_step)
|
||||||
|
rows = (
|
||||||
|
self.connection()
|
||||||
|
.execute(
|
||||||
|
f"SELECT tag,step,wall_time,value FROM metric_points "
|
||||||
|
f"WHERE {clause} ORDER BY tag,step",
|
||||||
|
parameters,
|
||||||
|
)
|
||||||
|
.fetchall()
|
||||||
|
)
|
||||||
|
grouped: dict[str, list[dict]] = {}
|
||||||
|
for row in rows:
|
||||||
|
grouped.setdefault(row["tag"], []).append(
|
||||||
|
{"step": row["step"], "wallTime": row["wall_time"], "value": row["value"]}
|
||||||
|
)
|
||||||
|
series = []
|
||||||
|
for tag, values in grouped.items():
|
||||||
|
if len(values) > max_points:
|
||||||
|
values = _lttb(values, max_points)
|
||||||
|
series.append({"tag": tag, "points": values})
|
||||||
|
return series
|
||||||
|
|
||||||
|
def audit(self, session_id: str, event_type: str, payload: Any) -> None:
|
||||||
|
self.connection().execute(
|
||||||
|
"INSERT INTO audit_events(session_id,event_type,payload_json,created_at) "
|
||||||
|
"VALUES (?,?,?,?)",
|
||||||
|
(session_id, event_type, _json(payload), now_iso()),
|
||||||
|
)
|
||||||
|
|
||||||
|
def audit_events(self, session_id: str) -> list[dict]:
|
||||||
|
rows = (
|
||||||
|
self.connection()
|
||||||
|
.execute("SELECT * FROM audit_events WHERE session_id=? ORDER BY id", (session_id,))
|
||||||
|
.fetchall()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"type": row["event_type"],
|
||||||
|
"payload": _decode(row["payload_json"]),
|
||||||
|
"createdAt": row["created_at"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def save_preset(self, name: str, session_id: str, trial_id: str, reward_config: dict) -> dict:
|
||||||
|
preset_id, at = uuid.uuid4().hex, now_iso()
|
||||||
|
self.connection().execute(
|
||||||
|
"INSERT INTO presets(id,name,session_id,trial_id,reward_config_json,created_at) "
|
||||||
|
"VALUES (?,?,?,?,?,?)",
|
||||||
|
(preset_id, name, session_id, trial_id, _json(reward_config), at),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": preset_id,
|
||||||
|
"name": name,
|
||||||
|
"sessionId": session_id,
|
||||||
|
"trialId": trial_id,
|
||||||
|
"rewardConfig": reward_config,
|
||||||
|
"createdAt": at,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_preset(self, preset_id: str) -> dict:
|
||||||
|
row = self.connection().execute("SELECT * FROM presets WHERE id=?", (preset_id,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(preset_id)
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"sessionId": row["session_id"],
|
||||||
|
"trialId": row["trial_id"],
|
||||||
|
"rewardConfig": _decode(row["reward_config_json"]),
|
||||||
|
"createdAt": row["created_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_presets(self) -> list[dict]:
|
||||||
|
rows = (
|
||||||
|
self.connection().execute("SELECT * FROM presets ORDER BY created_at DESC").fetchall()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"sessionId": row["session_id"],
|
||||||
|
"trialId": row["trial_id"],
|
||||||
|
"rewardConfig": _decode(row["reward_config_json"]),
|
||||||
|
"createdAt": row["created_at"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Lazy Optuna study integration used for durable trial history and pruning metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class OptunaStudies:
|
||||||
|
def __init__(self, root: Path):
|
||||||
|
self.path = (root / "optuna.sqlite3").resolve()
|
||||||
|
self.url = f"sqlite:///{self.path}"
|
||||||
|
|
||||||
|
def _study(self, session_id: str):
|
||||||
|
import optuna
|
||||||
|
|
||||||
|
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
||||||
|
return optuna.create_study(
|
||||||
|
study_name=f"reward-tuning-{session_id}",
|
||||||
|
storage=self.url,
|
||||||
|
direction="maximize",
|
||||||
|
load_if_exists=True,
|
||||||
|
pruner=optuna.pruners.SuccessiveHalvingPruner(
|
||||||
|
min_resource=300, reduction_factor=3, min_early_stopping_rate=0
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def record(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
reward_config: dict[str, Any],
|
||||||
|
score: float,
|
||||||
|
eligible: bool,
|
||||||
|
rung: int,
|
||||||
|
) -> int:
|
||||||
|
"""Record an externally proposed Agent config through Optuna ask/tell."""
|
||||||
|
import optuna
|
||||||
|
|
||||||
|
study = self._study(session_id)
|
||||||
|
trial = study.ask()
|
||||||
|
trial.set_user_attr("reward_config", reward_config)
|
||||||
|
trial.set_user_attr("eligible", eligible)
|
||||||
|
trial.set_user_attr("rung", rung)
|
||||||
|
state = optuna.trial.TrialState.COMPLETE if eligible else optuna.trial.TrialState.PRUNED
|
||||||
|
study.tell(trial, score if eligible else None, state=state)
|
||||||
|
return trial.number
|
||||||
|
|
||||||
|
def summary(self, session_id: str) -> dict[str, Any]:
|
||||||
|
study = self._study(session_id)
|
||||||
|
completed = [trial for trial in study.trials if trial.value is not None]
|
||||||
|
return {
|
||||||
|
"studyName": study.study_name,
|
||||||
|
"trialCount": len(study.trials),
|
||||||
|
"bestValue": max((trial.value for trial in completed), default=None),
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""TensorBoard scalar ingestion with optional dependency isolation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .storage import TuningStorage
|
||||||
|
|
||||||
|
|
||||||
|
class TensorboardUnavailable(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def ingest_scalars(storage: TuningStorage, trial_id: str, log_dir: Path) -> int:
|
||||||
|
"""Reload all scalar events and idempotently upsert them into SQLite."""
|
||||||
|
try:
|
||||||
|
from tensorboard.backend.event_processing.event_accumulator import EventAccumulator
|
||||||
|
except ImportError as error:
|
||||||
|
raise TensorboardUnavailable(
|
||||||
|
"缺少 tensorboard,请安装 training_server/requirements.txt"
|
||||||
|
) from error
|
||||||
|
if not log_dir.is_dir():
|
||||||
|
return 0
|
||||||
|
accumulator = EventAccumulator(str(log_dir), size_guidance={"scalars": 0})
|
||||||
|
try:
|
||||||
|
accumulator.Reload()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return 0
|
||||||
|
points: list[tuple[str, int, float, float]] = []
|
||||||
|
for tag in accumulator.Tags().get("scalars", []):
|
||||||
|
for event in accumulator.Scalars(tag):
|
||||||
|
points.append((tag, int(event.step), float(event.wall_time), float(event.value)))
|
||||||
|
if points:
|
||||||
|
storage.insert_metrics(trial_id, points)
|
||||||
|
return len(points)
|
||||||
@@ -14,7 +14,8 @@ src/
|
|||||||
├── rl/ ONNX 策略运行时、任务绑定、类型和面板
|
├── rl/ ONNX 策略运行时、任务绑定、类型和面板
|
||||||
├── simulation/ MuJoCo 会话、物理适配器和仿真控制组件
|
├── simulation/ MuJoCo 会话、物理适配器和仿真控制组件
|
||||||
├── telemetry/ 数据源抽象、记录器、导出和数据面板
|
├── telemetry/ 数据源抽象、记录器、导出和数据面板
|
||||||
├── training/ 本地训练客户端、类型和面板
|
├── training/ 本地训练/调参客户端、类型、共享连接和面板
|
||||||
|
├── tuning/ 独立 tuning.html 的 Agent dashboard 与 scalar 图表
|
||||||
├── viewer/ Three.js 场景、渲染和交互
|
├── viewer/ Three.js 场景、渲染和交互
|
||||||
├── stores/ 跨域应用状态
|
├── stores/ 跨域应用状态
|
||||||
└── test/ 全局测试初始化
|
└── test/ 全局测试初始化
|
||||||
@@ -38,4 +39,6 @@ src/
|
|||||||
SimulationSession snapshot → app → viewer / 各业务面板
|
SimulationSession snapshot → app → viewer / 各业务面板
|
||||||
```
|
```
|
||||||
|
|
||||||
|
主工作台由 `index.html → src/main.tsx` 启动;自调参工作台由 Vite MPA 入口 `tuning.html → src/tuning/main.tsx` 启动,避免把 MuJoCo/Three.js 主应用依赖打入监控页面。两页仅通过训练 HTTP API和严格同源的短消息交接训练服务凭据/策略导入请求,不在 URL 中传 token。
|
||||||
|
|
||||||
测试文件使用 `*.test.ts(x)` 与被测模块共置;端到端测试统一保存在 `e2e/`。
|
测试文件使用 `*.test.ts(x)` 与被测模块共置;端到端测试统一保存在 `e2e/`。
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
- 内置平地、坡道、楼梯、可复现随机障碍物及 9 类系统参数化地形,可配置尺寸、摩擦、难度、种子与高度场采样精度
|
- 内置平地、坡道、楼梯、可复现随机障碍物及 9 类系统参数化地形,可配置尺寸、摩擦、难度、种子与高度场采样精度
|
||||||
- 导入单文件 `.py` 控制器,通过本地 Pyodide 在 `mj_step` 前按仿真时间同步执行
|
- 导入单文件 `.py` 控制器,通过本地 Pyodide 在 `mj_step` 前按仿真时间同步执行
|
||||||
- 导入 mjlab 导出的 `policy.onnx`,在浏览器本地执行 Go2-W 平衡/速度策略推理
|
- 导入 mjlab 导出的 `policy.onnx`,在浏览器本地执行 Go2-W 平衡/速度策略推理
|
||||||
- 从图形界面向本机训练桥接服务发起 mjlab 强化学习训练、查看进度/日志、停止任务并导入训练生成的 ONNX
|
- 从图形界面向本机训练桥接服务发起 mjlab 强化学习训练、查看进度/日志、停止任务并导入训练生成的 ONNX;可在独立 TensorBoard 风格页面运行 DeepSeek 奖励函数自调参
|
||||||
- 可配置仿真遥测记录,实时查看速度、机身姿态、位置、驱动力等指标并导出 CSV/JSON
|
- 可配置仿真遥测记录,实时查看速度、机身姿态、位置、驱动力等指标并导出 CSV/JSON
|
||||||
- FPS、物理耗时和主线程步进预算提示
|
- FPS、物理耗时和主线程步进预算提示
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ python3 -m http.server 8080 --directory web-platform-dist
|
|||||||
|
|
||||||
## 地图模块
|
## 地图模块
|
||||||
|
|
||||||
模型加载后打开右侧“地图”标签,可以选择平地、坡道、楼梯、随机障碍物、系统参数化地形或工程内地图包。参数化地形包括离散障碍、沟壑、倒金字塔阶梯、深坑、金字塔阶梯、轨道、随机粗糙、踏石和波浪地形;相同参数与随机种子会确定性生成相同碰撞层。点击“应用并重新编译”后,平台会在当前入口同目录生成临时组合 MJCF;原始工程文件不会被修改。模型编译失败时保留上一个可用仿真会话。
|
模型加载后打开右侧“地图”标签,即可使用统一地图资产库。工程地图、认证资产、内置程序地图和系统参数化地形共用一条“点击/拖放 → 轻量预览 → 场景树管理 → 一次应用”链路;任何来源的增删或参数修改都先进入同一个场景草稿,不会隐式触发 MuJoCo 编译。参数化地形包括离散障碍、沟壑、倒金字塔阶梯、深坑、金字塔阶梯、轨道、随机粗糙、踏石和波浪地形;相同参数与随机种子会确定性生成相同碰撞层。卡片可直接拖到三维画布,落点按 0.1 m 吸附;所有内置程序地图都可在视口选择、移动和绕 Z 旋转。认证资产的“自动落位(重力)”会查询程序地图真实表面,可落到坡道、楼梯、坑底或高度场,而不是固定假设 `z=0`。最后点击“一次编译应用”才组合 MJCF;编译失败时保留上一个可用仿真会话和完整草稿,普通模型重载也不会提前提交草稿。
|
||||||
|
|
||||||
工程地图由 `map.json`、静态 MJCF 碰撞层和可选的自包含 GLB 视觉层组成:
|
工程地图由 `map.json`、静态 MJCF 碰撞层和可选的自包含 GLB 视觉层组成:
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ maps/warehouse/
|
|||||||
|
|
||||||
物理地图仅允许静态 `worldbody` 以及 mesh、heightfield、texture、material 等基础 asset,不允许 joint、mocap body、actuator、sensor、include 或 default class。OBJ/STL 应使用简化碰撞模型;高精度模型只放入 GLB。GLB 必须是 2.0 自包含文件,外部 URI 会被拒绝。地图统一使用米制、Z-up、+X 前向坐标系。
|
物理地图仅允许静态 `worldbody` 以及 mesh、heightfield、texture、material 等基础 asset,不允许 joint、mocap body、actuator、sensor、include 或 default class。OBJ/STL 应使用简化碰撞模型;高精度模型只放入 GLB。GLB 必须是 2.0 自包含文件,外部 URI 会被拒绝。地图统一使用米制、Z-up、+X 前向坐标系。
|
||||||
|
|
||||||
V3 可编辑地图使用 `schemaVersion: 2`,并增加 `"authoring": { "source": "authoring/map.scene.json" }`。创作层支持方盒、圆柱、胶囊、坡道、楼梯和出生点。“场景 · 资产库”中的认证资产可点击添加或拖到画布落位;没有可编辑地图时,首个资产会立即创建 Schema V2 场景草稿,不触发 MuJoCo 重编译。新增对象支持三种放置方式:自动贴地、沿世界 `-Z` 落到最高静态承载面的自动重力落位,以及禁止位姿编辑的锁定模式。只有点击“应用并重新编译”后才提交物理层。可以通过表单或视口 TransformControls 修改位置、绕 Z 轴旋转和原语尺寸,支持移动/旋转吸附、视口拾取、复制、删除及对齐地面;`W`/`E`/`S` 切换移动、旋转和缩放工具,`Delete` 删除,`Ctrl+Z`/`Ctrl+Y` 撤销重做。编辑只更新 Three.js 草稿预览,点击编辑器内“应用并重新编译”后才生成确定性的静态 MJCF。失败时保留旧仿真和草稿。浏览器不会直接写回原目录,可使用“导出地图 ZIP”下载当前已提交地图包。没有 `authoring.source` 的 V1/V2 地图默认只读;仅由 `box`、`cylinder`、`capsule` 构成且不含 asset、材质、碰撞过滤或隐藏姿态语义的静态 MJCF,可通过“创建可编辑副本”显式升级。转换会生成 `authoring/map.scene.json`、Schema V2 描述和确定性物理层;任何不可逆语义都会导致整体拒绝,不会静默丢失内容。
|
V3 可编辑地图使用 `schemaVersion: 2`,并增加 `"authoring": { "source": "authoring/map.scene.json" }`。创作层支持方盒、圆柱、胶囊、坡道、楼梯和出生点。“场景 · 资产库”中的认证资产可点击添加或拖到画布落位;没有可编辑地图时,首个资产会创建临时 Schema V2 场景,但不触发 MuJoCo 重编译,“放弃场景更改”会同时移除临时实例和三份临时工程文件。新增对象支持自动贴地、沿世界 `-Z` 自动重力落位及锁定位姿;属性面板可编辑位置、绕 Z 旋转、原语尺寸、摩擦、颜色和透明度。工程地图源与场景实例分离:同一来源可以重复放置,实例拥有独立 XY/绕 Z 位姿,物理层、GLB 视觉层和出生点同步变换;编辑 authoring 源内容会明确同步到同源实例。视口支持吸附、拾取、空白取消选择、复制、删除及对齐地面;`W`/`E`/`S` 切换移动、旋转和缩放工具,`Delete` 删除,`Ctrl+Z`/`Ctrl+Y` 撤销重做。浏览器不会直接写回原目录,可使用“导出地图 ZIP”下载当前已提交地图包。没有 `authoring.source` 的 V1/V2 地图默认只读;仅由 `box`、`cylinder`、`capsule` 构成且不含 asset、材质、碰撞过滤或隐藏姿态语义的静态 MJCF,可通过“创建可编辑副本”显式升级;任何不可逆语义都会导致整体拒绝,不会静默丢失内容。
|
||||||
|
|
||||||
物理地图超过 2000 个 geom 会产生性能警告,超过 10000 个会被拒绝;GLB 超过 100 万三角面会警告,超过 300 万会被拒绝。当前原生 URDF 模式不支持地图,请切换到“转换为 MJCF”。
|
物理地图超过 2000 个 geom 会产生性能警告,超过 10000 个会被拒绝;GLB 超过 100 万三角面会警告,超过 300 万会被拒绝。当前原生 URDF 模式不支持地图,请切换到“转换为 MJCF”。
|
||||||
|
|
||||||
@@ -119,7 +119,9 @@ npm run training-server -- \
|
|||||||
--trainer-python /path/to/training-env/bin/python
|
--trainer-python /path/to/training-env/bin/python
|
||||||
```
|
```
|
||||||
|
|
||||||
服务启动时会在终端输出一个随机访问令牌;在界面中填写该令牌后连接。令牌仅保存在当前标签页的 `sessionStorage`。界面默认连接 `http://127.0.0.1:8765`,可选择服务端允许的任务、并行环境数、训练迭代、随机种子、CPU/GPU、GPU 编号和实验记录方式。W&B 默认为本地离线模式,无需登录或 API Key;也可完全禁用,只有明确选择在线模式时才会联网登录。训练期间页面轮询迭代进度与最近日志,可以停止任务;训练成功后点击“导入策略”,生成的 `policy.onnx` 会进入现有 ONNX 加载流程。
|
服务启动时会在终端输出一个随机访问令牌;在界面中填写该令牌后连接。令牌仅保存在当前标签页的 `sessionStorage`。界面默认连接 `http://127.0.0.1:8765`,可选择服务端允许的任务、并行环境数、训练迭代、随机种子、CPU/GPU、GPU 编号和实验记录方式。W&B 默认为本地离线模式,无需登录或 API Key;也可完全禁用,只有明确选择在线模式时才会联网登录。训练期间页面轮询迭代进度与最近日志,可以停止任务;训练成功后点击“导入策略”,生成的 `policy.onnx` 会进入现有 ONNX 加载流程。普通训练还可以选择自调参产生的命名 reward preset,而不会改写仓库默认配置。
|
||||||
|
|
||||||
|
连接服务后点击“打开自调参 Agent 工作台”会打开独立 `tuning.html`。该页面采用 Cyber-Industrial 三栏控制台:左侧展示 Session/ASHA 晋级树和 Trial 对比选择,中间使用 uPlot 叠加多 Trial 增量收敛曲线(金线标记历史最优)及六维物理评分,右侧以可折叠因果时间线展示 rationale、expected impact、置信度和评估结果。工具栏支持自动/逐轮审批切换、一次一 Trial 的调度令牌、服务端参数范围/固定值护栏、回滚历史最优或复现任意安全 Trial;Reward Merge Patch 与回滚差异由按需加载的 Monaco Diff 审查。scalar 以 1 Hz 非重入方式增量轮询,进入固定容量环形缓冲并由 `requestAnimationFrame` 合批后调用 `uPlot.setData`,不会在每次轮询时重建图表。新标签页 URL 不包含 token;同源 opener 会一次性交接凭据,直接打开页面时也可手工输入。DeepSeek key 始终由本地 Python 服务的 `DEEPSEEK_API_KEY` 环境变量读取,浏览器不会接触该 key。
|
||||||
|
|
||||||
桥接服务只监听本机回环地址,并检查 Host、Origin 和 Bearer Token;仅接受允许列表中的任务和经过范围校验的参数,不执行前端提供的 Shell 命令;一次只运行一个训练进程。默认任务使用仓库内置的 Go2 机器人资产与环境配置,**不会自动把浏览器中临时编辑的 MJCF/URDF 作为训练环境**。自定义浏览器模型训练需要在兼容的外部训练工程中注册 task,并通过服务的 `--trainer-root` 指定该工程。服务配置、接口和安全边界见 [`../training_server/README.md`](../training_server/README.md)。
|
桥接服务只监听本机回环地址,并检查 Host、Origin 和 Bearer Token;仅接受允许列表中的任务和经过范围校验的参数,不执行前端提供的 Shell 命令;一次只运行一个训练进程。默认任务使用仓库内置的 Go2 机器人资产与环境配置,**不会自动把浏览器中临时编辑的 MJCF/URDF 作为训练环境**。自定义浏览器模型训练需要在兼容的外部训练工程中注册 task,并通过服务的 `--trainer-root` 指定该工程。服务配置、接口和安全边界见 [`../training_server/README.md`](../training_server/README.md)。
|
||||||
|
|
||||||
|
|||||||
+337
-46
@@ -77,6 +77,14 @@ const LARGE_MODEL = `
|
|||||||
</worldbody>
|
</worldbody>
|
||||||
</mujoco>`;
|
</mujoco>`;
|
||||||
|
|
||||||
|
test('独立自调参工作台不需要加载 MuJoCo 主应用即可打开', async ({ page }) => {
|
||||||
|
await page.goto('/tuning.html');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Go2 奖励函数自调参 Agent' })).toBeVisible();
|
||||||
|
await expect(page.getByText('新建 Unitree-Go2-Flat 调参 Session')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('访问令牌(仅当前标签页)')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '启动自调参' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
|
test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
|
||||||
page.on('console', (message) => console.log(`[browser:${message.type()}] ${message.text()}`));
|
page.on('console', (message) => console.log(`[browser:${message.type()}] ${message.text()}`));
|
||||||
page.on('pageerror', (error) => console.log(`[browser:error] ${error.message}`));
|
page.on('pageerror', (error) => console.log(`[browser:error] ${error.message}`));
|
||||||
@@ -137,6 +145,7 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
|
||||||
await page.getByRole('button', { name: '显示设置' }).click();
|
await page.getByRole('button', { name: '显示设置' }).click();
|
||||||
const displayDialog = page.getByRole('dialog', { name: '视图显示设置' });
|
const displayDialog = page.getByRole('dialog', { name: '视图显示设置' });
|
||||||
await expect(displayDialog).toBeVisible();
|
await expect(displayDialog).toBeVisible();
|
||||||
@@ -154,16 +163,22 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: /FPS .*物理/ }).click();
|
await page.getByRole('button', { name: /FPS .*物理/ }).click();
|
||||||
await expect(page.getByRole('dialog', { name: '性能详情' })).toBeVisible();
|
await expect(page.getByRole('dialog', { name: '性能详情' })).toBeVisible();
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await page.getByRole('tab', { name: '控制' }).click();
|
await page.getByRole('tab', { name: '控制台' }).click();
|
||||||
await page.getByRole('button', { name: 'Actuator' }).click();
|
const tools = page.getByRole('tabpanel', { name: '控制台' });
|
||||||
await expect(page.getByText('motor', { exact: true })).toBeVisible();
|
await expect(page.getByRole('dialog', { name: '工作区工具' })).toHaveCount(0);
|
||||||
await expect(
|
await expect(page.locator('main canvas')).toBeVisible();
|
||||||
page.getByRole('tabpanel', { name: '控制' }).getByText('slide', { exact: true }),
|
await tools.getByRole('button', { name: /执行器实时控制/ }).click();
|
||||||
).toBeVisible();
|
await expect(tools.getByText('motor', { exact: true })).toBeVisible();
|
||||||
await page.getByRole('tab', { name: '模型结构' }).click();
|
await expect(tools.getByText('关节:slide', { exact: true })).toBeVisible();
|
||||||
|
await page.getByRole('tab', { name: '数据录制' }).click();
|
||||||
|
await expect(page.getByRole('tabpanel', { name: '数据录制' })).toContainText('仿真遥测记录');
|
||||||
|
await expect(page.locator('main canvas')).toBeVisible();
|
||||||
|
await page.getByRole('tab', { name: '检查器' }).click();
|
||||||
const structure = page.getByRole('navigation', { name: '模型结构树' });
|
const structure = page.getByRole('navigation', { name: '模型结构树' });
|
||||||
await expect(structure).toBeVisible();
|
await expect(structure).toBeVisible();
|
||||||
await structure.getByRole('treeitem', { name: /hinge/ }).hover();
|
const hinge = structure.getByRole('treeitem', { name: /hinge/ });
|
||||||
|
await hinge.hover();
|
||||||
|
await hinge.click();
|
||||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||||
await expect(page.getByRole('button', { name: '重置关节' })).toBeVisible();
|
await expect(page.getByRole('button', { name: '重置关节' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: '高级' }).click();
|
await page.getByRole('button', { name: '高级' }).click();
|
||||||
@@ -187,10 +202,10 @@ test('窄视口默认保留完整视口并可按需打开侧栏', async ({ page
|
|||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('main')).toBeInViewport();
|
await expect(page.getByRole('main')).toBeInViewport();
|
||||||
await expect(page.getByRole('button', { name: '显示工程面板' })).toBeVisible();
|
await expect(page.getByRole('button', { name: '显示工程面板' })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: '显示属性面板' })).toBeVisible();
|
await expect(page.getByRole('button', { name: '显示右侧面板' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: '显示属性面板' }).click();
|
await page.getByRole('button', { name: '显示右侧面板' }).click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('complementary').filter({ hasText: '导入模型后显示属性' }),
|
page.getByRole('complementary').filter({ hasText: '导入模型后显示检查器' }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -288,7 +303,6 @@ test('加载引用 OBJ 的 URDF 工程', async ({ page }) => {
|
|||||||
await options.getByRole('button', { name: '转换并加载' }).click();
|
await options.getByRole('button', { name: '转换并加载' }).click();
|
||||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(page.getByText('2 个文件')).toBeVisible();
|
await expect(page.getByText('2 个文件')).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'URDF 处理方式' }).click();
|
|
||||||
await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf');
|
await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf');
|
||||||
await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating');
|
await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating');
|
||||||
await page.getByRole('button', { name: '通知中心' }).click();
|
await page.getByRole('button', { name: '通知中心' }).click();
|
||||||
@@ -336,8 +350,11 @@ test('URDF 自动生成的关节驱动器与摄像头可通过 MuJoCo 编译', a
|
|||||||
await expect(notifications).toContainText('已为 1 个 hinge/slide 关节生成 motor 驱动器');
|
await expect(notifications).toContainText('已为 1 个 hinge/slide 关节生成 motor 驱动器');
|
||||||
await expect(notifications).toContainText('已将 640×480 摄像头固连到 arm');
|
await expect(notifications).toContainText('已将 640×480 摄像头固连到 arm');
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await page.getByRole('tab', { name: '控制' }).click();
|
await page.getByRole('tab', { name: '控制台' }).click();
|
||||||
await page.getByRole('button', { name: 'Actuator' }).click();
|
await page
|
||||||
|
.getByRole('tabpanel', { name: '控制台' })
|
||||||
|
.getByRole('button', { name: /执行器实时控制/ })
|
||||||
|
.click();
|
||||||
await expect(page.getByText('shoulder_motor')).toBeVisible();
|
await expect(page.getByText('shoulder_motor')).toBeVisible();
|
||||||
await expect(page.getByText('关节:shoulder')).toBeVisible();
|
await expect(page.getByText('关节:shoulder')).toBeVisible();
|
||||||
await expect(page.getByText('N·m', { exact: true })).toBeVisible();
|
await expect(page.getByText('N·m', { exact: true })).toBeVisible();
|
||||||
@@ -405,8 +422,10 @@ test('slide 关节向屏幕轴正方向拖动时 qpos 同向增加', async ({ pa
|
|||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.mouse.move(x + 70, y, { steps: 8 });
|
await page.mouse.move(x + 70, y, { steps: 8 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
await page.getByRole('tab', { name: '控制' }).click();
|
await page.getByRole('tab', { name: '控制台' }).click();
|
||||||
const jointSection = page.getByRole('button', { name: '关节 1' });
|
const jointSection = page
|
||||||
|
.getByRole('tabpanel', { name: '控制台' })
|
||||||
|
.getByRole('button', { name: /关节姿态调试/ });
|
||||||
if ((await jointSection.getAttribute('aria-expanded')) === 'false') await jointSection.click();
|
if ((await jointSection.getAttribute('aria-expanded')) === 'false') await jointSection.click();
|
||||||
const output = page.getByText('screen_x').locator('..').locator('output');
|
const output = page.getByText('screen_x').locator('..').locator('output');
|
||||||
await expect
|
await expect
|
||||||
@@ -421,7 +440,11 @@ test('可导入并启用 Python 控制器', async ({ page }) => {
|
|||||||
.first()
|
.first()
|
||||||
.setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) });
|
.setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) });
|
||||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||||
await page.getByRole('tab', { name: '控制' }).click();
|
await page.getByRole('tab', { name: '控制台' }).click();
|
||||||
|
await page
|
||||||
|
.getByRole('tabpanel', { name: '控制台' })
|
||||||
|
.getByRole('button', { name: /Python 脚本控制/ })
|
||||||
|
.click();
|
||||||
const python = `NAME = "测试 PD 控制器"\nCONTROL_HZ = 100\ndef init(api):\n return {"joint": api.joint("slide"), "actuator": api.actuator("motor"), "body": api.body("box")}\ndef step(ctx, state):\n assert len(ctx.body_quat(state["body"])) == 4\n assert len(ctx.body_position(state["body"])) == 3\n ctx.set_control(state["actuator"], -ctx.qpos(state["joint"]) - 0.1 * ctx.qvel(state["joint"]))\n`;
|
const python = `NAME = "测试 PD 控制器"\nCONTROL_HZ = 100\ndef init(api):\n return {"joint": api.joint("slide"), "actuator": api.actuator("motor"), "body": api.body("box")}\ndef step(ctx, state):\n assert len(ctx.body_quat(state["body"])) == 4\n assert len(ctx.body_position(state["body"])) == 3\n ctx.set_control(state["actuator"], -ctx.qpos(state["joint"]) - 0.1 * ctx.qvel(state["joint"]))\n`;
|
||||||
await page
|
await page
|
||||||
.locator('input[accept=".py,text/x-python"]')
|
.locator('input[accept=".py,text/x-python"]')
|
||||||
@@ -429,7 +452,9 @@ test('可导入并启用 Python 控制器', async ({ page }) => {
|
|||||||
await expect(page.getByText('测试 PD 控制器', { exact: true })).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText('测试 PD 控制器', { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(page.getByText('Python / Pyodide')).toBeVisible();
|
await expect(page.getByText('Python / Pyodide')).toBeVisible();
|
||||||
await page.getByRole('button', { name: '启用', exact: true }).click();
|
await page.getByRole('button', { name: '启用', exact: true }).click();
|
||||||
await expect(page.getByText('运行中')).toBeVisible();
|
await expect(
|
||||||
|
page.getByRole('tabpanel', { name: '控制台' }).getByRole('button', { name: /Python 脚本控制/ }),
|
||||||
|
).toContainText('运行');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('中等规模模型持续步进并可重复加载', async ({ page }) => {
|
test('中等规模模型持续步进并可重复加载', async ({ page }) => {
|
||||||
@@ -471,16 +496,15 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page })
|
|||||||
buffer: Buffer.from(SIMPLE_MODEL),
|
buffer: Buffer.from(SIMPLE_MODEL),
|
||||||
});
|
});
|
||||||
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
|
||||||
await page.getByRole('tab', { name: '地图' }).click();
|
|
||||||
|
|
||||||
const library = page.getByLabel('认证资产');
|
const library = page.getByLabel('地图资产库');
|
||||||
await expect(library.getByText('点击添加,或按住资产拖到画布落位。')).toBeVisible();
|
await expect(library.getByText('物理几何原语', { exact: true })).toBeVisible();
|
||||||
await library.getByRole('button', { name: '添加基础方盒' }).click();
|
await library.getByRole('button', { name: '添加基础方盒' }).click();
|
||||||
await expect(page.getByText('正在加载 MuJoCo 与模型…')).toHaveCount(0);
|
await expect(page.getByText('正在加载 MuJoCo 与模型…')).toHaveCount(0);
|
||||||
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json', {
|
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json', {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(page.getByText('V3 地图编辑器')).toBeVisible();
|
await expect(page.getByLabel('地图物体检查器')).toBeVisible();
|
||||||
await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
|
await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
|
||||||
|
|
||||||
await page.locator('[data-map-asset="ramp"]').dragTo(page.locator('main canvas'));
|
await page.locator('[data-map-asset="ramp"]').dragTo(page.locator('main canvas'));
|
||||||
@@ -488,9 +512,9 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page })
|
|||||||
await expect(page.getByText('地图草稿尚未应用')).toBeVisible();
|
await expect(page.getByText('地图草稿尚未应用')).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒').click();
|
await page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒').click();
|
||||||
await page.getByLabel('对象放置方式').selectOption('locked');
|
await page.getByLabel('贴地检测模式').selectOption('locked');
|
||||||
await expect(page.getByLabel('对象位置X')).toBeDisabled();
|
await expect(page.getByLabel('对象位置X')).toBeDisabled();
|
||||||
await page.getByLabel('对象放置方式').selectOption('auto_ground');
|
await page.getByLabel('贴地检测模式').selectOption('auto_ground');
|
||||||
await expect(page.getByLabel('对象位置X')).toBeEnabled();
|
await expect(page.getByLabel('对象位置X')).toBeEnabled();
|
||||||
|
|
||||||
const gizmoLine = page.getByRole('img', { name: 'XYZ 方向指示器' }).locator('line').first();
|
const gizmoLine = page.getByRole('img', { name: 'XYZ 方向指示器' }).locator('line').first();
|
||||||
@@ -502,6 +526,236 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page })
|
|||||||
await page.mouse.move(canvasBox!.x + 104, canvasBox!.y + 50, { steps: 8 });
|
await page.mouse.move(canvasBox!.x + 104, canvasBox!.y + 50, { steps: 8 });
|
||||||
await page.mouse.up({ button: 'left' });
|
await page.mouse.up({ button: 'left' });
|
||||||
await expect.poll(() => gizmoLine.getAttribute('x2')).not.toBe(beforeRotation);
|
await expect.poll(() => gizmoLine.getAttribute('x2')).not.toBe(beforeRotation);
|
||||||
|
await expect(page.getByLabel('对象名称')).toHaveCount(0);
|
||||||
|
await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('认证资产自动打开地图属性并与参数地形一次编译', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await page
|
||||||
|
.locator('input[type="file"]')
|
||||||
|
.first()
|
||||||
|
.setInputFiles({
|
||||||
|
name: 'unified-map-model.xml',
|
||||||
|
mimeType: 'text/xml',
|
||||||
|
buffer: Buffer.from(SIMPLE_MODEL),
|
||||||
|
});
|
||||||
|
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const library = page.getByLabel('地图资产库');
|
||||||
|
await library.getByRole('button', { name: '添加基础方盒' }).click();
|
||||||
|
await expect(page.getByText('Map / Object')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('地图物体检查器')).toBeVisible();
|
||||||
|
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
|
||||||
|
|
||||||
|
const sceneTree = page.getByLabel('场景资产树');
|
||||||
|
await expect(sceneTree.getByText('基础方盒')).toBeVisible();
|
||||||
|
await sceneTree.getByRole('treeitem', { name: 'box', exact: true }).click();
|
||||||
|
await expect(page.getByText('Robot / Body')).toBeVisible();
|
||||||
|
await sceneTree.getByRole('treeitem', { name: /基础方盒/ }).click();
|
||||||
|
await expect(page.getByText('Map / Object')).toBeVisible();
|
||||||
|
|
||||||
|
await library.getByRole('button', { name: '添加随机粗糙地形' }).click();
|
||||||
|
await page.getByLabel('位置 X(m)').fill('4');
|
||||||
|
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
|
||||||
|
|
||||||
|
// 切换到参数地形后,尚未编译的认证资产仍保留在画布中并可直接选回。
|
||||||
|
const draftCanvasBox = await page.locator('main canvas').first().boundingBox();
|
||||||
|
expect(draftCanvasBox).not.toBeNull();
|
||||||
|
let selectedDraftAsset = false;
|
||||||
|
for (let y = 0.15; y <= 0.85 && !selectedDraftAsset; y += 0.05) {
|
||||||
|
for (let x = 0.05; x <= 0.95; x += 0.05) {
|
||||||
|
await page.mouse.click(
|
||||||
|
draftCanvasBox!.x + draftCanvasBox!.width * x,
|
||||||
|
draftCanvasBox!.y + draftCanvasBox!.height * y,
|
||||||
|
);
|
||||||
|
await page.waitForTimeout(20);
|
||||||
|
if (await page.getByLabel('对象名称').count()) {
|
||||||
|
selectedDraftAsset = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(selectedDraftAsset).toBe(true);
|
||||||
|
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json');
|
||||||
|
await sceneTree.getByRole('treeitem', { name: /随机粗糙地形/ }).click();
|
||||||
|
await expect(page.getByLabel('地图来源')).toHaveValue('builtin');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '应用场景' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
|
||||||
|
await expect(page.getByText(/已加载工程地图“场景 1”(1 个物理几何/)).toBeVisible();
|
||||||
|
await expect(page.getByText(/已加载随机粗糙地形物理地图/)).toBeVisible();
|
||||||
|
await expect(page.getByLabel('地图来源')).toHaveValue('builtin');
|
||||||
|
|
||||||
|
// 当前仍选中参数地形时,直接点已编译的认证资产也必须反查场景与对象,首次点击即挂载操纵器。
|
||||||
|
const canvasBox = await page.locator('main canvas').first().boundingBox();
|
||||||
|
expect(canvasBox).not.toBeNull();
|
||||||
|
let selectedCompiledAsset = false;
|
||||||
|
for (let y = 0.15; y <= 0.85 && !selectedCompiledAsset; y += 0.05) {
|
||||||
|
for (let x = 0.05; x <= 0.95; x += 0.05) {
|
||||||
|
await page.mouse.click(
|
||||||
|
canvasBox!.x + canvasBox!.width * x,
|
||||||
|
canvasBox!.y + canvasBox!.height * y,
|
||||||
|
);
|
||||||
|
await page.waitForTimeout(20);
|
||||||
|
if (await page.getByLabel('对象名称').count()) {
|
||||||
|
selectedCompiledAsset = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(selectedCompiledAsset).toBe(true);
|
||||||
|
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json');
|
||||||
|
await expect(page.getByLabel('对象名称')).toHaveValue('基础方盒');
|
||||||
|
await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
|
||||||
|
await expect(page.getByText('地图草稿尚未应用')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('放弃首次认证资产会完整回滚临时场景和工程文件', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await page
|
||||||
|
.locator('input[type="file"]')
|
||||||
|
.first()
|
||||||
|
.setInputFiles({
|
||||||
|
name: 'discard-map-model.xml',
|
||||||
|
mimeType: 'text/xml',
|
||||||
|
buffer: Buffer.from(SIMPLE_MODEL),
|
||||||
|
});
|
||||||
|
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const library = page.getByLabel('地图资产库');
|
||||||
|
await library.getByRole('button', { name: '添加基础方盒' }).click();
|
||||||
|
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('场景资产树')).toContainText('场景 1');
|
||||||
|
await page.getByRole('button', { name: '放弃更改' }).click();
|
||||||
|
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
|
||||||
|
await expect(page.getByLabel('场景资产树')).not.toContainText('场景 1');
|
||||||
|
await expect(library.getByText('场景 1')).toHaveCount(0);
|
||||||
|
|
||||||
|
// 文件和 map id 也必须回滚;再次创建应复用 scene_1,而不是泄漏出 scene_2。
|
||||||
|
await library.getByRole('button', { name: '添加基础方盒' }).click();
|
||||||
|
await expect(page.getByLabel('场景资产树')).toContainText('场景 1');
|
||||||
|
await expect(page.getByLabel('场景资产树')).not.toContainText('场景 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('工程地图与参数地形共享放置草稿、实例变换和回滚入口', async ({ page }) => {
|
||||||
|
const mapJson = JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
id: 'warehouse-draft',
|
||||||
|
name: '草稿仓库',
|
||||||
|
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||||
|
physics: { source: 'physics/world.xml' },
|
||||||
|
spawnPoints: [],
|
||||||
|
});
|
||||||
|
const project = zipSync({
|
||||||
|
'model.xml': Buffer.from(SIMPLE_MODEL),
|
||||||
|
'maps/warehouse/map.json': Buffer.from(mapJson),
|
||||||
|
'maps/warehouse/physics/world.xml': Buffer.from(
|
||||||
|
'<mujoco><worldbody><geom name="wall" type="box" pos="0 0 .5" size=".1 1 .5"/></worldbody></mujoco>',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
await page.goto('/');
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await page
|
||||||
|
.locator('input[type="file"]')
|
||||||
|
.first()
|
||||||
|
.setInputFiles({
|
||||||
|
name: 'unified-project-map.zip',
|
||||||
|
mimeType: 'application/zip',
|
||||||
|
buffer: Buffer.from(project),
|
||||||
|
});
|
||||||
|
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const library = page.getByLabel('地图资产库');
|
||||||
|
await library.getByRole('button', { name: '放置工程地图 草稿仓库' }).click();
|
||||||
|
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('场景资产树')).toContainText('待应用');
|
||||||
|
await expect(page.getByText('正在准备仿真')).toHaveCount(0);
|
||||||
|
await page.getByLabel('位置 X(m)').fill('3');
|
||||||
|
await page.getByLabel('位置 Y(m)').fill('-2');
|
||||||
|
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
|
||||||
|
|
||||||
|
await library.getByRole('button', { name: '添加波浪地形' }).click();
|
||||||
|
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: '应用场景' }).click();
|
||||||
|
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
|
||||||
|
await expect(page.getByText(/已加载工程地图“草稿仓库”/)).toBeVisible();
|
||||||
|
await expect(page.getByText(/已加载波浪地形物理地图/)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '删除地图实例 草稿仓库' }).click();
|
||||||
|
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('场景资产树')).not.toContainText('草稿仓库');
|
||||||
|
await page.getByRole('button', { name: '放弃更改' }).click();
|
||||||
|
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
|
||||||
|
await expect(page.getByLabel('场景资产树')).toContainText('草稿仓库');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('认证资产重力放置使用参数地形的真实承载高度', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await page
|
||||||
|
.locator('input[type="file"]')
|
||||||
|
.first()
|
||||||
|
.setInputFiles({
|
||||||
|
name: 'terrain-surface-model.xml',
|
||||||
|
mimeType: 'text/xml',
|
||||||
|
buffer: Buffer.from(SIMPLE_MODEL),
|
||||||
|
});
|
||||||
|
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const library = page.getByLabel('地图资产库');
|
||||||
|
await library.getByRole('button', { name: '添加深坑地形' }).click();
|
||||||
|
await page.getByLabel('地形难度(0–1)').fill('1');
|
||||||
|
await library.getByLabel('新增资产放置方式').selectOption('gravity');
|
||||||
|
await library.getByRole('button', { name: '添加基础方盒' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByLabel('贴地检测模式')).toHaveValue('gravity');
|
||||||
|
await expect(page.getByLabel('对象位置Z')).toHaveValue('-0.3');
|
||||||
|
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('参数化地形可连续拖到画布并一次性编译', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await page
|
||||||
|
.locator('input[type="file"]')
|
||||||
|
.first()
|
||||||
|
.setInputFiles({
|
||||||
|
name: 'terrain-draft-model.xml',
|
||||||
|
mimeType: 'text/xml',
|
||||||
|
buffer: Buffer.from(SIMPLE_MODEL),
|
||||||
|
});
|
||||||
|
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.locator('[data-system-terrain="rough"]').dragTo(page.locator('main canvas'));
|
||||||
|
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
|
||||||
|
await expect(page.getByText('正在准备仿真')).toHaveCount(0);
|
||||||
|
await page.locator('[data-system-terrain="wave"]').dragTo(page.locator('main canvas'));
|
||||||
|
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('场景资产树')).toContainText('随机粗糙地形');
|
||||||
|
await expect(page.getByLabel('场景资产树')).toContainText('波浪地形');
|
||||||
|
|
||||||
|
await page.getByLabel('场景资产树').getByRole('treeitem', { name: 'box' }).click();
|
||||||
|
await expect(page.getByText('Robot / Body')).toBeVisible();
|
||||||
|
const canvasBox = await page.locator('main canvas').first().boundingBox();
|
||||||
|
expect(canvasBox).not.toBeNull();
|
||||||
|
await page.mouse.click(
|
||||||
|
canvasBox!.x + canvasBox!.width * 0.5,
|
||||||
|
canvasBox!.y + canvasBox!.height * 0.78,
|
||||||
|
);
|
||||||
|
await expect(page.getByText('Map / Instance')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('地图视口工具')).toBeVisible();
|
||||||
|
await expect(page.getByText('地图与对象属性')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '应用场景' }).click();
|
||||||
|
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
|
||||||
|
await expect(page.getByText(/已加载随机粗糙地形物理地图/)).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByText(/已加载波浪地形物理地图/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('应用内置 MJCF 楼梯物理地图', async ({ page }) => {
|
test('应用内置 MJCF 楼梯物理地图', async ({ page }) => {
|
||||||
@@ -516,7 +770,6 @@ test('应用内置 MJCF 楼梯物理地图', async ({ page }) => {
|
|||||||
buffer: Buffer.from(SIMPLE_MODEL),
|
buffer: Buffer.from(SIMPLE_MODEL),
|
||||||
});
|
});
|
||||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||||
await page.getByRole('tab', { name: '地图' }).click();
|
|
||||||
await page.getByLabel('地图来源').selectOption('builtin');
|
await page.getByLabel('地图来源').selectOption('builtin');
|
||||||
await page.getByLabel('物理地图预设').selectOption('stairs');
|
await page.getByLabel('物理地图预设').selectOption('stairs');
|
||||||
await page.getByLabel('台阶数量').fill('6');
|
await page.getByLabel('台阶数量').fill('6');
|
||||||
@@ -548,7 +801,6 @@ test('依次应用全部系统参数化地形', async ({ page }) => {
|
|||||||
buffer: Buffer.from(SIMPLE_MODEL),
|
buffer: Buffer.from(SIMPLE_MODEL),
|
||||||
});
|
});
|
||||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||||
await page.getByRole('tab', { name: '地图' }).click();
|
|
||||||
await page.getByLabel('地图来源').selectOption('builtin');
|
await page.getByLabel('地图来源').selectOption('builtin');
|
||||||
for (const [preset, label] of terrains) {
|
for (const [preset, label] of terrains) {
|
||||||
await page.getByLabel('物理地图预设').selectOption(preset);
|
await page.getByLabel('物理地图预设').selectOption(preset);
|
||||||
@@ -592,7 +844,6 @@ test('导入并应用分层工程地图包', async ({ page }) => {
|
|||||||
buffer: Buffer.from(project),
|
buffer: Buffer.from(project),
|
||||||
});
|
});
|
||||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||||
await page.getByRole('tab', { name: '地图' }).click();
|
|
||||||
await page.getByLabel('地图来源').selectOption({ label: '测试场景' });
|
await page.getByLabel('地图来源').selectOption({ label: '测试场景' });
|
||||||
await expect(page.getByLabel('地图出生点')).toHaveValue('start');
|
await expect(page.getByLabel('地图出生点')).toHaveValue('start');
|
||||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||||
@@ -627,14 +878,15 @@ test('将受支持的只读物理地图转换为可编辑副本', async ({ page
|
|||||||
buffer: Buffer.from(project),
|
buffer: Buffer.from(project),
|
||||||
});
|
});
|
||||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||||
await page.getByRole('tab', { name: '地图' }).click();
|
|
||||||
await page.getByLabel('地图来源').selectOption({ label: '旧版基础场景' });
|
await page.getByLabel('地图来源').selectOption({ label: '旧版基础场景' });
|
||||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||||
const editor = page.getByText('V3 地图编辑器').locator('..');
|
const editor = page.getByText('认证资产与场景对象属性').locator('..');
|
||||||
await expect(editor.getByText(/保持只读/)).toBeVisible({ timeout: 30_000 });
|
await expect(editor.getByText(/保持只读/)).toBeVisible({ timeout: 30_000 });
|
||||||
await editor.getByRole('button', { name: '创建可编辑副本' }).click();
|
await editor.getByRole('button', { name: '创建可编辑副本' }).click();
|
||||||
await expect(page.getByText('已创建可编辑地图副本')).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText('已创建可编辑地图副本')).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(editor.getByRole('button', { name: '移动工具 W' })).toBeVisible();
|
await expect(
|
||||||
|
page.getByLabel('地图视口工具').getByRole('button', { name: '移动工具 W' }),
|
||||||
|
).toBeVisible();
|
||||||
await expect(editor.getByRole('button', { name: /floor · 方盒/ })).toBeVisible();
|
await expect(editor.getByRole('button', { name: /floor · 方盒/ })).toBeVisible();
|
||||||
await expect(editor.getByRole('button', { name: /wall · 方盒/ })).toBeVisible();
|
await expect(editor.getByRole('button', { name: /wall · 方盒/ })).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -673,30 +925,56 @@ test('编辑 V3 地图对象并事务式应用', async ({ page }) => {
|
|||||||
buffer: Buffer.from(project),
|
buffer: Buffer.from(project),
|
||||||
});
|
});
|
||||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||||
await page.getByRole('tab', { name: '地图' }).click();
|
|
||||||
await page.getByLabel('地图来源').selectOption({ label: '可编辑场景' });
|
await page.getByLabel('地图来源').selectOption({ label: '可编辑场景' });
|
||||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||||
await expect(page.getByText('V3 地图编辑器')).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText('认证资产与场景对象属性')).toBeVisible({ timeout: 30_000 });
|
||||||
const editor = page.getByText('V3 地图编辑器').locator('..');
|
const mapTools = page.getByLabel('地图视口工具');
|
||||||
await expect(editor.getByRole('button', { name: '移动工具 W' })).toHaveAttribute(
|
await expect(mapTools.getByRole('button', { name: '移动工具 W' })).toHaveAttribute(
|
||||||
'aria-pressed',
|
'aria-pressed',
|
||||||
'true',
|
'true',
|
||||||
);
|
);
|
||||||
await page.keyboard.press('e');
|
await page.keyboard.press('e');
|
||||||
await expect(editor.getByRole('button', { name: '旋转工具 E' })).toHaveAttribute(
|
await expect(mapTools.getByRole('button', { name: '旋转工具 E' })).toHaveAttribute(
|
||||||
'aria-pressed',
|
'aria-pressed',
|
||||||
'true',
|
'true',
|
||||||
);
|
);
|
||||||
await page.keyboard.press('s');
|
await page.keyboard.press('r');
|
||||||
await expect(editor.getByRole('button', { name: '缩放工具 S' })).toHaveAttribute(
|
await expect(mapTools.getByRole('button', { name: '缩放工具 R' })).toHaveAttribute(
|
||||||
'aria-pressed',
|
'aria-pressed',
|
||||||
'true',
|
'true',
|
||||||
);
|
);
|
||||||
await editor.getByRole('button', { name: '新增', exact: true }).click();
|
await page.getByRole('button', { name: '新增', exact: true }).click();
|
||||||
await editor.getByLabel('对象位置X').fill('2');
|
await page.getByLabel('对象位置X').fill('2');
|
||||||
await editor.getByRole('button', { name: '应用并重新编译' }).click();
|
await page.getByLabel('对象位置X').blur();
|
||||||
await expect(editor.getByRole('button', { name: /box · 方盒/ })).toBeVisible({ timeout: 30_000 });
|
await page.keyboard.press('f');
|
||||||
await expect(editor.getByText('地图草稿尚未应用')).toHaveCount(0);
|
const draftStatus = page.getByLabel('地图草稿状态');
|
||||||
|
await expect(draftStatus).toContainText('未保存改动');
|
||||||
|
await page.keyboard.press('Control+s');
|
||||||
|
await expect(
|
||||||
|
page.getByLabel('地图对象列表').getByRole('button', { name: /box · 方盒/ }),
|
||||||
|
).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(draftStatus).toContainText('地图草稿已同步');
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByLabel('地图对象列表')
|
||||||
|
.getByRole('button', { name: /box · 方盒/ })
|
||||||
|
.click();
|
||||||
|
await page.getByLabel('对象位置X').fill('3');
|
||||||
|
await expect(draftStatus).toContainText('未保存改动');
|
||||||
|
await draftStatus.getByRole('button', { name: '丢弃地图草稿' }).click();
|
||||||
|
await page
|
||||||
|
.getByLabel('地图对象列表')
|
||||||
|
.getByRole('button', { name: /box · 方盒/ })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByLabel('对象位置X')).toHaveValue('2');
|
||||||
|
await expect(draftStatus).toContainText('地图草稿已同步');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '新增', exact: true }).click();
|
||||||
|
await expect(page.getByLabel('地图对象列表').getByRole('button')).toHaveCount(2);
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await expect(page.getByLabel('地图对象列表').getByRole('button')).toHaveCount(1);
|
||||||
await expect(page.getByText('WASM 已加载')).toBeVisible();
|
await expect(page.getByText('WASM 已加载')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -727,15 +1005,28 @@ test('工程地图编译失败时保留上一仿真会话', async ({ page }) =>
|
|||||||
buffer: Buffer.from(project),
|
buffer: Buffer.from(project),
|
||||||
});
|
});
|
||||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||||
await page.getByRole('tab', { name: '地图' }).click();
|
await page.getByRole('button', { name: '▶ 播放' }).click();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
const runningTime = Number(
|
||||||
|
(await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0,
|
||||||
|
);
|
||||||
await page.getByLabel('地图来源').selectOption({ label: '动态错误地图' });
|
await page.getByLabel('地图来源').selectOption({ label: '动态错误地图' });
|
||||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||||
await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 });
|
await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 });
|
||||||
await expect(page.getByLabel('地图来源')).toHaveValue('none');
|
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/invalid/map.json');
|
||||||
|
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('场景资产树')).toContainText('待应用');
|
||||||
|
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: '关闭错误' }).click();
|
await page.getByRole('button', { name: '关闭错误' }).click();
|
||||||
await page.getByRole('button', { name: '▶ 播放' }).click();
|
await page.getByRole('button', { name: '▶ 播放' }).click();
|
||||||
await page.waitForTimeout(300);
|
await expect
|
||||||
await expect(page.locator('footer')).not.toContainText('时间 0.000 s');
|
.poll(async () =>
|
||||||
|
Number((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0),
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(runningTime);
|
||||||
|
await page.getByRole('button', { name: '放弃更改' }).click();
|
||||||
|
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
|
||||||
|
await expect(page.getByLabel('地图来源')).toHaveValue('none');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('无效模型显示中文诊断且保留工程树', async ({ page }) => {
|
test('无效模型显示中文诊断且保留工程树', async ({ page }) => {
|
||||||
|
|||||||
+1053
-185
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ export function LayoutSettingsDialog({
|
|||||||
onClick={() => onRightOpen(!rightOpen)}
|
onClick={() => onRightOpen(!rightOpen)}
|
||||||
icon={<PanelRight className="h-3.5 w-3.5" />}
|
icon={<PanelRight className="h-3.5 w-3.5" />}
|
||||||
>
|
>
|
||||||
属性面板
|
右侧面板
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mb-2 mt-4 text-xs font-semibold">布局预设</h3>
|
<h3 className="mb-2 mt-4 text-xs font-semibold">布局预设</h3>
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { MapDraftStatusOverlay, MapViewportToolbar } from './MapViewportTools';
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
describe('MapViewportToolbar', () => {
|
||||||
|
it('统一联动变换、吸附与贴地检测命令', () => {
|
||||||
|
const onModeChange = vi.fn();
|
||||||
|
const onSnappingChange = vi.fn();
|
||||||
|
const onPlacementModeChange = vi.fn();
|
||||||
|
const onAlignToSurface = vi.fn();
|
||||||
|
render(
|
||||||
|
<MapViewportToolbar
|
||||||
|
visible
|
||||||
|
interactionActive
|
||||||
|
mode="translate"
|
||||||
|
snapping
|
||||||
|
placementMode="auto_ground"
|
||||||
|
hasSelectedObject
|
||||||
|
allowScale
|
||||||
|
loading={false}
|
||||||
|
onActivate={noop}
|
||||||
|
onModeChange={onModeChange}
|
||||||
|
onSnappingChange={onSnappingChange}
|
||||||
|
onPlacementModeChange={onPlacementModeChange}
|
||||||
|
onAlignToSurface={onAlignToSurface}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '旋转工具 E' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '网格吸附' }));
|
||||||
|
fireEvent.change(screen.getByLabelText('贴地检测模式'), {
|
||||||
|
target: { value: 'gravity' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '立即贴合承载面' }));
|
||||||
|
|
||||||
|
expect(onModeChange).toHaveBeenCalledWith('rotate');
|
||||||
|
expect(onSnappingChange).toHaveBeenCalledWith(false);
|
||||||
|
expect(onPlacementModeChange).toHaveBeenCalledWith('gravity');
|
||||||
|
expect(onAlignToSurface).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('参数地形或未选中对象时不提供缩放与贴合动作', () => {
|
||||||
|
render(
|
||||||
|
<MapViewportToolbar
|
||||||
|
visible
|
||||||
|
interactionActive
|
||||||
|
mode="translate"
|
||||||
|
snapping={false}
|
||||||
|
placementMode="auto_ground"
|
||||||
|
hasSelectedObject={false}
|
||||||
|
allowScale={false}
|
||||||
|
loading={false}
|
||||||
|
onActivate={noop}
|
||||||
|
onModeChange={noop}
|
||||||
|
onSnappingChange={noop}
|
||||||
|
onPlacementModeChange={noop}
|
||||||
|
onAlignToSurface={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole('button', { name: '缩放工具 R' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '立即贴合承载面' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MapDraftStatusOverlay', () => {
|
||||||
|
it('常驻显示未保存数量并提供提交与丢弃入口', () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const onDiscard = vi.fn();
|
||||||
|
render(
|
||||||
|
<MapDraftStatusOverlay
|
||||||
|
visible
|
||||||
|
changeCount={3}
|
||||||
|
loading={false}
|
||||||
|
onCommit={onCommit}
|
||||||
|
onDiscard={onDiscard}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('3 项未保存改动')).toBeVisible();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '提交地图草稿' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '丢弃地图草稿' }));
|
||||||
|
expect(onCommit).toHaveBeenCalledOnce();
|
||||||
|
expect(onDiscard).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无草稿时显示已同步并禁用动作', () => {
|
||||||
|
render(
|
||||||
|
<MapDraftStatusOverlay
|
||||||
|
visible
|
||||||
|
changeCount={0}
|
||||||
|
loading={false}
|
||||||
|
onCommit={noop}
|
||||||
|
onDiscard={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('地图草稿已同步')).toBeVisible();
|
||||||
|
expect(screen.getByRole('button', { name: '提交地图草稿' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import {
|
||||||
|
ArrowDownToLine,
|
||||||
|
Check,
|
||||||
|
Grid3X3,
|
||||||
|
LockKeyhole,
|
||||||
|
MapPinned,
|
||||||
|
Move3d,
|
||||||
|
Rotate3d,
|
||||||
|
Scaling,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Select,
|
||||||
|
ToolbarToggleGroup,
|
||||||
|
type ToolbarItem,
|
||||||
|
} from '../../components/ui';
|
||||||
|
import {
|
||||||
|
MAP_OBJECT_PLACEMENT_LABELS,
|
||||||
|
type MapEditorTransformMode,
|
||||||
|
type MapObjectPlacementMode,
|
||||||
|
} from '../../map/editor/types';
|
||||||
|
|
||||||
|
const TRANSFORM_TOOLS: ToolbarItem<MapEditorTransformMode>[] = [
|
||||||
|
{ value: 'translate', label: '移动工具 W', icon: Move3d },
|
||||||
|
{ value: 'rotate', label: '旋转工具 E', icon: Rotate3d },
|
||||||
|
{ value: 'scale', label: '缩放工具 R', icon: Scaling },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MapViewportToolbar({
|
||||||
|
visible,
|
||||||
|
interactionActive,
|
||||||
|
mode,
|
||||||
|
snapping,
|
||||||
|
placementMode,
|
||||||
|
hasSelectedObject,
|
||||||
|
allowScale,
|
||||||
|
loading,
|
||||||
|
onActivate,
|
||||||
|
onModeChange,
|
||||||
|
onSnappingChange,
|
||||||
|
onPlacementModeChange,
|
||||||
|
onAlignToSurface,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
interactionActive: boolean;
|
||||||
|
mode: MapEditorTransformMode;
|
||||||
|
snapping: boolean;
|
||||||
|
placementMode: MapObjectPlacementMode;
|
||||||
|
hasSelectedObject: boolean;
|
||||||
|
allowScale: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
onActivate: () => void;
|
||||||
|
onModeChange: (mode: MapEditorTransformMode) => void;
|
||||||
|
onSnappingChange: (value: boolean) => void;
|
||||||
|
onPlacementModeChange: (mode: MapObjectPlacementMode) => void;
|
||||||
|
onAlignToSurface: () => void;
|
||||||
|
}) {
|
||||||
|
if (!visible) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-label="地图视口工具"
|
||||||
|
className="engineering-glass map-tool-enter absolute left-1/2 top-3 z-20 flex max-w-[calc(100%-24px)] -translate-x-1/2 items-center gap-1.5 rounded-xl border p-1.5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="激活地图编辑"
|
||||||
|
aria-pressed={interactionActive}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={onActivate}
|
||||||
|
className={`hidden h-8 items-center gap-1.5 rounded-lg px-2 text-[10px] font-semibold transition-colors sm:flex ${interactionActive ? 'bg-accent-soft text-accent' : 'bg-surface/80 text-text-secondary hover:bg-element-hover'}`}
|
||||||
|
>
|
||||||
|
<MapPinned className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
地图编辑
|
||||||
|
</button>
|
||||||
|
<ToolbarToggleGroup
|
||||||
|
items={TRANSFORM_TOOLS.filter((item) => allowScale || item.value !== 'scale')}
|
||||||
|
value={mode}
|
||||||
|
onChange={onModeChange}
|
||||||
|
label="地图变换模式"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
active={snapping}
|
||||||
|
tooltip={snapping ? '关闭网格吸附(当前 0.1 m / 5°)' : '开启网格吸附'}
|
||||||
|
aria-label="网格吸附"
|
||||||
|
aria-pressed={snapping}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => onSnappingChange(!snapping)}
|
||||||
|
>
|
||||||
|
<Grid3X3 className="h-3.5 w-3.5" />
|
||||||
|
</IconButton>
|
||||||
|
<span aria-hidden="true" className="mx-0.5 h-5 w-px bg-border" />
|
||||||
|
<label className="flex items-center gap-1 text-[10px] text-text-tertiary">
|
||||||
|
<span className="hidden lg:inline">贴地</span>
|
||||||
|
<Select
|
||||||
|
aria-label="贴地检测模式"
|
||||||
|
className="w-[112px]"
|
||||||
|
value={placementMode}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={(event) => onPlacementModeChange(event.target.value as MapObjectPlacementMode)}
|
||||||
|
>
|
||||||
|
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{value === 'auto_ground' ? '地面 z=0' : value === 'gravity' ? '表面检测' : '锁定位姿'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<IconButton
|
||||||
|
tooltip={hasSelectedObject ? '重新检测并贴合承载面' : '选中地图物体后可贴合表面'}
|
||||||
|
aria-label="立即贴合承载面"
|
||||||
|
disabled={loading || !hasSelectedObject || placementMode === 'locked'}
|
||||||
|
onClick={onAlignToSurface}
|
||||||
|
>
|
||||||
|
{placementMode === 'locked' ? (
|
||||||
|
<LockKeyhole className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ArrowDownToLine className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapDraftStatusOverlay({
|
||||||
|
visible,
|
||||||
|
changeCount,
|
||||||
|
loading,
|
||||||
|
onCommit,
|
||||||
|
onDiscard,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
changeCount: number;
|
||||||
|
loading: boolean;
|
||||||
|
onCommit: () => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
}) {
|
||||||
|
if (!visible) return null;
|
||||||
|
const dirty = changeCount > 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-label="地图草稿状态"
|
||||||
|
role="status"
|
||||||
|
className="engineering-glass absolute bottom-3 left-1/2 z-20 flex max-w-[calc(100%-24px)] -translate-x-1/2 items-center gap-2 rounded-xl border px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`h-2 w-2 shrink-0 rounded-full ${dirty ? 'draft-dirty-dot bg-warning' : 'bg-success'}`}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 whitespace-nowrap text-[10px] font-medium text-text-secondary sm:text-[11px]">
|
||||||
|
{dirty ? `${changeCount} 项未保存改动` : '地图草稿已同步'}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="丢弃地图草稿"
|
||||||
|
disabled={loading || !dirty}
|
||||||
|
icon={<Trash2 className="h-3 w-3" />}
|
||||||
|
onClick={onDiscard}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">丢弃</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
aria-label="提交地图草稿"
|
||||||
|
disabled={loading || !dirty}
|
||||||
|
icon={<Check className="h-3 w-3" />}
|
||||||
|
onClick={onCommit}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">提交</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapAssetDropTarget {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
position: readonly [number, number, number] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapAssetDropIndicator({ target }: { target?: MapAssetDropTarget }) {
|
||||||
|
if (!target) return null;
|
||||||
|
const valid = Boolean(target.position);
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-30" aria-label="地图资产放置预览">
|
||||||
|
<div
|
||||||
|
className={`absolute -translate-x-1/2 -translate-y-1/2 rounded-full border-2 p-1 shadow-2xl ${valid ? 'border-accent bg-accent/25 text-accent' : 'border-danger bg-danger/20 text-danger'}`}
|
||||||
|
style={{ left: target.left, top: target.top }}
|
||||||
|
>
|
||||||
|
<span className="grid h-8 w-8 place-items-center rounded-full border border-current bg-panel/85 backdrop-blur">
|
||||||
|
<MapPinned className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<span className="absolute left-1/2 top-full mt-2 -translate-x-1/2 whitespace-nowrap rounded-lg border border-border-strong bg-panel/90 px-2 py-1 text-[10px] font-medium shadow-xl backdrop-blur">
|
||||||
|
{valid
|
||||||
|
? `释放放置 · ${target.position![0].toFixed(1)}, ${target.position![1].toFixed(1)}`
|
||||||
|
: '请拖到 3D 地面'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { DEFAULT_PHYSICAL_MAP_CONFIG } from '../../map/types';
|
||||||
|
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
|
||||||
|
import { ModelControlsSidebar } from './ModelControlsSidebar';
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
bodies: [
|
||||||
|
{ id: 0, name: 'world', parentId: 0 },
|
||||||
|
{ id: 1, name: 'base', parentId: 0 },
|
||||||
|
{ id: 2, name: 'arm', parentId: 1 },
|
||||||
|
],
|
||||||
|
joints: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: 'arm_joint',
|
||||||
|
bodyId: 2,
|
||||||
|
type: 3,
|
||||||
|
value: 0.25,
|
||||||
|
min: -1,
|
||||||
|
max: 1,
|
||||||
|
limitMin: -1,
|
||||||
|
limitMax: 1,
|
||||||
|
limited: true,
|
||||||
|
limitsIgnored: false,
|
||||||
|
editable: true,
|
||||||
|
axis: [0, 0, 1],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actuators: [],
|
||||||
|
model: { nbody: 3, njnt: 1, ngeom: 2, ncam: 0, nactuator: 0, nu: 0, nq: 1, nv: 1 },
|
||||||
|
} as unknown as SimulationSnapshot;
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
const asyncTrue = async () => true;
|
||||||
|
|
||||||
|
function props(
|
||||||
|
patch: Partial<ComponentProps<typeof ModelControlsSidebar>> = {},
|
||||||
|
): ComponentProps<typeof ModelControlsSidebar> {
|
||||||
|
return {
|
||||||
|
snapshot,
|
||||||
|
selection: null,
|
||||||
|
viewerSelection: null,
|
||||||
|
loading: false,
|
||||||
|
urdfMode: 'mjcf',
|
||||||
|
baseMode: 'floating',
|
||||||
|
showCollision: false,
|
||||||
|
ignoreJointLimits: false,
|
||||||
|
jointAdvanced: false,
|
||||||
|
angleUnit: 'rad',
|
||||||
|
mapSelection: { kind: 'none' },
|
||||||
|
mapSceneDirty: false,
|
||||||
|
maps: [],
|
||||||
|
showVisualMap: true,
|
||||||
|
showMapCollision: false,
|
||||||
|
editorDocument: null,
|
||||||
|
workspaceTool: null,
|
||||||
|
onWorkspaceToolChange: noop,
|
||||||
|
onSelectJoint: noop,
|
||||||
|
onUrdfMode: noop,
|
||||||
|
onBaseMode: noop,
|
||||||
|
onShowCollision: noop,
|
||||||
|
onResetJoints: noop,
|
||||||
|
onToggleJointLimits: noop,
|
||||||
|
onToggleAdvanced: noop,
|
||||||
|
onToggleAngleUnit: noop,
|
||||||
|
onActuator: noop,
|
||||||
|
onActuatorParameters: noop,
|
||||||
|
onJoint: noop,
|
||||||
|
onApplyMap: noop,
|
||||||
|
onMapDraft: noop,
|
||||||
|
onEditorPreview: noop,
|
||||||
|
onEditorDraftChange: noop,
|
||||||
|
onEditorApply: asyncTrue,
|
||||||
|
onEditorExport: noop,
|
||||||
|
onEditorConvert: asyncTrue,
|
||||||
|
onEditorBindInteraction: noop,
|
||||||
|
onEditorSelect: noop,
|
||||||
|
onEditorSessionStateChange: noop,
|
||||||
|
onMapDisplay: noop,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ModelControlsSidebar', () => {
|
||||||
|
it('没有选择时显示场景摘要,并保留基础地图快速入口', () => {
|
||||||
|
render(<ModelControlsSidebar {...props()} />);
|
||||||
|
expect(screen.getByText('未选择对象')).toBeVisible();
|
||||||
|
expect(screen.getByText('模型摘要')).toBeVisible();
|
||||||
|
expect(screen.getByText('未选择地图实例')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('按照统一选择自动路由 Body 与 Joint 检查器', () => {
|
||||||
|
const view = render(
|
||||||
|
<ModelControlsSidebar {...props({ selection: { kind: 'body', bodyId: 2 } })} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Robot / Body')).toBeVisible();
|
||||||
|
expect(screen.getByText('arm', { selector: '[title="arm"]' })).toBeVisible();
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<ModelControlsSidebar {...props({ selection: { kind: 'joint', jointId: 7, bodyId: 2 } })} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Robot / Joint')).toBeVisible();
|
||||||
|
expect(screen.getByText('Hinge · arm')).toBeVisible();
|
||||||
|
expect(screen.getByText('关联 Actuator')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('控制台与数据录制在右侧面板内切换,不创建模态窗口', () => {
|
||||||
|
const onWorkspaceToolChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ModelControlsSidebar
|
||||||
|
{...props({
|
||||||
|
selection: { kind: 'body', bodyId: 2 },
|
||||||
|
workspaceTool: 'controls',
|
||||||
|
workspaceTools: (
|
||||||
|
<div role="tabpanel" aria-label="控制台">
|
||||||
|
内嵌控制工具
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onWorkspaceToolChange,
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('tabpanel', { name: '控制台' })).toBeVisible();
|
||||||
|
expect(screen.getByText('内嵌控制工具')).toBeVisible();
|
||||||
|
const inspector = document.querySelector('[role="tabpanel"][aria-label="检查器"]');
|
||||||
|
expect(inspector).not.toBeNull();
|
||||||
|
expect(inspector).not.toBeVisible();
|
||||||
|
const controlsTab = screen.getByRole('tab', { name: '控制台' });
|
||||||
|
expect(controlsTab).toHaveAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
|
fireEvent.keyDown(controlsTab, { key: 'ArrowRight' });
|
||||||
|
expect(onWorkspaceToolChange).toHaveBeenCalledWith('data');
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '检查器' }));
|
||||||
|
expect(onWorkspaceToolChange).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('选择地图实例后自动展示地图检查器,并把全局工具移到右侧入口', () => {
|
||||||
|
const onWorkspaceToolChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ModelControlsSidebar
|
||||||
|
{...props({
|
||||||
|
selection: { kind: 'map', mapAssetId: 'terrain-a' },
|
||||||
|
activeMapAssetId: 'terrain-a',
|
||||||
|
activeMapAssetName: '随机粗糙地形',
|
||||||
|
mapSelection: {
|
||||||
|
kind: 'builtin',
|
||||||
|
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough' },
|
||||||
|
},
|
||||||
|
onWorkspaceToolChange,
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Map / Instance')).toBeVisible();
|
||||||
|
expect(screen.getByText('随机粗糙地形', { selector: '[title="随机粗糙地形"]' })).toBeVisible();
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '控制台' }));
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '数据录制' }));
|
||||||
|
expect(onWorkspaceToolChange.mock.calls).toEqual([['controls'], ['data']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,38 +1,31 @@
|
|||||||
import { useState } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Database, Info, Map as MapIcon, SlidersHorizontal } from 'lucide-react';
|
import { Box, Database, MapPinned, PanelRight, SlidersHorizontal } from 'lucide-react';
|
||||||
import type { MapEntry, ModelEntry } from '../../project/types';
|
import type { MapEntry, ModelEntry } from '../../project/types';
|
||||||
import type { ActuatorParameters, SimulationSnapshot } from '../../simulation/SimulationSession';
|
import type { ActuatorParameters, SimulationSnapshot } from '../../simulation/SimulationSession';
|
||||||
import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter';
|
import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter';
|
||||||
import type { DataRecorderConfig } from '../../telemetry/DataRecorder';
|
|
||||||
import type { ViewerSelection } from '../../viewer/MuJoCoViewer';
|
import type { ViewerSelection } from '../../viewer/MuJoCoViewer';
|
||||||
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
|
import type { MapSelection, PlacedMapSelection } from '../../map/types';
|
||||||
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
CollapsibleSection,
|
|
||||||
CopyButton,
|
|
||||||
PropertyRow,
|
|
||||||
Select,
|
|
||||||
Tabs,
|
|
||||||
} from '../../components/ui';
|
|
||||||
import type { MapSelection } from '../../map/types';
|
|
||||||
import type {
|
import type {
|
||||||
EditableMapDocument,
|
EditableMapDocument,
|
||||||
MapEditorInteractionCallbacks,
|
MapEditorInteractionCallbacks,
|
||||||
MapEditorTransformMode,
|
MapEditorSessionState,
|
||||||
} from '../../map/editor/types';
|
} from '../../map/editor/types';
|
||||||
import { DataRecordingPanel } from '../../telemetry/DataRecordingPanel';
|
|
||||||
import { ActuatorControl, Check, ControlSlider } from '../../simulation/ActuatorControl';
|
|
||||||
import { LocalTrainingPanel } from '../../training/LocalTrainingPanel';
|
|
||||||
import { PhysicalMapPanel } from '../../map/PhysicalMapPanel';
|
import { PhysicalMapPanel } from '../../map/PhysicalMapPanel';
|
||||||
import { PythonControllerPanel } from '../../controller/PythonControllerPanel';
|
import {
|
||||||
import { RLPolicyPanel } from '../../rl/RLPolicyPanel';
|
BodyInspector,
|
||||||
|
InspectorIdentity,
|
||||||
|
JointInspector,
|
||||||
|
ModelSummaryInspector,
|
||||||
|
} from '../../simulation/RobotInspector';
|
||||||
|
import type { EditorSelection } from '../editorSelection';
|
||||||
import { SidebarPanel } from './SidebarPanel';
|
import { SidebarPanel } from './SidebarPanel';
|
||||||
|
import type { WorkspaceTool } from './WorkspaceToolsPanel';
|
||||||
|
import { RightSidebarTabs } from './RightSidebarTabs';
|
||||||
|
|
||||||
interface ModelControlsProps {
|
interface ModelControlsProps {
|
||||||
snapshot?: SimulationSnapshot;
|
snapshot?: SimulationSnapshot;
|
||||||
selection: ViewerSelection | null;
|
selection: EditorSelection | null;
|
||||||
|
viewerSelection: ViewerSelection | null;
|
||||||
selectedFormat?: ModelEntry['format'];
|
selectedFormat?: ModelEntry['format'];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
@@ -42,18 +35,19 @@ interface ModelControlsProps {
|
|||||||
ignoreJointLimits: boolean;
|
ignoreJointLimits: boolean;
|
||||||
jointAdvanced: boolean;
|
jointAdvanced: boolean;
|
||||||
angleUnit: 'rad' | 'deg';
|
angleUnit: 'rad' | 'deg';
|
||||||
forceScale: number;
|
|
||||||
controllerPaths: string[];
|
|
||||||
selectedControllerPath?: string;
|
|
||||||
controllerStatus?: ControllerStatus;
|
|
||||||
policyPaths: string[];
|
|
||||||
selectedPolicyPath?: string;
|
|
||||||
policyStatus?: RLPolicyStatus;
|
|
||||||
mapSelection: MapSelection;
|
mapSelection: MapSelection;
|
||||||
|
activeMapAssetId?: string;
|
||||||
|
activeMapAssetName?: string;
|
||||||
|
mapSceneDirty: boolean;
|
||||||
maps: MapEntry[];
|
maps: MapEntry[];
|
||||||
showVisualMap: boolean;
|
showVisualMap: boolean;
|
||||||
showMapCollision: boolean;
|
showMapCollision: boolean;
|
||||||
editorDocument: EditableMapDocument | null;
|
editorDocument: EditableMapDocument | null;
|
||||||
|
editorDraftDocument?: EditableMapDocument;
|
||||||
|
workspaceTool: WorkspaceTool | null;
|
||||||
|
workspaceTools?: ReactNode;
|
||||||
|
onWorkspaceToolChange: (tool: WorkspaceTool | null) => void;
|
||||||
|
onSelectJoint: (jointId: number, bodyId: number) => void;
|
||||||
onUrdfMode: (value: UrdfLoadMode) => void;
|
onUrdfMode: (value: UrdfLoadMode) => void;
|
||||||
onBaseMode: (value: UrdfBaseMode) => void;
|
onBaseMode: (value: UrdfBaseMode) => void;
|
||||||
onShowCollision: (value: boolean) => void;
|
onShowCollision: (value: boolean) => void;
|
||||||
@@ -64,304 +58,89 @@ interface ModelControlsProps {
|
|||||||
onActuator: (id: number, value: number) => void;
|
onActuator: (id: number, value: number) => void;
|
||||||
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
|
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
|
||||||
onJoint: (id: number, value: number) => void;
|
onJoint: (id: number, value: number) => void;
|
||||||
onForceScale: (value: number) => void;
|
|
||||||
onSelectControllerPath: (path: string) => void;
|
|
||||||
onLoadControllerPath: (path: string) => void;
|
|
||||||
onImportController: (file: File) => void;
|
|
||||||
onToggleController: (enabled: boolean) => void;
|
|
||||||
onControllerCommand: (command: ControllerCommand) => void;
|
|
||||||
onRemoveController: () => void;
|
|
||||||
onSelectPolicyPath: (path: string) => void;
|
|
||||||
onLoadPolicyPath: (path: string) => void;
|
|
||||||
onImportPolicy: (file: File) => void;
|
|
||||||
onTogglePolicy: (enabled: boolean) => void;
|
|
||||||
onPolicyCommand: (command: RLCommand) => void;
|
|
||||||
onRemovePolicy: () => void;
|
|
||||||
onApplyMap: (value: MapSelection) => void;
|
onApplyMap: (value: MapSelection) => void;
|
||||||
|
onMapDraft: (value: PlacedMapSelection) => void;
|
||||||
onEditorPreview: (document: EditableMapDocument | null) => void;
|
onEditorPreview: (document: EditableMapDocument | null) => void;
|
||||||
|
onEditorDraftChange: (
|
||||||
|
descriptorPath: string,
|
||||||
|
document: EditableMapDocument,
|
||||||
|
dirty: boolean,
|
||||||
|
) => void;
|
||||||
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
|
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
|
||||||
onEditorExport: () => void;
|
onEditorExport: () => void;
|
||||||
onEditorConvert: () => Promise<boolean>;
|
onEditorConvert: () => Promise<boolean>;
|
||||||
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
|
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
|
||||||
onEditorSelect: (id: string | null) => void;
|
onEditorSelect: (id: string | null) => void;
|
||||||
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
|
onEditorSessionStateChange?: (
|
||||||
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
|
descriptorPath: string,
|
||||||
|
state: MapEditorSessionState | null,
|
||||||
|
) => void;
|
||||||
|
onEditorSurfaceHeight?: (position: readonly [number, number, number]) => number | null;
|
||||||
onMapDisplay: (visual: boolean, collision: boolean) => void;
|
onMapDisplay: (visual: boolean, collision: boolean) => void;
|
||||||
onMapTabOpen?: () => void;
|
|
||||||
onDataRecorderConfigure: (patch: Partial<DataRecorderConfig>) => void;
|
|
||||||
onDataRecordingStart: () => void;
|
|
||||||
onDataRecordingStop: () => void;
|
|
||||||
onDataRecordingClear: () => void;
|
|
||||||
onDataRecordingExport: (format: 'csv' | 'json') => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelControlsSidebar(props: ModelControlsProps) {
|
export function ModelControlsSidebar(props: ModelControlsProps) {
|
||||||
const [tab, setTab] = useState<'properties' | 'controls' | 'data' | 'map'>('properties'),
|
const snapshot = props.snapshot;
|
||||||
s = props.snapshot;
|
const activeView = props.workspaceTool ?? 'inspector';
|
||||||
if (!s)
|
const title =
|
||||||
|
activeView === 'controls' ? '控制台' : activeView === 'data' ? '数据录制' : '检查器';
|
||||||
|
const icon =
|
||||||
|
activeView === 'controls' ? (
|
||||||
|
<SlidersHorizontal />
|
||||||
|
) : activeView === 'data' ? (
|
||||||
|
<Database />
|
||||||
|
) : (
|
||||||
|
<PanelRight />
|
||||||
|
);
|
||||||
|
const tabs = (
|
||||||
|
<RightSidebarTabs
|
||||||
|
value={activeView}
|
||||||
|
onChange={(value) => props.onWorkspaceToolChange(value === 'inspector' ? null : value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (!snapshot)
|
||||||
return (
|
return (
|
||||||
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
|
<SidebarPanel title={title} side="right" visible={props.visible} icon={icon}>
|
||||||
<div className="p-4 text-sm text-text-tertiary">导入模型后显示属性</div>
|
{tabs}
|
||||||
|
{props.workspaceTool ? (
|
||||||
|
props.workspaceTools
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-sm text-text-tertiary">导入模型后显示检查器</div>
|
||||||
|
)}
|
||||||
</SidebarPanel>
|
</SidebarPanel>
|
||||||
);
|
);
|
||||||
const properties = (
|
|
||||||
<>
|
const selection = props.selection;
|
||||||
<CollapsibleSection title="模型信息" defaultOpen badge={<Badge>{s.model.nbody} Body</Badge>}>
|
const selectedBody =
|
||||||
<div>
|
selection?.kind === 'body'
|
||||||
<PropertyRow label="Body" value={s.model.nbody} />
|
? snapshot.bodies.find((body) => body.id === selection.bodyId)
|
||||||
<PropertyRow label="Joint" value={s.model.njnt} />
|
: undefined;
|
||||||
<PropertyRow label="Geom" value={s.model.ngeom} />
|
const selectedJoint =
|
||||||
<PropertyRow label="Actuator" value={s.model.nactuator} />
|
selection?.kind === 'joint'
|
||||||
<PropertyRow label="qpos / qvel" value={`${s.model.nq} / ${s.model.nv}`} />
|
? snapshot.joints.find((joint) => joint.id === selection.jointId)
|
||||||
</div>
|
: undefined;
|
||||||
</CollapsibleSection>
|
const mapSelected =
|
||||||
{props.selectedFormat === 'urdf' && (
|
(selection?.kind === 'map' || selection?.kind === 'map-object') &&
|
||||||
<CollapsibleSection title="URDF 处理方式" defaultOpen={false}>
|
selection.mapAssetId === props.activeMapAssetId;
|
||||||
<Select
|
const selectedMapObject =
|
||||||
aria-label="URDF 处理方式"
|
selection?.kind === 'map-object' && mapSelected
|
||||||
className="w-full"
|
? (props.editorDraftDocument ?? props.editorDocument)?.objects.find(
|
||||||
value={props.urdfMode}
|
(object) => object.id === selection.objectId,
|
||||||
disabled={props.loading}
|
)
|
||||||
onChange={(event) => props.onUrdfMode(event.target.value as UrdfLoadMode)}
|
: undefined;
|
||||||
>
|
const mapPanel = (
|
||||||
<option value="mjcf">转换为 MJCF(推荐)</option>
|
|
||||||
<option value="native">MuJoCo 原生 URDF</option>
|
|
||||||
</Select>
|
|
||||||
<label className="mt-3 block text-xs text-text-secondary">
|
|
||||||
<span className="mb-1 block">基座类型</span>
|
|
||||||
<Select
|
|
||||||
aria-label="URDF 基座类型"
|
|
||||||
className="w-full"
|
|
||||||
value={props.baseMode}
|
|
||||||
disabled={props.loading || props.urdfMode === 'native'}
|
|
||||||
onChange={(event) => props.onBaseMode(event.target.value as UrdfBaseMode)}
|
|
||||||
>
|
|
||||||
<option value="floating">浮动基座(Free Joint)</option>
|
|
||||||
<option value="fixed">固定基座(连接世界)</option>
|
|
||||||
</Select>
|
|
||||||
</label>
|
|
||||||
<p className="mt-2 text-xs text-text-tertiary">
|
|
||||||
MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。
|
|
||||||
</p>
|
|
||||||
<Check
|
|
||||||
label="显示碰撞几何"
|
|
||||||
checked={props.showCollision}
|
|
||||||
onChange={props.onShowCollision}
|
|
||||||
/>
|
|
||||||
</CollapsibleSection>
|
|
||||||
)}
|
|
||||||
<CollapsibleSection title="当前选择" defaultOpen>
|
|
||||||
{props.selection ? (
|
|
||||||
<div className="text-xs">
|
|
||||||
<PropertyRow
|
|
||||||
label="Body"
|
|
||||||
value={props.selection.bodyName}
|
|
||||||
action={<CopyButton value={props.selection.bodyName} label="复制 Body 名称" />}
|
|
||||||
/>
|
|
||||||
<PropertyRow
|
|
||||||
label="标识"
|
|
||||||
value={`${props.selection.bodyId} / ${props.selection.geomId} / ${props.selection.geomType}`}
|
|
||||||
action={
|
|
||||||
<CopyButton
|
|
||||||
value={`body ${props.selection.bodyId}, geom ${props.selection.geomId}, type ${props.selection.geomType}`}
|
|
||||||
label="复制标识"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PropertyRow
|
|
||||||
label="位置"
|
|
||||||
value={props.selection.position.map((value) => value.toFixed(3)).join(', ')}
|
|
||||||
action={<CopyButton value={props.selection.position.join(', ')} label="复制位置" />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="flex items-center gap-2 text-xs text-text-tertiary">
|
|
||||||
<Info className="h-3.5 w-3.5" />
|
|
||||||
在视口中单击物体
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CollapsibleSection>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
const controls = (
|
|
||||||
<>
|
|
||||||
<CollapsibleSection
|
|
||||||
title="ONNX 强化学习策略"
|
|
||||||
defaultOpen
|
|
||||||
badge={s.rlPolicy ? <Badge>{s.rlPolicy.enabled ? '推理' : '停止'}</Badge> : undefined}
|
|
||||||
>
|
|
||||||
<RLPolicyPanel
|
|
||||||
paths={props.policyPaths}
|
|
||||||
selectedPath={props.selectedPolicyPath}
|
|
||||||
status={props.policyStatus ?? s.rlPolicy}
|
|
||||||
loading={props.loading}
|
|
||||||
onSelectPath={props.onSelectPolicyPath}
|
|
||||||
onLoadPath={props.onLoadPolicyPath}
|
|
||||||
onImport={props.onImportPolicy}
|
|
||||||
onToggle={props.onTogglePolicy}
|
|
||||||
onCommand={props.onPolicyCommand}
|
|
||||||
onRemove={props.onRemovePolicy}
|
|
||||||
/>
|
|
||||||
</CollapsibleSection>
|
|
||||||
<CollapsibleSection title="本地强化学习训练" defaultOpen={false}>
|
|
||||||
<LocalTrainingPanel onPolicyReady={props.onImportPolicy} />
|
|
||||||
</CollapsibleSection>
|
|
||||||
<CollapsibleSection
|
|
||||||
title="Python 控制器"
|
|
||||||
defaultOpen
|
|
||||||
badge={s.controller ? <Badge>{s.controller.enabled ? '运行' : '停止'}</Badge> : undefined}
|
|
||||||
>
|
|
||||||
<PythonControllerPanel
|
|
||||||
paths={props.controllerPaths}
|
|
||||||
selectedPath={props.selectedControllerPath}
|
|
||||||
status={props.controllerStatus ?? s.controller}
|
|
||||||
loading={props.loading}
|
|
||||||
onSelectPath={props.onSelectControllerPath}
|
|
||||||
onLoadPath={props.onLoadControllerPath}
|
|
||||||
onImport={props.onImportController}
|
|
||||||
onToggle={props.onToggleController}
|
|
||||||
onCommand={props.onControllerCommand}
|
|
||||||
onRemove={props.onRemoveController}
|
|
||||||
/>
|
|
||||||
</CollapsibleSection>
|
|
||||||
<CollapsibleSection
|
|
||||||
title="Actuator"
|
|
||||||
defaultOpen={false}
|
|
||||||
badge={<Badge>{s.actuators.length}</Badge>}
|
|
||||||
>
|
|
||||||
{s.actuators.length ? (
|
|
||||||
s.actuators.map((actuator) => (
|
|
||||||
<ActuatorControl
|
|
||||||
key={actuator.id}
|
|
||||||
actuator={actuator}
|
|
||||||
onControl={(value) => props.onActuator(actuator.id, value)}
|
|
||||||
onParameters={(parameters) => props.onActuatorParameters(actuator.id, parameters)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-text-tertiary">模型没有驱动器</p>
|
|
||||||
)}
|
|
||||||
</CollapsibleSection>
|
|
||||||
<CollapsibleSection title="关节" defaultOpen badge={<Badge>{s.joints.length}</Badge>}>
|
|
||||||
<div className="mb-4 grid grid-cols-2 gap-2">
|
|
||||||
<Button onClick={props.onResetJoints}>重置关节</Button>
|
|
||||||
<Button
|
|
||||||
variant={props.ignoreJointLimits ? 'primary' : 'secondary'}
|
|
||||||
aria-pressed={props.ignoreJointLimits}
|
|
||||||
onClick={props.onToggleJointLimits}
|
|
||||||
>
|
|
||||||
忽略关节限位
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={props.jointAdvanced ? 'primary' : 'secondary'}
|
|
||||||
aria-pressed={props.jointAdvanced}
|
|
||||||
onClick={props.onToggleAdvanced}
|
|
||||||
>
|
|
||||||
高级
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={props.angleUnit === 'deg' ? 'primary' : 'secondary'}
|
|
||||||
aria-pressed={props.angleUnit === 'deg'}
|
|
||||||
onClick={props.onToggleAngleUnit}
|
|
||||||
>
|
|
||||||
{props.angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{s.joints.map((joint) => {
|
|
||||||
const scale = joint.type === 3 && props.angleUnit === 'deg' ? 180 / Math.PI : 1,
|
|
||||||
unit =
|
|
||||||
joint.type === 3
|
|
||||||
? props.angleUnit === 'deg'
|
|
||||||
? '°'
|
|
||||||
: ' rad'
|
|
||||||
: joint.type === 2
|
|
||||||
? ' m'
|
|
||||||
: '';
|
|
||||||
return (
|
|
||||||
<ControlSlider
|
|
||||||
key={joint.id}
|
|
||||||
label={`${joint.name}${joint.editable ? '' : '(只读)'}`}
|
|
||||||
value={joint.value * scale}
|
|
||||||
min={joint.min * scale}
|
|
||||||
max={joint.max * scale}
|
|
||||||
unit={unit}
|
|
||||||
advanced={props.jointAdvanced}
|
|
||||||
limited={joint.limited}
|
|
||||||
limitsIgnored={joint.limitsIgnored}
|
|
||||||
limitMin={joint.limitMin * scale}
|
|
||||||
limitMax={joint.limitMax * scale}
|
|
||||||
disabled={!joint.editable}
|
|
||||||
onChange={(value) => props.onJoint(joint.id, value / scale)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CollapsibleSection>
|
|
||||||
<CollapsibleSection title="外力强度" defaultOpen={false}>
|
|
||||||
<ControlSlider
|
|
||||||
label={`${props.forceScale.toFixed(0)} N/屏幕单位`}
|
|
||||||
value={props.forceScale}
|
|
||||||
min={5}
|
|
||||||
max={200}
|
|
||||||
onChange={props.onForceScale}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-text-tertiary">
|
|
||||||
选择“外力施加”,在动态物体上按住拖动,松开即清零。
|
|
||||||
</p>
|
|
||||||
</CollapsibleSection>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
|
|
||||||
<Tabs
|
|
||||||
label="模型控制侧栏"
|
|
||||||
value={tab}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setTab(value);
|
|
||||||
if (value === 'map') props.onMapTabOpen?.();
|
|
||||||
}}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
value: 'properties',
|
|
||||||
label: '属性',
|
|
||||||
icon: <Info className="h-3.5 w-3.5" />,
|
|
||||||
content: properties,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'controls',
|
|
||||||
label: '控制',
|
|
||||||
icon: <SlidersHorizontal className="h-3.5 w-3.5" />,
|
|
||||||
content: controls,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'data',
|
|
||||||
label: '数据',
|
|
||||||
icon: <Database className="h-3.5 w-3.5" />,
|
|
||||||
content: (
|
|
||||||
<DataRecordingPanel
|
|
||||||
status={s.telemetry}
|
|
||||||
bodies={s.bodies}
|
|
||||||
onConfigure={props.onDataRecorderConfigure}
|
|
||||||
onStart={props.onDataRecordingStart}
|
|
||||||
onStop={props.onDataRecordingStop}
|
|
||||||
onClear={props.onDataRecordingClear}
|
|
||||||
onExport={props.onDataRecordingExport}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'map',
|
|
||||||
label: '地图',
|
|
||||||
icon: <MapIcon className="h-3.5 w-3.5" />,
|
|
||||||
content: (
|
|
||||||
<PhysicalMapPanel
|
<PhysicalMapPanel
|
||||||
key={JSON.stringify(props.mapSelection)}
|
key={`${props.activeMapAssetId ?? 'none'}:${
|
||||||
|
props.mapSelection.kind === 'project'
|
||||||
|
? props.mapSelection.descriptorPath
|
||||||
|
: props.mapSelection.kind
|
||||||
|
}`}
|
||||||
value={props.mapSelection}
|
value={props.mapSelection}
|
||||||
maps={props.maps}
|
maps={props.maps}
|
||||||
rootBodies={s.bodies
|
rootBodies={snapshot.bodies
|
||||||
.filter(
|
.filter(
|
||||||
(body) =>
|
(body) =>
|
||||||
body.id !== 0 &&
|
body.id !== 0 && body.parentId === 0 && !body.name.startsWith('__platform_map_'),
|
||||||
body.parentId === 0 &&
|
|
||||||
!body.name.startsWith('__platform_map_'),
|
|
||||||
)
|
)
|
||||||
.map((body) => body.name)}
|
.map((body) => body.name)}
|
||||||
loading={props.loading}
|
loading={props.loading}
|
||||||
@@ -369,21 +148,106 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
|
|||||||
showVisualMap={props.showVisualMap}
|
showVisualMap={props.showVisualMap}
|
||||||
showMapCollision={props.showMapCollision}
|
showMapCollision={props.showMapCollision}
|
||||||
editorDocument={props.editorDocument}
|
editorDocument={props.editorDocument}
|
||||||
|
editorDraftDocument={props.editorDraftDocument}
|
||||||
|
sceneDirty={props.mapSceneDirty}
|
||||||
onEditorPreview={props.onEditorPreview}
|
onEditorPreview={props.onEditorPreview}
|
||||||
|
onEditorDraftChange={props.onEditorDraftChange}
|
||||||
onEditorApply={props.onEditorApply}
|
onEditorApply={props.onEditorApply}
|
||||||
onEditorExport={props.onEditorExport}
|
onEditorExport={props.onEditorExport}
|
||||||
onEditorConvert={props.onEditorConvert}
|
onEditorConvert={props.onEditorConvert}
|
||||||
|
selectedEditorObjectId={
|
||||||
|
selection?.kind === 'map-object' && mapSelected ? selection.objectId : undefined
|
||||||
|
}
|
||||||
onEditorBindInteraction={props.onEditorBindInteraction}
|
onEditorBindInteraction={props.onEditorBindInteraction}
|
||||||
onEditorSelect={props.onEditorSelect}
|
onEditorSelect={props.onEditorSelect}
|
||||||
onEditorTransformMode={props.onEditorTransformMode}
|
onEditorSessionStateChange={props.onEditorSessionStateChange}
|
||||||
onEditorSnapping={props.onEditorSnapping}
|
onEditorSurfaceHeight={props.onEditorSurfaceHeight}
|
||||||
onMapDisplay={props.onMapDisplay}
|
onMapDisplay={props.onMapDisplay}
|
||||||
|
onDraft={props.onMapDraft}
|
||||||
onApply={props.onApplyMap}
|
onApply={props.onApplyMap}
|
||||||
/>
|
/>
|
||||||
),
|
);
|
||||||
},
|
|
||||||
]}
|
let inspector: ReactNode;
|
||||||
|
if (selectedBody) {
|
||||||
|
inspector = (
|
||||||
|
<BodyInspector
|
||||||
|
body={selectedBody}
|
||||||
|
snapshot={snapshot}
|
||||||
|
viewerSelection={props.viewerSelection}
|
||||||
|
showCollision={props.showCollision}
|
||||||
|
onShowCollision={props.onShowCollision}
|
||||||
|
onSelectJoint={props.onSelectJoint}
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
} else if (selectedJoint) {
|
||||||
|
inspector = (
|
||||||
|
<JointInspector
|
||||||
|
joint={selectedJoint}
|
||||||
|
snapshot={snapshot}
|
||||||
|
loading={props.loading}
|
||||||
|
ignoreJointLimits={props.ignoreJointLimits}
|
||||||
|
jointAdvanced={props.jointAdvanced}
|
||||||
|
angleUnit={props.angleUnit}
|
||||||
|
onResetJoints={props.onResetJoints}
|
||||||
|
onToggleJointLimits={props.onToggleJointLimits}
|
||||||
|
onToggleAdvanced={props.onToggleAdvanced}
|
||||||
|
onToggleAngleUnit={props.onToggleAngleUnit}
|
||||||
|
onJoint={props.onJoint}
|
||||||
|
onActuator={props.onActuator}
|
||||||
|
onActuatorParameters={props.onActuatorParameters}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (mapSelected) {
|
||||||
|
const mapName = selectedMapObject?.name ?? props.activeMapAssetName ?? '地图实例';
|
||||||
|
inspector = (
|
||||||
|
<>
|
||||||
|
<InspectorIdentity
|
||||||
|
icon={selectedMapObject ? <Box /> : <MapPinned />}
|
||||||
|
eyebrow={selectedMapObject ? 'Map / Object' : 'Map / Instance'}
|
||||||
|
name={mapName}
|
||||||
|
meta={
|
||||||
|
selectedMapObject
|
||||||
|
? `${selectedMapObject.type} · ${props.activeMapAssetName ?? '地图'}`
|
||||||
|
: props.mapSelection.kind === 'builtin'
|
||||||
|
? '参数化地形'
|
||||||
|
: '工程地图包实例'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{mapPanel}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
inspector = (
|
||||||
|
<>
|
||||||
|
<ModelSummaryInspector
|
||||||
|
snapshot={snapshot}
|
||||||
|
selectedFormat={props.selectedFormat}
|
||||||
|
loading={props.loading}
|
||||||
|
urdfMode={props.urdfMode}
|
||||||
|
baseMode={props.baseMode}
|
||||||
|
showCollision={props.showCollision}
|
||||||
|
onUrdfMode={props.onUrdfMode}
|
||||||
|
onBaseMode={props.onBaseMode}
|
||||||
|
onShowCollision={props.onShowCollision}
|
||||||
|
/>
|
||||||
|
{props.mapSelection.kind === 'none' && mapPanel}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarPanel title={title} side="right" visible={props.visible} icon={icon}>
|
||||||
|
{tabs}
|
||||||
|
<div
|
||||||
|
hidden={props.workspaceTool !== null}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-label="检查器"
|
||||||
|
className="min-h-0 flex-1 overflow-auto panel-scroll"
|
||||||
|
>
|
||||||
|
{inspector}
|
||||||
|
</div>
|
||||||
|
{props.workspaceTool && props.workspaceTools}
|
||||||
</SidebarPanel>
|
</SidebarPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Box, FolderTree, Map as MapIcon } from 'lucide-react';
|
import { FolderTree, Library, Search } from 'lucide-react';
|
||||||
import type { ModelEntry } from '../../project/types';
|
import type { MapEntry, ModelEntry } from '../../project/types';
|
||||||
import {
|
import {
|
||||||
countProjectSearchResults,
|
countProjectSearchResults,
|
||||||
ProjectTree,
|
ProjectTree,
|
||||||
type ProjectTreeFile,
|
type ProjectTreeFile,
|
||||||
} from '../../project/ProjectTree';
|
} from '../../project/ProjectTree';
|
||||||
import {
|
|
||||||
countModelStructureSearchResults,
|
|
||||||
ModelStructureTree,
|
|
||||||
} from '../../project/ModelStructureTree';
|
|
||||||
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
|
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
|
||||||
import { Button, Tabs } from '../../components/ui';
|
import { Button, Tabs, VerticalSplitPane } from '../../components/ui';
|
||||||
import {
|
import {
|
||||||
DEFAULT_PHYSICAL_MAP_CONFIG,
|
DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||||
type MapSelection,
|
type MapSelection,
|
||||||
|
type PlacedMapAsset,
|
||||||
type SystemTerrainPreset,
|
type SystemTerrainPreset,
|
||||||
} from '../../map/types';
|
} from '../../map/types';
|
||||||
import type {
|
import type {
|
||||||
@@ -23,28 +20,46 @@ import type {
|
|||||||
MapObjectPlacementMode,
|
MapObjectPlacementMode,
|
||||||
} from '../../map/editor/types';
|
} from '../../map/editor/types';
|
||||||
import { MapAssetLibrary } from '../../map/MapAssetLibrary';
|
import { MapAssetLibrary } from '../../map/MapAssetLibrary';
|
||||||
|
import type { EditorSelection } from '../editorSelection';
|
||||||
import { ProjectBreadcrumb } from './ProjectBreadcrumb';
|
import { ProjectBreadcrumb } from './ProjectBreadcrumb';
|
||||||
|
import { SceneOutliner, countSceneSearchResults } from './SceneOutliner';
|
||||||
import { SidebarPanel } from './SidebarPanel';
|
import { SidebarPanel } from './SidebarPanel';
|
||||||
import { TreeSearchField } from './TreeSearchField';
|
import { TreeSearchField } from './TreeSearchField';
|
||||||
|
|
||||||
|
export type ProjectResourceTab = 'assets' | 'files';
|
||||||
|
|
||||||
export function ProjectSidebar({
|
export function ProjectSidebar({
|
||||||
projectName,
|
projectName,
|
||||||
files,
|
files,
|
||||||
entries,
|
entries,
|
||||||
selectedEntry,
|
selectedEntry,
|
||||||
snapshot,
|
snapshot,
|
||||||
|
selection,
|
||||||
loading,
|
loading,
|
||||||
visible = true,
|
visible = true,
|
||||||
nativeUrdf,
|
nativeUrdf,
|
||||||
mapSelection,
|
mapSelection,
|
||||||
editorDocument,
|
maps,
|
||||||
|
placedMaps,
|
||||||
|
pendingSceneChangeCount,
|
||||||
|
pendingSceneIds,
|
||||||
|
editorDocuments,
|
||||||
|
assetPlacementMode,
|
||||||
activeTab,
|
activeTab,
|
||||||
onActiveTabChange,
|
onActiveTabChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
onSelectEntry,
|
onSelectEntry,
|
||||||
|
onSelectBody,
|
||||||
|
onSelectJoint,
|
||||||
onJointHover,
|
onJointHover,
|
||||||
onAddMapAsset,
|
onAddMapAsset,
|
||||||
|
onAddProjectMap,
|
||||||
onSelectTerrain,
|
onSelectTerrain,
|
||||||
|
onAssetPlacementModeChange,
|
||||||
|
onApplyScene,
|
||||||
|
onDiscardScene,
|
||||||
|
onSelectMap,
|
||||||
|
onRemoveMap,
|
||||||
onSelectMapObject,
|
onSelectMapObject,
|
||||||
}: {
|
}: {
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
@@ -52,33 +67,46 @@ export function ProjectSidebar({
|
|||||||
entries: ModelEntry[];
|
entries: ModelEntry[];
|
||||||
selectedEntry?: string;
|
selectedEntry?: string;
|
||||||
snapshot?: SimulationSnapshot;
|
snapshot?: SimulationSnapshot;
|
||||||
|
selection: EditorSelection | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
nativeUrdf: boolean;
|
nativeUrdf: boolean;
|
||||||
mapSelection: MapSelection;
|
mapSelection: MapSelection;
|
||||||
editorDocument: EditableMapDocument | null;
|
maps: MapEntry[];
|
||||||
activeTab?: 'project' | 'structure' | 'assets';
|
placedMaps: PlacedMapAsset[];
|
||||||
onActiveTabChange?: (value: 'project' | 'structure' | 'assets') => void;
|
pendingSceneChangeCount: number;
|
||||||
|
pendingSceneIds: string[];
|
||||||
|
editorDocuments: ReadonlyMap<string, EditableMapDocument>;
|
||||||
|
assetPlacementMode?: MapObjectPlacementMode;
|
||||||
|
activeTab?: ProjectResourceTab;
|
||||||
|
onActiveTabChange?: (value: ProjectResourceTab) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onSelectEntry: (path: string) => void;
|
onSelectEntry: (path: string) => void;
|
||||||
|
onSelectBody: (bodyId: number) => void;
|
||||||
|
onSelectJoint: (jointId: number, bodyId: number) => void;
|
||||||
onJointHover: (jointId: number | null) => void;
|
onJointHover: (jointId: number | null) => void;
|
||||||
onAddMapAsset: (
|
onAddMapAsset: (
|
||||||
type: EditableMapObjectType,
|
type: EditableMapObjectType,
|
||||||
placementMode: MapObjectPlacementMode,
|
placementMode: MapObjectPlacementMode,
|
||||||
) => void | Promise<void>;
|
) => void | Promise<void>;
|
||||||
|
onAddProjectMap: (descriptorPath: string) => void;
|
||||||
onSelectTerrain: (preset: SystemTerrainPreset) => void;
|
onSelectTerrain: (preset: SystemTerrainPreset) => void;
|
||||||
onSelectMapObject: (id: string) => void;
|
onAssetPlacementModeChange?: (mode: MapObjectPlacementMode) => void;
|
||||||
|
onApplyScene: () => void;
|
||||||
|
onDiscardScene: () => void;
|
||||||
|
onSelectMap: (id: string) => void;
|
||||||
|
onRemoveMap: (id: string) => void;
|
||||||
|
onSelectMapObject: (mapId: string, objectId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [internalTab, setInternalTab] = useState<'project' | 'structure' | 'assets'>('project'),
|
const [internalTab, setInternalTab] = useState<ProjectResourceTab>('assets');
|
||||||
[fileQuery, setFileQuery] = useState(''),
|
const [fileQuery, setFileQuery] = useState('');
|
||||||
[structureQuery, setStructureQuery] = useState('');
|
const [sceneQuery, setSceneQuery] = useState('');
|
||||||
const tab = activeTab ?? internalTab,
|
const tab = activeTab ?? internalTab;
|
||||||
fileMatches = countProjectSearchResults(files, fileQuery),
|
const fileMatches = countProjectSearchResults(files, fileQuery);
|
||||||
structureMatches = snapshot
|
const sceneMatches = countSceneSearchResults(snapshot, placedMaps, editorDocuments, sceneQuery);
|
||||||
? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery)
|
|
||||||
: 0;
|
|
||||||
return (
|
return (
|
||||||
<SidebarPanel title="资产与结构" side="left" visible={visible}>
|
<SidebarPanel title="场景大纲与资产中心" side="left" visible={visible} icon={<Library />}>
|
||||||
{projectName ? (
|
{projectName ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
|
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
|
||||||
@@ -86,21 +114,62 @@ export function ProjectSidebar({
|
|||||||
<div className="truncate text-sm font-medium text-accent" title={projectName}>
|
<div className="truncate text-sm font-medium text-accent" title={projectName}>
|
||||||
{projectName}
|
{projectName}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-[10px] text-text-tertiary">{files.length} 个文件</div>
|
<div className="mt-0.5 truncate text-[10px] text-text-tertiary">
|
||||||
|
{snapshot
|
||||||
|
? `${snapshot.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')).length} Body`
|
||||||
|
: '模型未加载'}{' '}
|
||||||
|
· {placedMaps.length} 个地图实例 · {files.length} 个文件
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="danger" onClick={onRemove} disabled={loading}>
|
<Button variant="danger" onClick={onRemove} disabled={loading}>
|
||||||
移除
|
移除
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ProjectBreadcrumb
|
|
||||||
projectName={projectName}
|
<VerticalSplitPane
|
||||||
entries={entries}
|
storageKey="mujoco-project-sidebar-scene-ratio"
|
||||||
selectedEntry={selectedEntry}
|
separatorLabel="调整场景大纲与资源面板高度"
|
||||||
loading={loading}
|
defaultRatio={0.42}
|
||||||
onSelect={onSelectEntry}
|
minFirstSize={160}
|
||||||
|
minSecondSize={160}
|
||||||
|
firstClassName="flex flex-col"
|
||||||
|
secondClassName="flex flex-col"
|
||||||
|
first={
|
||||||
|
<>
|
||||||
|
<div className="flex h-8 shrink-0 items-center gap-2 px-3 text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">
|
||||||
|
<Search className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
场景大纲
|
||||||
|
</div>
|
||||||
|
<TreeSearchField
|
||||||
|
value={sceneQuery}
|
||||||
|
onChange={setSceneQuery}
|
||||||
|
resultCount={sceneMatches}
|
||||||
|
label="搜索场景对象"
|
||||||
|
placeholder="搜索 Body、关节或地图物体…"
|
||||||
/>
|
/>
|
||||||
|
<SceneOutliner
|
||||||
|
snapshot={snapshot}
|
||||||
|
maps={placedMaps}
|
||||||
|
documents={editorDocuments}
|
||||||
|
selection={selection}
|
||||||
|
query={sceneQuery}
|
||||||
|
loading={loading}
|
||||||
|
pendingSceneChangeCount={pendingSceneChangeCount}
|
||||||
|
pendingSceneIds={pendingSceneIds}
|
||||||
|
onSelectBody={onSelectBody}
|
||||||
|
onSelectJoint={onSelectJoint}
|
||||||
|
onJointHover={onJointHover}
|
||||||
|
onSelectMap={onSelectMap}
|
||||||
|
onSelectMapObject={onSelectMapObject}
|
||||||
|
onRemoveMap={onRemoveMap}
|
||||||
|
onApplyScene={onApplyScene}
|
||||||
|
onDiscardScene={onDiscardScene}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
second={
|
||||||
<Tabs
|
<Tabs
|
||||||
label="工程侧栏"
|
label="资产与工程文件"
|
||||||
value={tab}
|
value={tab}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setInternalTab(value);
|
setInternalTab(value);
|
||||||
@@ -108,11 +177,40 @@ export function ProjectSidebar({
|
|||||||
}}
|
}}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
value: 'project',
|
value: 'assets',
|
||||||
label: '工程',
|
label: '资产库',
|
||||||
|
icon: <Library className="h-3.5 w-3.5" />,
|
||||||
|
disabled: !snapshot,
|
||||||
|
content: (
|
||||||
|
<MapAssetLibrary
|
||||||
|
disabled={loading || nativeUrdf}
|
||||||
|
terrainSize={
|
||||||
|
mapSelection.kind === 'builtin'
|
||||||
|
? mapSelection.config.size
|
||||||
|
: DEFAULT_PHYSICAL_MAP_CONFIG.size
|
||||||
|
}
|
||||||
|
maps={maps}
|
||||||
|
placementMode={assetPlacementMode}
|
||||||
|
onPlacementModeChange={onAssetPlacementModeChange}
|
||||||
|
onAdd={onAddMapAsset}
|
||||||
|
onAddProjectMap={onAddProjectMap}
|
||||||
|
onSelectTerrain={onSelectTerrain}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'files',
|
||||||
|
label: '工程文件',
|
||||||
icon: <FolderTree className="h-3.5 w-3.5" />,
|
icon: <FolderTree className="h-3.5 w-3.5" />,
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
|
<ProjectBreadcrumb
|
||||||
|
projectName={projectName}
|
||||||
|
entries={entries}
|
||||||
|
selectedEntry={selectedEntry}
|
||||||
|
loading={loading}
|
||||||
|
onSelect={onSelectEntry}
|
||||||
|
/>
|
||||||
<TreeSearchField
|
<TreeSearchField
|
||||||
value={fileQuery}
|
value={fileQuery}
|
||||||
onChange={setFileQuery}
|
onChange={setFileQuery}
|
||||||
@@ -132,58 +230,13 @@ export function ProjectSidebar({
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: 'structure',
|
|
||||||
label: '模型结构',
|
|
||||||
icon: <Box className="h-3.5 w-3.5" />,
|
|
||||||
disabled: !snapshot,
|
|
||||||
content: snapshot ? (
|
|
||||||
<>
|
|
||||||
<TreeSearchField
|
|
||||||
value={structureQuery}
|
|
||||||
onChange={setStructureQuery}
|
|
||||||
resultCount={structureMatches}
|
|
||||||
label="搜索模型结构"
|
|
||||||
placeholder="搜索 Body 或关节…"
|
|
||||||
/>
|
|
||||||
<div className="px-2 pb-3">
|
|
||||||
<ModelStructureTree
|
|
||||||
bodies={snapshot.bodies}
|
|
||||||
joints={snapshot.joints}
|
|
||||||
onJointHover={onJointHover}
|
|
||||||
query={structureQuery}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="p-4 text-center text-xs text-text-tertiary">加载模型后显示结构</p>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'assets',
|
|
||||||
label: '资产',
|
|
||||||
icon: <MapIcon className="h-3.5 w-3.5" />,
|
|
||||||
disabled: !snapshot,
|
|
||||||
content: (
|
|
||||||
<MapAssetLibrary
|
|
||||||
disabled={loading || nativeUrdf}
|
|
||||||
terrainSize={
|
|
||||||
mapSelection.kind === 'builtin'
|
|
||||||
? mapSelection.config.size
|
|
||||||
: DEFAULT_PHYSICAL_MAP_CONFIG.size
|
|
||||||
}
|
|
||||||
document={editorDocument}
|
|
||||||
onAdd={onAddMapAsset}
|
|
||||||
onSelectTerrain={onSelectTerrain}
|
|
||||||
onSelectObject={onSelectMapObject}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4 text-center text-sm text-text-tertiary">导入模型后显示工程资源</div>
|
<div className="p-4 text-center text-sm text-text-tertiary">导入模型后显示场景与资产</div>
|
||||||
)}
|
)}
|
||||||
</SidebarPanel>
|
</SidebarPanel>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Database, PanelRight, SlidersHorizontal, type LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
export type RightSidebarView = 'inspector' | 'controls' | 'data';
|
||||||
|
|
||||||
|
const VIEWS: ReadonlyArray<{
|
||||||
|
value: RightSidebarView;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}> = [
|
||||||
|
{ value: 'inspector', label: '检查器', icon: PanelRight },
|
||||||
|
{ value: 'controls', label: '控制台', icon: SlidersHorizontal },
|
||||||
|
{ value: 'data', label: '数据录制', icon: Database },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function RightSidebarTabs({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: RightSidebarView;
|
||||||
|
onChange: (value: RightSidebarView) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="右侧工作区视图"
|
||||||
|
className="grid shrink-0 grid-cols-3 gap-1 border-b border-border bg-panel-muted/40 p-2"
|
||||||
|
>
|
||||||
|
{VIEWS.map((view, index) => {
|
||||||
|
const Icon = view.icon;
|
||||||
|
const selected = value === view.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={view.value}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={selected}
|
||||||
|
tabIndex={selected ? 0 : -1}
|
||||||
|
className={`flex min-w-0 items-center justify-center gap-1.5 rounded-md px-1 py-2 text-[11px] font-medium transition-colors ${
|
||||||
|
selected
|
||||||
|
? 'bg-panel text-accent shadow-sm'
|
||||||
|
: 'text-text-tertiary hover:bg-element-hover hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
onClick={() => onChange(view.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
let nextIndex: number | undefined;
|
||||||
|
if (event.key === 'ArrowLeft') nextIndex = (index + VIEWS.length - 1) % VIEWS.length;
|
||||||
|
else if (event.key === 'ArrowRight') nextIndex = (index + 1) % VIEWS.length;
|
||||||
|
else if (event.key === 'Home') nextIndex = 0;
|
||||||
|
else if (event.key === 'End') nextIndex = VIEWS.length - 1;
|
||||||
|
if (nextIndex === undefined) return;
|
||||||
|
event.preventDefault();
|
||||||
|
onChange(VIEWS[nextIndex].value);
|
||||||
|
const tabs =
|
||||||
|
event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="tab"]');
|
||||||
|
tabs?.[nextIndex]?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||||
|
<span className="truncate">{view.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import type { EditableMapDocument } from '../../map/editor/types';
|
||||||
|
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
|
||||||
|
import { SceneOutliner, countSceneSearchResults } from './SceneOutliner';
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
bodies: [
|
||||||
|
{ id: 0, name: 'world', parentId: 0 },
|
||||||
|
{ id: 1, name: 'base', parentId: 0 },
|
||||||
|
{ id: 2, name: 'arm', parentId: 1 },
|
||||||
|
],
|
||||||
|
joints: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: 'arm_joint',
|
||||||
|
bodyId: 2,
|
||||||
|
type: 3,
|
||||||
|
value: 0,
|
||||||
|
min: -1,
|
||||||
|
max: 1,
|
||||||
|
limitMin: -1,
|
||||||
|
limitMax: 1,
|
||||||
|
limited: true,
|
||||||
|
limitsIgnored: false,
|
||||||
|
editable: true,
|
||||||
|
axis: [0, 0, 1],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
model: { nbody: 3 },
|
||||||
|
} as SimulationSnapshot;
|
||||||
|
|
||||||
|
const document: EditableMapDocument = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
mapId: 'warehouse',
|
||||||
|
revision: 0,
|
||||||
|
objects: [
|
||||||
|
{
|
||||||
|
id: 'crate-1',
|
||||||
|
name: '木箱',
|
||||||
|
type: 'box',
|
||||||
|
pose: { position: [0, 0, 0.5], quaternion: [1, 0, 0, 0] },
|
||||||
|
parameters: { sizeX: 1, sizeY: 1, sizeZ: 1 },
|
||||||
|
friction: [1, 0.005, 0.0001],
|
||||||
|
rgba: [0.5, 0.6, 0.7, 1],
|
||||||
|
placementMode: 'auto_ground',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
spawnPoints: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const maps = [
|
||||||
|
{
|
||||||
|
id: 'map-a',
|
||||||
|
name: '仓库',
|
||||||
|
selection: { kind: 'project' as const, descriptorPath: 'maps/warehouse/map.json' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const documents = new Map([['maps/warehouse/map.json', document]]);
|
||||||
|
|
||||||
|
function renderOutliner(overrides: Record<string, unknown> = {}) {
|
||||||
|
const props = {
|
||||||
|
snapshot,
|
||||||
|
maps,
|
||||||
|
documents,
|
||||||
|
selection: null,
|
||||||
|
query: '',
|
||||||
|
loading: false,
|
||||||
|
pendingSceneChangeCount: 0,
|
||||||
|
pendingSceneIds: [],
|
||||||
|
onSelectBody: vi.fn(),
|
||||||
|
onSelectJoint: vi.fn(),
|
||||||
|
onJointHover: vi.fn(),
|
||||||
|
onSelectMap: vi.fn(),
|
||||||
|
onSelectMapObject: vi.fn(),
|
||||||
|
onRemoveMap: vi.fn(),
|
||||||
|
onApplyScene: vi.fn(),
|
||||||
|
onDiscardScene: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
render(<SceneOutliner {...props} />);
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SceneOutliner', () => {
|
||||||
|
it('在一棵场景大纲中统一展示机器人、地图实例和地图物体', () => {
|
||||||
|
renderOutliner();
|
||||||
|
expect(screen.getByLabelText('场景资产树')).toHaveTextContent('机器人');
|
||||||
|
expect(screen.getByLabelText('场景资产树')).toHaveTextContent('地图与环境');
|
||||||
|
expect(screen.getByRole('treeitem', { name: /arm_joint/ })).toBeVisible();
|
||||||
|
expect(screen.getByRole('treeitem', { name: /木箱/ })).toBeVisible();
|
||||||
|
expect(countSceneSearchResults(snapshot, maps, documents, '木箱')).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('把所有选择统一上报为稳定 id', () => {
|
||||||
|
const props = renderOutliner();
|
||||||
|
fireEvent.click(screen.getByRole('treeitem', { name: 'base' }));
|
||||||
|
fireEvent.click(screen.getByRole('treeitem', { name: /arm_joint/ }));
|
||||||
|
fireEvent.click(screen.getByRole('treeitem', { name: /^仓库/ }));
|
||||||
|
fireEvent.click(screen.getByRole('treeitem', { name: /木箱/ }));
|
||||||
|
expect(props.onSelectBody).toHaveBeenCalledWith(1);
|
||||||
|
expect(props.onSelectJoint).toHaveBeenCalledWith(7, 2);
|
||||||
|
expect(props.onSelectMap).toHaveBeenCalledWith('map-a');
|
||||||
|
expect(props.onSelectMapObject).toHaveBeenCalledWith('map-a', 'crate-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('在大纲中集中处理场景草稿与实例删除', () => {
|
||||||
|
const props = renderOutliner({
|
||||||
|
pendingSceneChangeCount: 2,
|
||||||
|
pendingSceneIds: ['map-a'],
|
||||||
|
});
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('2 项场景更改待应用');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '应用场景' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '放弃更改' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '删除地图实例 仓库' }));
|
||||||
|
expect(props.onApplyScene).toHaveBeenCalledOnce();
|
||||||
|
expect(props.onDiscardScene).toHaveBeenCalledOnce();
|
||||||
|
expect(props.onRemoveMap).toHaveBeenCalledWith('map-a');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Box, Bot, Layers3, MapPinned, Trash2, Zap } from 'lucide-react';
|
||||||
|
import { Button, EmptySearchState, SearchHighlight } from '../../components/ui';
|
||||||
|
import type { EditableMapDocument } from '../../map/editor/types';
|
||||||
|
import type { PlacedMapAsset } from '../../map/types';
|
||||||
|
import {
|
||||||
|
countModelStructureSearchResults,
|
||||||
|
ModelStructureTree,
|
||||||
|
} from '../../project/ModelStructureTree';
|
||||||
|
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
|
||||||
|
import type { EditorSelection } from '../editorSelection';
|
||||||
|
|
||||||
|
interface VisibleMap {
|
||||||
|
asset: PlacedMapAsset;
|
||||||
|
objects: EditableMapDocument['objects'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDocument(
|
||||||
|
asset: PlacedMapAsset,
|
||||||
|
documents: ReadonlyMap<string, EditableMapDocument>,
|
||||||
|
): EditableMapDocument | undefined {
|
||||||
|
return asset.selection.kind === 'project'
|
||||||
|
? documents.get(asset.selection.descriptorPath)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function countSceneSearchResults(
|
||||||
|
snapshot: SimulationSnapshot | undefined,
|
||||||
|
maps: readonly PlacedMapAsset[],
|
||||||
|
documents: ReadonlyMap<string, EditableMapDocument>,
|
||||||
|
query: string,
|
||||||
|
): number {
|
||||||
|
const normalized = query.trim().toLocaleLowerCase();
|
||||||
|
const robotBodies =
|
||||||
|
snapshot?.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')) ??
|
||||||
|
[];
|
||||||
|
const robotBodyIds = new Set(robotBodies.map((body) => body.id));
|
||||||
|
const robotCount = snapshot
|
||||||
|
? countModelStructureSearchResults(
|
||||||
|
robotBodies,
|
||||||
|
snapshot.joints.filter((joint) => robotBodyIds.has(joint.bodyId)),
|
||||||
|
normalized,
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
if (!normalized)
|
||||||
|
return (
|
||||||
|
robotCount +
|
||||||
|
maps.reduce((count, map) => count + 1 + (mapDocument(map, documents)?.objects.length ?? 0), 0)
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
robotCount +
|
||||||
|
maps.reduce((count, map) => {
|
||||||
|
const document = mapDocument(map, documents);
|
||||||
|
if (map.name.toLocaleLowerCase().includes(normalized))
|
||||||
|
return count + 1 + (document?.objects.length ?? 0);
|
||||||
|
const objectCount =
|
||||||
|
document?.objects.filter((object) =>
|
||||||
|
`${object.name} ${object.type}`.toLocaleLowerCase().includes(normalized),
|
||||||
|
).length ?? 0;
|
||||||
|
return count + (objectCount ? 1 + objectCount : 0);
|
||||||
|
}, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SceneOutliner({
|
||||||
|
snapshot,
|
||||||
|
maps,
|
||||||
|
documents,
|
||||||
|
selection,
|
||||||
|
query,
|
||||||
|
loading,
|
||||||
|
pendingSceneChangeCount,
|
||||||
|
pendingSceneIds,
|
||||||
|
onSelectBody,
|
||||||
|
onSelectJoint,
|
||||||
|
onJointHover,
|
||||||
|
onSelectMap,
|
||||||
|
onSelectMapObject,
|
||||||
|
onRemoveMap,
|
||||||
|
onApplyScene,
|
||||||
|
onDiscardScene,
|
||||||
|
}: {
|
||||||
|
snapshot?: SimulationSnapshot;
|
||||||
|
maps: readonly PlacedMapAsset[];
|
||||||
|
documents: ReadonlyMap<string, EditableMapDocument>;
|
||||||
|
selection: EditorSelection | null;
|
||||||
|
query: string;
|
||||||
|
loading: boolean;
|
||||||
|
pendingSceneChangeCount: number;
|
||||||
|
pendingSceneIds: readonly string[];
|
||||||
|
onSelectBody: (bodyId: number) => void;
|
||||||
|
onSelectJoint: (jointId: number, bodyId: number) => void;
|
||||||
|
onJointHover: (jointId: number | null) => void;
|
||||||
|
onSelectMap: (mapId: string) => void;
|
||||||
|
onSelectMapObject: (mapId: string, objectId: string) => void;
|
||||||
|
onRemoveMap: (mapId: string) => void;
|
||||||
|
onApplyScene: () => void;
|
||||||
|
onDiscardScene: () => void;
|
||||||
|
}) {
|
||||||
|
const normalized = query.trim().toLocaleLowerCase();
|
||||||
|
const robotBodies = useMemo(
|
||||||
|
() =>
|
||||||
|
snapshot?.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')) ??
|
||||||
|
[],
|
||||||
|
[snapshot?.bodies],
|
||||||
|
);
|
||||||
|
const robotBodyIds = useMemo(() => new Set(robotBodies.map((body) => body.id)), [robotBodies]);
|
||||||
|
const robotJoints = useMemo(
|
||||||
|
() => snapshot?.joints.filter((joint) => robotBodyIds.has(joint.bodyId)) ?? [],
|
||||||
|
[snapshot?.joints, robotBodyIds],
|
||||||
|
);
|
||||||
|
const visibleMaps = useMemo<VisibleMap[]>(
|
||||||
|
() =>
|
||||||
|
maps.flatMap((asset) => {
|
||||||
|
const objects = mapDocument(asset, documents)?.objects ?? [];
|
||||||
|
if (!normalized || asset.name.toLocaleLowerCase().includes(normalized))
|
||||||
|
return [{ asset, objects }];
|
||||||
|
const matches = objects.filter((object) =>
|
||||||
|
`${object.name} ${object.type}`.toLocaleLowerCase().includes(normalized),
|
||||||
|
);
|
||||||
|
return matches.length ? [{ asset, objects: matches }] : [];
|
||||||
|
}),
|
||||||
|
[documents, maps, normalized],
|
||||||
|
);
|
||||||
|
const hasRobotMatches = snapshot
|
||||||
|
? countModelStructureSearchResults(robotBodies, robotJoints, normalized) > 0
|
||||||
|
: false;
|
||||||
|
const pending = new Set(pendingSceneIds);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="场景资产树"
|
||||||
|
className="min-h-0 flex-1 overflow-auto px-2 pb-2 panel-scroll"
|
||||||
|
>
|
||||||
|
{pendingSceneChangeCount > 0 && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="mb-2 rounded-lg border border-accent/30 bg-accent-soft p-2.5 text-[10px] text-text-secondary"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 font-medium text-accent">
|
||||||
|
<Zap className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
{pendingSceneChangeCount} 项场景更改待应用
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
<Button variant="primary" disabled={loading} onClick={onApplyScene}>
|
||||||
|
应用场景
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" disabled={loading} onClick={onDiscardScene}>
|
||||||
|
放弃更改
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{snapshot && hasRobotMatches && (
|
||||||
|
<details open className="group/robot">
|
||||||
|
<summary className="flex cursor-pointer select-none items-center gap-2 rounded px-1.5 py-1.5 text-xs font-semibold text-text-primary hover:bg-element-hover">
|
||||||
|
<Bot className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">机器人</span>
|
||||||
|
<span className="technical-value text-[9px] font-normal text-text-tertiary">
|
||||||
|
{robotBodies.length} Body
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="ml-2 border-l border-border pl-1">
|
||||||
|
<ModelStructureTree
|
||||||
|
bodies={robotBodies}
|
||||||
|
joints={robotJoints}
|
||||||
|
query={query}
|
||||||
|
selectedBodyId={selection?.kind === 'body' ? selection.bodyId : undefined}
|
||||||
|
selectedJointId={selection?.kind === 'joint' ? selection.jointId : undefined}
|
||||||
|
onSelectBody={onSelectBody}
|
||||||
|
onSelectJoint={(joint) => onSelectJoint(joint.id, joint.bodyId)}
|
||||||
|
onJointHover={onJointHover}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleMaps.length > 0 && (
|
||||||
|
<details open className="mt-1">
|
||||||
|
<summary className="flex cursor-pointer select-none items-center gap-2 rounded px-1.5 py-1.5 text-xs font-semibold text-text-primary hover:bg-element-hover">
|
||||||
|
<Layers3 className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">地图与环境</span>
|
||||||
|
<span className="technical-value text-[9px] font-normal text-text-tertiary">
|
||||||
|
{maps.length} 实例
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<ul role="tree" aria-label="地图物体树" className="ml-2 border-l border-border pl-1">
|
||||||
|
{visibleMaps.map(({ asset, objects }) => {
|
||||||
|
const mapSelected =
|
||||||
|
(selection?.kind === 'map' || selection?.kind === 'map-object') &&
|
||||||
|
selection.mapAssetId === asset.id;
|
||||||
|
return (
|
||||||
|
<li key={asset.id} role="none">
|
||||||
|
<div
|
||||||
|
className={`flex items-center rounded ${
|
||||||
|
selection?.kind === 'map' && mapSelected
|
||||||
|
? 'bg-accent-soft text-accent'
|
||||||
|
: 'text-text-secondary hover:bg-element-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="treeitem"
|
||||||
|
aria-selected={selection?.kind === 'map' && mapSelected}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-1.5 px-1.5 py-1 text-left text-xs"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => onSelectMap(asset.id)}
|
||||||
|
>
|
||||||
|
<MapPinned className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
<SearchHighlight text={asset.name} query={query} />
|
||||||
|
</span>
|
||||||
|
{pending.has(asset.id) && (
|
||||||
|
<span className="rounded bg-warning/10 px-1 text-[9px] text-warning">
|
||||||
|
待应用
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`删除地图实例 ${asset.name}`}
|
||||||
|
className="mr-1 rounded p-1 text-text-tertiary hover:bg-danger/10 hover:text-danger"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => onRemoveMap(asset.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{objects.length > 0 && (
|
||||||
|
<ul role="group" className="ml-3 border-l border-border pl-1">
|
||||||
|
{objects.map((object) => {
|
||||||
|
const selected =
|
||||||
|
selection?.kind === 'map-object' &&
|
||||||
|
selection.mapAssetId === asset.id &&
|
||||||
|
selection.objectId === object.id;
|
||||||
|
return (
|
||||||
|
<li role="none" key={object.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="treeitem"
|
||||||
|
aria-selected={selected}
|
||||||
|
disabled={loading}
|
||||||
|
className={`flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-[11px] ${
|
||||||
|
selected
|
||||||
|
? 'bg-accent-soft text-accent'
|
||||||
|
: 'text-text-secondary hover:bg-element-hover'
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelectMapObject(asset.id, object.id)}
|
||||||
|
>
|
||||||
|
<Box className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
<SearchHighlight text={object.name} query={query} />
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-text-tertiary">{object.type}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasRobotMatches && !visibleMaps.length && (
|
||||||
|
<EmptySearchState label={normalized ? '没有匹配的场景对象' : '加载模型后显示场景层级'} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
import { Dialog, Kbd, Separator } from '../../components/ui';
|
import { Dialog, Kbd, Separator } from '../../components/ui';
|
||||||
const shortcuts = [
|
const shortcuts = [
|
||||||
['Space', '播放 / 暂停'],
|
['Space', '播放 / 暂停'],
|
||||||
['R', '重置仿真'],
|
['R', '重置仿真(非地图编辑)'],
|
||||||
['1', '选择模式'],
|
['1', '选择模式'],
|
||||||
['2', '关节拖动'],
|
['2', '关节拖动'],
|
||||||
['3', '外力施加'],
|
['3', '外力施加'],
|
||||||
|
['W / E / R', '地图移动 / 旋转 / 缩放'],
|
||||||
|
['F', '聚焦当前对象'],
|
||||||
|
['Delete', '删除当前地图对象'],
|
||||||
|
['Ctrl / Cmd + S', '保存并编译地图草稿'],
|
||||||
|
['Ctrl / Cmd + Z', '撤销地图编辑'],
|
||||||
|
['Ctrl / Cmd + Y / Shift + Z', '重做地图编辑'],
|
||||||
|
['G', '切换地图网格吸附'],
|
||||||
|
['Esc', '取消选择或关闭浮层'],
|
||||||
];
|
];
|
||||||
export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +37,8 @@ export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose:
|
|||||||
<li>左键拖动:旋转相机或执行当前交互工具</li>
|
<li>左键拖动:旋转相机或执行当前交互工具</li>
|
||||||
<li>右键拖动:平移相机</li>
|
<li>右键拖动:平移相机</li>
|
||||||
<li>滚轮:缩放视口</li>
|
<li>滚轮:缩放视口</li>
|
||||||
<li>选择物体后可在右侧“属性”中查看信息</li>
|
<li>选择物体后可在右侧 Inspector 中编辑尺寸、位姿、材质和摩擦</li>
|
||||||
|
<li>从左侧资产库拖动几何障碍物到视口地面即可放置</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -7,20 +7,22 @@ export function SidebarPanel({
|
|||||||
side,
|
side,
|
||||||
children,
|
children,
|
||||||
visible = true,
|
visible = true,
|
||||||
|
icon,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
side: 'left' | 'right';
|
side: 'left' | 'right';
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
|
icon?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}>
|
<ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}>
|
||||||
<aside
|
<aside
|
||||||
className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side === 'left' ? 'border-r' : 'border-l'} border-border`}
|
className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side === 'left' ? 'border-r' : 'border-l'} border-border`}
|
||||||
>
|
>
|
||||||
<h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel/95 px-3 text-sm font-semibold tracking-tight text-text-primary">
|
<h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-gradient-to-r from-panel via-surface/70 to-panel px-3 text-sm font-semibold tracking-tight text-text-primary shadow-[inset_0_-1px_0_rgb(255_255_255/0.025)]">
|
||||||
<span className="grid h-6 w-6 place-items-center rounded-md bg-accent-soft text-accent">
|
<span className="grid h-6 w-6 place-items-center rounded-md bg-accent-soft text-accent [&>svg]:h-3.5 [&>svg]:w-3.5">
|
||||||
<Settings2 aria-hidden="true" className="h-3.5 w-3.5" />
|
{icon ?? <Settings2 aria-hidden="true" className="h-3.5 w-3.5" />}
|
||||||
</span>
|
</span>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Crosshair, Hand, MousePointer2, RotateCcw } from 'lucide-react';
|
import { Crosshair, Hand, MapPinned, MousePointer2, RotateCcw } from 'lucide-react';
|
||||||
import type { InteractionMode } from '../../viewer/MuJoCoViewer';
|
import type { InteractionMode } from '../../viewer/MuJoCoViewer';
|
||||||
import type { ViewerDisplayOptions } from '../../viewer/displayOptions';
|
import type { ViewerDisplayOptions } from '../../viewer/displayOptions';
|
||||||
import { IconButton, ToolbarToggleGroup, type ToolbarItem } from '../../components/ui';
|
import { IconButton, ToolbarToggleGroup, type ToolbarItem } from '../../components/ui';
|
||||||
@@ -11,12 +11,18 @@ const tools: ToolbarItem<InteractionMode>[] = [
|
|||||||
export function ViewerToolDock({
|
export function ViewerToolDock({
|
||||||
mode,
|
mode,
|
||||||
display,
|
display,
|
||||||
|
mapEditContext,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
onDisplayChange,
|
onDisplayChange,
|
||||||
onResetCamera,
|
onResetCamera,
|
||||||
}: {
|
}: {
|
||||||
mode: InteractionMode;
|
mode: InteractionMode;
|
||||||
display: ViewerDisplayOptions;
|
display: ViewerDisplayOptions;
|
||||||
|
mapEditContext?: {
|
||||||
|
active: boolean;
|
||||||
|
label: string;
|
||||||
|
onActivate: () => void;
|
||||||
|
};
|
||||||
onModeChange: (mode: InteractionMode) => void;
|
onModeChange: (mode: InteractionMode) => void;
|
||||||
onDisplayChange: (next: ViewerDisplayOptions) => void;
|
onDisplayChange: (next: ViewerDisplayOptions) => void;
|
||||||
onResetCamera: () => void;
|
onResetCamera: () => void;
|
||||||
@@ -24,6 +30,18 @@ export function ViewerToolDock({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<ToolbarToggleGroup items={tools} value={mode} onChange={onModeChange} label="视口交互模式" />
|
<ToolbarToggleGroup items={tools} value={mode} onChange={onModeChange} label="视口交互模式" />
|
||||||
|
{mapEditContext && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`地图编辑联动:${mapEditContext.label}`}
|
||||||
|
aria-pressed={mapEditContext.active}
|
||||||
|
onClick={mapEditContext.onActivate}
|
||||||
|
className={`hidden h-7 items-center gap-1.5 rounded-lg border px-2 text-[10px] font-semibold transition-colors md:flex ${mapEditContext.active ? 'border-accent/40 bg-accent-soft text-accent' : 'border-border bg-surface/80 text-text-tertiary hover:bg-element-hover hover:text-text-primary'}`}
|
||||||
|
>
|
||||||
|
<MapPinned className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
{mapEditContext.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<ViewerDisplayPopover value={display} onChange={onDisplayChange} />
|
<ViewerDisplayPopover value={display} onChange={onDisplayChange} />
|
||||||
<IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}>
|
<IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}>
|
||||||
<RotateCcw className="h-3.5 w-3.5" />
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ export function ViewportHUD({
|
|||||||
mode,
|
mode,
|
||||||
selection,
|
selection,
|
||||||
ready,
|
ready,
|
||||||
|
mapEditing = false,
|
||||||
}: {
|
}: {
|
||||||
paused: boolean;
|
paused: boolean;
|
||||||
mode: InteractionMode;
|
mode: InteractionMode;
|
||||||
selection: ViewerSelection | null;
|
selection: ViewerSelection | null;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
|
mapEditing?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (!ready) return null;
|
if (!ready) return null;
|
||||||
return (
|
return (
|
||||||
@@ -45,7 +47,7 @@ export function ViewportHUD({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
aria-label="视口操作提示"
|
aria-label="视口操作提示"
|
||||||
className="pointer-events-none absolute bottom-3 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel px-3 py-1.5 text-[10px] text-text-secondary shadow-xl lg:flex"
|
className="pointer-events-none absolute bottom-14 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel/85 px-3 py-1.5 text-[10px] text-text-secondary shadow-xl backdrop-blur lg:flex"
|
||||||
>
|
>
|
||||||
<Mouse aria-hidden="true" className="h-3 w-3 text-text-secondary" />
|
<Mouse aria-hidden="true" className="h-3 w-3 text-text-secondary" />
|
||||||
<span>{primaryGestures[mode]}</span>
|
<span>{primaryGestures[mode]}</span>
|
||||||
@@ -57,7 +59,20 @@ export function ViewportHUD({
|
|||||||
·
|
·
|
||||||
</span>
|
</span>
|
||||||
<span>滚轮缩放</span>
|
<span>滚轮缩放</span>
|
||||||
{mode !== 'select' && (
|
{mapEditing && mode === 'select' ? (
|
||||||
|
<>
|
||||||
|
<span aria-hidden="true" className="text-border-strong">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<Kbd>W</Kbd>
|
||||||
|
<Kbd>E</Kbd>
|
||||||
|
<Kbd>R</Kbd>
|
||||||
|
<span>变换</span>
|
||||||
|
<Kbd>F</Kbd>
|
||||||
|
<span>聚焦</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
mode !== 'select' && (
|
||||||
<>
|
<>
|
||||||
<span aria-hidden="true" className="text-border-strong">
|
<span aria-hidden="true" className="text-border-strong">
|
||||||
·
|
·
|
||||||
@@ -65,6 +80,7 @@ export function ViewportHUD({
|
|||||||
<Kbd>1</Kbd>
|
<Kbd>1</Kbd>
|
||||||
<span>旋转视角</span>
|
<span>旋转视角</span>
|
||||||
</>
|
</>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function WorkbenchHeader({
|
|||||||
onToggleFullscreen: () => void;
|
onToggleFullscreen: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel/95 px-2.5 shadow-[0_1px_0_rgba(255,255,255,0.02)] backdrop-blur-md">
|
<header className="workbench-header relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border px-2.5 shadow-[0_1px_0_rgba(255,255,255,0.02)] backdrop-blur-md">
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
<div className="mr-2 hidden items-center gap-2 border-r border-border pr-3 xl:flex">
|
<div className="mr-2 hidden items-center gap-2 border-r border-border pr-3 xl:flex">
|
||||||
<span className="grid h-7 w-7 place-items-center rounded-lg border border-accent/25 bg-accent-soft text-accent shadow-sm">
|
<span className="grid h-7 w-7 place-items-center rounded-lg border border-accent/25 bg-accent-soft text-accent shadow-sm">
|
||||||
@@ -170,8 +170,8 @@ export function WorkbenchHeader({
|
|||||||
<PanelLeft className="h-4 w-4" />
|
<PanelLeft className="h-4 w-4" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
tooltip={rightOpen ? '隐藏属性面板' : '显示属性面板'}
|
tooltip={rightOpen ? '隐藏右侧面板' : '显示右侧面板'}
|
||||||
aria-label={rightOpen ? '隐藏属性面板' : '显示属性面板'}
|
aria-label={rightOpen ? '隐藏右侧面板' : '显示右侧面板'}
|
||||||
aria-expanded={rightOpen}
|
aria-expanded={rightOpen}
|
||||||
onClick={onToggleRight}
|
onClick={onToggleRight}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
|
||||||
|
import { WorkspaceToolsPanel } from './WorkspaceToolsPanel';
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
actuators: [],
|
||||||
|
joints: [],
|
||||||
|
controller: { enabled: true },
|
||||||
|
rlPolicy: { enabled: false },
|
||||||
|
} as unknown as SimulationSnapshot;
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
function props(): ComponentProps<typeof WorkspaceToolsPanel> {
|
||||||
|
return {
|
||||||
|
active: 'controls',
|
||||||
|
snapshot,
|
||||||
|
loading: false,
|
||||||
|
ignoreJointLimits: false,
|
||||||
|
jointAdvanced: false,
|
||||||
|
angleUnit: 'rad',
|
||||||
|
forceScale: 50,
|
||||||
|
controllerPaths: [],
|
||||||
|
controllerStatus: { enabled: true } as ComponentProps<
|
||||||
|
typeof WorkspaceToolsPanel
|
||||||
|
>['controllerStatus'],
|
||||||
|
policyPaths: [],
|
||||||
|
policyStatus: { enabled: false } as ComponentProps<typeof WorkspaceToolsPanel>['policyStatus'],
|
||||||
|
onResetJoints: noop,
|
||||||
|
onToggleJointLimits: noop,
|
||||||
|
onToggleAdvanced: noop,
|
||||||
|
onToggleAngleUnit: noop,
|
||||||
|
onActuator: noop,
|
||||||
|
onActuatorParameters: noop,
|
||||||
|
onJoint: noop,
|
||||||
|
onForceScale: noop,
|
||||||
|
onSelectControllerPath: noop,
|
||||||
|
onLoadControllerPath: noop,
|
||||||
|
onImportController: noop,
|
||||||
|
onToggleController: noop,
|
||||||
|
onControllerCommand: noop,
|
||||||
|
onRemoveController: noop,
|
||||||
|
onSelectPolicyPath: noop,
|
||||||
|
onLoadPolicyPath: noop,
|
||||||
|
onImportPolicy: noop,
|
||||||
|
onTogglePolicy: noop,
|
||||||
|
onPolicyCommand: noop,
|
||||||
|
onRemovePolicy: noop,
|
||||||
|
onDataRecorderConfigure: noop,
|
||||||
|
onDataRecordingStart: noop,
|
||||||
|
onDataRecordingStop: noop,
|
||||||
|
onDataRecordingClear: noop,
|
||||||
|
onDataRecordingExport: noop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WorkspaceToolsPanel', () => {
|
||||||
|
it('按实时控制到自动化流程排序,并默认折叠所有控制台组件', () => {
|
||||||
|
render(<WorkspaceToolsPanel {...props()} />);
|
||||||
|
|
||||||
|
const sections = screen
|
||||||
|
.getAllByRole('button')
|
||||||
|
.filter((button) => button.hasAttribute('aria-expanded'));
|
||||||
|
const expected = [
|
||||||
|
['执行器实时控制', '控制'],
|
||||||
|
['关节姿态调试', '调试'],
|
||||||
|
['外力交互参数', '交互'],
|
||||||
|
['Python 脚本控制', '控制', '运行'],
|
||||||
|
['ONNX 策略运行', '推理', '停止'],
|
||||||
|
['强化学习任务', '训练'],
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(sections).toHaveLength(expected.length);
|
||||||
|
sections.forEach((section, index) => {
|
||||||
|
expect(section).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
expected[index].forEach((label) => expect(section).toHaveTextContent(label));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
|
||||||
|
import { PythonControllerPanel } from '../../controller/PythonControllerPanel';
|
||||||
|
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
|
||||||
|
import { RLPolicyPanel } from '../../rl/RLPolicyPanel';
|
||||||
|
import { ActuatorControl, ControlSlider } from '../../simulation/ActuatorControl';
|
||||||
|
import type { ActuatorParameters, SimulationSnapshot } from '../../simulation/SimulationSession';
|
||||||
|
import type { DataRecorderConfig } from '../../telemetry/DataRecorder';
|
||||||
|
import { DataRecordingPanel } from '../../telemetry/DataRecordingPanel';
|
||||||
|
import { LocalTrainingPanel } from '../../training/LocalTrainingPanel';
|
||||||
|
import { Badge, Button, CollapsibleSection } from '../../components/ui';
|
||||||
|
import type { RightSidebarView } from './RightSidebarTabs';
|
||||||
|
|
||||||
|
export type WorkspaceTool = Exclude<RightSidebarView, 'inspector'>;
|
||||||
|
|
||||||
|
function ConsoleSectionBadges({
|
||||||
|
category,
|
||||||
|
count,
|
||||||
|
state,
|
||||||
|
running = false,
|
||||||
|
}: {
|
||||||
|
category: string;
|
||||||
|
count?: number;
|
||||||
|
state?: string;
|
||||||
|
running?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="flex shrink-0 items-center gap-1">
|
||||||
|
<Badge tone="accent">{category}</Badge>
|
||||||
|
{count !== undefined && <Badge>{count}</Badge>}
|
||||||
|
{state && <Badge tone={running ? 'success' : 'neutral'}>{state}</Badge>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceToolsPanel({
|
||||||
|
active,
|
||||||
|
snapshot,
|
||||||
|
loading,
|
||||||
|
ignoreJointLimits,
|
||||||
|
jointAdvanced,
|
||||||
|
angleUnit,
|
||||||
|
forceScale,
|
||||||
|
controllerPaths,
|
||||||
|
selectedControllerPath,
|
||||||
|
controllerStatus,
|
||||||
|
policyPaths,
|
||||||
|
selectedPolicyPath,
|
||||||
|
policyStatus,
|
||||||
|
onResetJoints,
|
||||||
|
onToggleJointLimits,
|
||||||
|
onToggleAdvanced,
|
||||||
|
onToggleAngleUnit,
|
||||||
|
onActuator,
|
||||||
|
onActuatorParameters,
|
||||||
|
onJoint,
|
||||||
|
onForceScale,
|
||||||
|
onSelectControllerPath,
|
||||||
|
onLoadControllerPath,
|
||||||
|
onImportController,
|
||||||
|
onToggleController,
|
||||||
|
onControllerCommand,
|
||||||
|
onRemoveController,
|
||||||
|
onSelectPolicyPath,
|
||||||
|
onLoadPolicyPath,
|
||||||
|
onImportPolicy,
|
||||||
|
onTogglePolicy,
|
||||||
|
onPolicyCommand,
|
||||||
|
onRemovePolicy,
|
||||||
|
onDataRecorderConfigure,
|
||||||
|
onDataRecordingStart,
|
||||||
|
onDataRecordingStop,
|
||||||
|
onDataRecordingClear,
|
||||||
|
onDataRecordingExport,
|
||||||
|
}: {
|
||||||
|
active: WorkspaceTool;
|
||||||
|
snapshot?: SimulationSnapshot;
|
||||||
|
loading: boolean;
|
||||||
|
ignoreJointLimits: boolean;
|
||||||
|
jointAdvanced: boolean;
|
||||||
|
angleUnit: 'rad' | 'deg';
|
||||||
|
forceScale: number;
|
||||||
|
controllerPaths: string[];
|
||||||
|
selectedControllerPath?: string;
|
||||||
|
controllerStatus?: ControllerStatus;
|
||||||
|
policyPaths: string[];
|
||||||
|
selectedPolicyPath?: string;
|
||||||
|
policyStatus?: RLPolicyStatus;
|
||||||
|
onResetJoints: () => void;
|
||||||
|
onToggleJointLimits: () => void;
|
||||||
|
onToggleAdvanced: () => void;
|
||||||
|
onToggleAngleUnit: () => void;
|
||||||
|
onActuator: (id: number, value: number) => void;
|
||||||
|
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
|
||||||
|
onJoint: (id: number, value: number) => void;
|
||||||
|
onForceScale: (value: number) => void;
|
||||||
|
onSelectControllerPath: (path: string) => void;
|
||||||
|
onLoadControllerPath: (path: string) => void;
|
||||||
|
onImportController: (file: File) => void;
|
||||||
|
onToggleController: (enabled: boolean) => void;
|
||||||
|
onControllerCommand: (command: ControllerCommand) => void;
|
||||||
|
onRemoveController: () => void;
|
||||||
|
onSelectPolicyPath: (path: string) => void;
|
||||||
|
onLoadPolicyPath: (path: string) => void;
|
||||||
|
onImportPolicy: (file: File) => void;
|
||||||
|
onTogglePolicy: (enabled: boolean) => void;
|
||||||
|
onPolicyCommand: (command: RLCommand) => void;
|
||||||
|
onRemovePolicy: () => void;
|
||||||
|
onDataRecorderConfigure: (patch: Partial<DataRecorderConfig>) => void;
|
||||||
|
onDataRecordingStart: () => void;
|
||||||
|
onDataRecordingStop: () => void;
|
||||||
|
onDataRecordingClear: () => void;
|
||||||
|
onDataRecordingExport: (format: 'csv' | 'json') => void;
|
||||||
|
}) {
|
||||||
|
const resolvedControllerStatus = controllerStatus ?? snapshot?.controller;
|
||||||
|
const resolvedPolicyStatus = policyStatus ?? snapshot?.rlPolicy;
|
||||||
|
const controls = snapshot ? (
|
||||||
|
<>
|
||||||
|
<CollapsibleSection
|
||||||
|
title="执行器实时控制"
|
||||||
|
defaultOpen={false}
|
||||||
|
badge={<ConsoleSectionBadges category="控制" count={snapshot.actuators.length} />}
|
||||||
|
>
|
||||||
|
{snapshot.actuators.length ? (
|
||||||
|
snapshot.actuators.map((actuator) => (
|
||||||
|
<ActuatorControl
|
||||||
|
key={actuator.id}
|
||||||
|
actuator={actuator}
|
||||||
|
onControl={(value) => onActuator(actuator.id, value)}
|
||||||
|
onParameters={(parameters) => onActuatorParameters(actuator.id, parameters)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-text-tertiary">模型没有驱动器</p>
|
||||||
|
)}
|
||||||
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection
|
||||||
|
title="关节姿态调试"
|
||||||
|
defaultOpen={false}
|
||||||
|
badge={<ConsoleSectionBadges category="调试" count={snapshot.joints.length} />}
|
||||||
|
>
|
||||||
|
<div className="mb-4 grid grid-cols-2 gap-2">
|
||||||
|
<Button onClick={onResetJoints}>重置关节</Button>
|
||||||
|
<Button
|
||||||
|
variant={ignoreJointLimits ? 'primary' : 'secondary'}
|
||||||
|
aria-pressed={ignoreJointLimits}
|
||||||
|
onClick={onToggleJointLimits}
|
||||||
|
>
|
||||||
|
忽略关节限位
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={jointAdvanced ? 'primary' : 'secondary'}
|
||||||
|
aria-pressed={jointAdvanced}
|
||||||
|
onClick={onToggleAdvanced}
|
||||||
|
>
|
||||||
|
高级
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={angleUnit === 'deg' ? 'primary' : 'secondary'}
|
||||||
|
aria-pressed={angleUnit === 'deg'}
|
||||||
|
onClick={onToggleAngleUnit}
|
||||||
|
>
|
||||||
|
{angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{snapshot.joints.map((joint) => {
|
||||||
|
const scale = joint.type === 3 && angleUnit === 'deg' ? 180 / Math.PI : 1;
|
||||||
|
const unit =
|
||||||
|
joint.type === 3 ? (angleUnit === 'deg' ? '°' : ' rad') : joint.type === 2 ? ' m' : '';
|
||||||
|
return (
|
||||||
|
<ControlSlider
|
||||||
|
key={joint.id}
|
||||||
|
label={`${joint.name}${joint.editable ? '' : '(只读)'}`}
|
||||||
|
value={joint.value * scale}
|
||||||
|
min={joint.min * scale}
|
||||||
|
max={joint.max * scale}
|
||||||
|
unit={unit}
|
||||||
|
advanced={jointAdvanced}
|
||||||
|
limited={joint.limited}
|
||||||
|
limitsIgnored={joint.limitsIgnored}
|
||||||
|
limitMin={joint.limitMin * scale}
|
||||||
|
limitMax={joint.limitMax * scale}
|
||||||
|
disabled={!joint.editable}
|
||||||
|
onChange={(value) => onJoint(joint.id, value / scale)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection
|
||||||
|
title="外力交互参数"
|
||||||
|
defaultOpen={false}
|
||||||
|
badge={<ConsoleSectionBadges category="交互" />}
|
||||||
|
>
|
||||||
|
<ControlSlider
|
||||||
|
label={`${forceScale.toFixed(0)} N/屏幕单位`}
|
||||||
|
value={forceScale}
|
||||||
|
min={5}
|
||||||
|
max={200}
|
||||||
|
onChange={onForceScale}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-text-tertiary">
|
||||||
|
选择“外力施加”,在动态物体上按住拖动,松开即清零。
|
||||||
|
</p>
|
||||||
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection
|
||||||
|
title="Python 脚本控制"
|
||||||
|
defaultOpen={false}
|
||||||
|
badge={
|
||||||
|
<ConsoleSectionBadges
|
||||||
|
category="控制"
|
||||||
|
state={
|
||||||
|
resolvedControllerStatus
|
||||||
|
? resolvedControllerStatus.enabled
|
||||||
|
? '运行'
|
||||||
|
: '停止'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
running={Boolean(resolvedControllerStatus?.enabled)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PythonControllerPanel
|
||||||
|
paths={controllerPaths}
|
||||||
|
selectedPath={selectedControllerPath}
|
||||||
|
status={resolvedControllerStatus}
|
||||||
|
loading={loading}
|
||||||
|
onSelectPath={onSelectControllerPath}
|
||||||
|
onLoadPath={onLoadControllerPath}
|
||||||
|
onImport={onImportController}
|
||||||
|
onToggle={onToggleController}
|
||||||
|
onCommand={onControllerCommand}
|
||||||
|
onRemove={onRemoveController}
|
||||||
|
/>
|
||||||
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection
|
||||||
|
title="ONNX 策略运行"
|
||||||
|
defaultOpen={false}
|
||||||
|
badge={
|
||||||
|
<ConsoleSectionBadges
|
||||||
|
category="推理"
|
||||||
|
state={
|
||||||
|
resolvedPolicyStatus ? (resolvedPolicyStatus.enabled ? '运行' : '停止') : undefined
|
||||||
|
}
|
||||||
|
running={Boolean(resolvedPolicyStatus?.enabled)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RLPolicyPanel
|
||||||
|
paths={policyPaths}
|
||||||
|
selectedPath={selectedPolicyPath}
|
||||||
|
status={resolvedPolicyStatus}
|
||||||
|
loading={loading}
|
||||||
|
onSelectPath={onSelectPolicyPath}
|
||||||
|
onLoadPath={onLoadPolicyPath}
|
||||||
|
onImport={onImportPolicy}
|
||||||
|
onToggle={onTogglePolicy}
|
||||||
|
onCommand={onPolicyCommand}
|
||||||
|
onRemove={onRemovePolicy}
|
||||||
|
/>
|
||||||
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection
|
||||||
|
title="强化学习任务"
|
||||||
|
defaultOpen={false}
|
||||||
|
badge={<ConsoleSectionBadges category="训练" />}
|
||||||
|
>
|
||||||
|
<LocalTrainingPanel onPolicyReady={onImportPolicy} />
|
||||||
|
</CollapsibleSection>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="p-4 text-sm text-text-tertiary">导入模型后显示控制工具</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
aria-label={active === 'controls' ? '控制台' : '数据录制'}
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto panel-scroll"
|
||||||
|
>
|
||||||
|
{active === 'controls' ? (
|
||||||
|
controls
|
||||||
|
) : snapshot ? (
|
||||||
|
<DataRecordingPanel
|
||||||
|
status={snapshot.telemetry}
|
||||||
|
bodies={snapshot.bodies}
|
||||||
|
onConfigure={onDataRecorderConfigure}
|
||||||
|
onStart={onDataRecordingStart}
|
||||||
|
onStop={onDataRecordingStop}
|
||||||
|
onClear={onDataRecordingClear}
|
||||||
|
onExport={onDataRecordingExport}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="p-4 text-sm text-text-tertiary">导入模型后显示数据录制工具</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export type EditorSelection =
|
||||||
|
| { kind: 'body'; bodyId: number }
|
||||||
|
| { kind: 'joint'; jointId: number; bodyId: number }
|
||||||
|
| { kind: 'map'; mapAssetId: string }
|
||||||
|
| { kind: 'map-object'; mapAssetId: string; objectId: string };
|
||||||
|
|
||||||
|
export function isMapEditorSelection(
|
||||||
|
selection: EditorSelection | null,
|
||||||
|
): selection is Extract<EditorSelection, { kind: 'map' | 'map-object' }> {
|
||||||
|
return selection?.kind === 'map' || selection?.kind === 'map-object';
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { useMapEditorShortcuts, type MapEditorShortcutOptions } from './useMapEditorShortcuts';
|
||||||
|
|
||||||
|
function Harness({ options }: { options: MapEditorShortcutOptions }) {
|
||||||
|
useMapEditorShortcuts(options);
|
||||||
|
return <input aria-label="数值字段" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function press(code: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
||||||
|
const event = new KeyboardEvent('keydown', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
code,
|
||||||
|
key: init.key ?? code.replace('Key', '').toLowerCase(),
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
function options(patch: Partial<MapEditorShortcutOptions> = {}): MapEditorShortcutOptions {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
mapEditing: true,
|
||||||
|
dirty: true,
|
||||||
|
loading: false,
|
||||||
|
hasSelection: true,
|
||||||
|
canDelete: true,
|
||||||
|
canScale: true,
|
||||||
|
onTransformMode: vi.fn(),
|
||||||
|
onFocusSelection: vi.fn(),
|
||||||
|
onDeleteSelection: vi.fn(),
|
||||||
|
onToggleSnapping: vi.fn(),
|
||||||
|
onSave: vi.fn(),
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useMapEditorShortcuts', () => {
|
||||||
|
it('处理 W/E/R、F、Delete 与 Ctrl+S', () => {
|
||||||
|
const value = options();
|
||||||
|
render(<Harness options={value} />);
|
||||||
|
press('KeyW');
|
||||||
|
press('KeyE');
|
||||||
|
press('KeyR');
|
||||||
|
press('KeyF');
|
||||||
|
press('KeyG');
|
||||||
|
press('Delete', { key: 'Delete' });
|
||||||
|
const saveEvent = press('KeyS', { key: 's', ctrlKey: true });
|
||||||
|
|
||||||
|
expect(value.onTransformMode).toHaveBeenNthCalledWith(1, 'translate');
|
||||||
|
expect(value.onTransformMode).toHaveBeenNthCalledWith(2, 'rotate');
|
||||||
|
expect(value.onTransformMode).toHaveBeenNthCalledWith(3, 'scale');
|
||||||
|
expect(value.onFocusSelection).toHaveBeenCalledOnce();
|
||||||
|
expect(value.onDeleteSelection).toHaveBeenCalledOnce();
|
||||||
|
expect(value.onToggleSnapping).toHaveBeenCalledOnce();
|
||||||
|
expect(value.onSave).toHaveBeenCalledOnce();
|
||||||
|
expect(saveEvent.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('文本输入时避免误触变换和删除,但仍允许保存', () => {
|
||||||
|
const value = options();
|
||||||
|
const view = render(<Harness options={value} />);
|
||||||
|
const input = view.getByLabelText('数值字段');
|
||||||
|
input.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
key: 'Delete',
|
||||||
|
code: 'Delete',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
input.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
key: 's',
|
||||||
|
code: 'KeyS',
|
||||||
|
ctrlKey: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(value.onDeleteSelection).not.toHaveBeenCalled();
|
||||||
|
expect(value.onSave).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非地图上下文仍支持 F 聚焦,但不劫持变换键', () => {
|
||||||
|
const value = options({ mapEditing: false });
|
||||||
|
render(<Harness options={value} />);
|
||||||
|
press('KeyF');
|
||||||
|
const transformEvent = press('KeyW');
|
||||||
|
expect(value.onFocusSelection).toHaveBeenCalledOnce();
|
||||||
|
expect(value.onTransformMode).not.toHaveBeenCalled();
|
||||||
|
expect(transformEvent.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缩放不可用时消费 R 但不切换模式', () => {
|
||||||
|
const value = options({ canScale: false });
|
||||||
|
render(<Harness options={value} />);
|
||||||
|
const event = press('KeyR');
|
||||||
|
expect(value.onTransformMode).not.toHaveBeenCalled();
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import type { MapEditorTransformMode } from '../../map/editor/types';
|
||||||
|
|
||||||
|
export interface MapEditorShortcutOptions {
|
||||||
|
enabled: boolean;
|
||||||
|
mapEditing: boolean;
|
||||||
|
dirty: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
hasSelection: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
canScale: boolean;
|
||||||
|
onTransformMode: (mode: MapEditorTransformMode) => void;
|
||||||
|
onFocusSelection: () => void;
|
||||||
|
onDeleteSelection: () => void;
|
||||||
|
onToggleSnapping: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextEditingTarget(target: EventTarget | null): boolean {
|
||||||
|
return (
|
||||||
|
target instanceof HTMLElement &&
|
||||||
|
(target.matches('input, textarea, select') || target.isContentEditable)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地图编辑快捷键的唯一监听入口。
|
||||||
|
*
|
||||||
|
* W/E/R: 平移/旋转/缩放,F: 聚焦,Delete: 删除,Ctrl/Cmd+S: 提交草稿。
|
||||||
|
* 对话框优先持有键盘;文本输入期间只保留 Ctrl/Cmd+S,避免误删对象。
|
||||||
|
*/
|
||||||
|
export function useMapEditorShortcuts({
|
||||||
|
enabled,
|
||||||
|
mapEditing,
|
||||||
|
dirty,
|
||||||
|
loading,
|
||||||
|
hasSelection,
|
||||||
|
canDelete,
|
||||||
|
canScale,
|
||||||
|
onTransformMode,
|
||||||
|
onFocusSelection,
|
||||||
|
onDeleteSelection,
|
||||||
|
onToggleSnapping,
|
||||||
|
onSave,
|
||||||
|
}: MapEditorShortcutOptions): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const keydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.defaultPrevented || event.isComposing) return;
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof HTMLElement && target.closest('[role="dialog"]')) return;
|
||||||
|
|
||||||
|
const save =
|
||||||
|
(mapEditing || dirty) && (event.ctrlKey || event.metaKey) && event.code === 'KeyS';
|
||||||
|
if (save) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
if (!loading && dirty && !event.repeat) onSave();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loading || isTextEditingTarget(target) || event.ctrlKey || event.metaKey || event.altKey)
|
||||||
|
return;
|
||||||
|
if (event.code === 'KeyF' && hasSelection) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
onFocusSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mapEditing) return;
|
||||||
|
|
||||||
|
let handled = true;
|
||||||
|
if (event.code === 'KeyW') onTransformMode('translate');
|
||||||
|
else if (event.code === 'KeyE') onTransformMode('rotate');
|
||||||
|
else if (event.code === 'KeyR') {
|
||||||
|
if (canScale) onTransformMode('scale');
|
||||||
|
} else if (event.code === 'KeyG') onToggleSnapping();
|
||||||
|
else if (event.key === 'Delete' && canDelete && !event.repeat) onDeleteSelection();
|
||||||
|
else handled = false;
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', keydown, true);
|
||||||
|
return () => window.removeEventListener('keydown', keydown, true);
|
||||||
|
}, [
|
||||||
|
enabled,
|
||||||
|
mapEditing,
|
||||||
|
dirty,
|
||||||
|
loading,
|
||||||
|
hasSelection,
|
||||||
|
canDelete,
|
||||||
|
canScale,
|
||||||
|
onTransformMode,
|
||||||
|
onFocusSelection,
|
||||||
|
onDeleteSelection,
|
||||||
|
onToggleSnapping,
|
||||||
|
onSave,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ export function Badge({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
title={title}
|
title={title}
|
||||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${toneClass} ${className}`}
|
className={`engineering-badge inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${toneClass} ${className}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function Button({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type={type}
|
type={type}
|
||||||
className={`inline-flex shrink-0 select-none items-center justify-center border font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()}
|
className={`inline-flex shrink-0 select-none items-center justify-center border font-medium transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:hover:-translate-y-px enabled:active:translate-y-0 enabled:active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{icon && (
|
{icon && (
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function IconButton({
|
|||||||
<button
|
<button
|
||||||
type={type}
|
type={type}
|
||||||
aria-pressed={active || undefined}
|
aria-pressed={active || undefined}
|
||||||
className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${active ? 'border-accent/40 bg-accent-soft text-accent' : 'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()}
|
className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${active ? 'tool-active-glow border-accent/40 bg-accent-soft text-accent' : 'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function ResizablePanel({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="separator"
|
role="separator"
|
||||||
aria-label={side === 'left' ? '调整工程面板宽度' : '调整属性面板宽度'}
|
aria-label={side === 'left' ? '调整工程面板宽度' : '调整右侧面板宽度'}
|
||||||
aria-orientation="vertical"
|
aria-orientation="vertical"
|
||||||
aria-valuemin={minWidth}
|
aria-valuemin={minWidth}
|
||||||
aria-valuemax={Math.round(panelMaxWidth(minWidth))}
|
aria-valuemax={Math.round(panelMaxWidth(minWidth))}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { ScrubbableNumberInput } from './ScrubbableNumberInput';
|
||||||
|
|
||||||
|
describe('ScrubbableNumberInput', () => {
|
||||||
|
it('保留标准数值输入行为', () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ScrubbableNumberInput label="位置 X" value={1} step={0.1} onValueChange={onValueChange} />,
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByLabelText('位置 X'), { target: { value: '2.5' } });
|
||||||
|
expect(onValueChange).toHaveBeenCalledWith(2.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('在标签上水平拖拽并支持 Shift 精调', () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ScrubbableNumberInput
|
||||||
|
label="摩擦力"
|
||||||
|
value={1}
|
||||||
|
step={0.1}
|
||||||
|
min={0}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const handle = screen.getByRole('button', { name: '拖拽调节数值' });
|
||||||
|
fireEvent.pointerDown(handle, { button: 0, pointerId: 3, clientX: 20 });
|
||||||
|
fireEvent.pointerMove(window, { pointerId: 3, clientX: 60 });
|
||||||
|
expect(onValueChange).toHaveBeenLastCalledWith(2);
|
||||||
|
fireEvent.pointerMove(window, { pointerId: 3, clientX: 80, shiftKey: true });
|
||||||
|
expect(onValueChange).toHaveBeenLastCalledWith(1.15);
|
||||||
|
fireEvent.pointerUp(window, { pointerId: 3, clientX: 80 });
|
||||||
|
expect(document.body).not.toHaveClass('is-scrubbing-number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('通过键盘箭头进行无鼠标调节', () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ScrubbableNumberInput label="尺寸" value={2} step={0.5} onValueChange={onValueChange} />,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(screen.getByRole('button', { name: '拖拽调节数值' }), {
|
||||||
|
key: 'ArrowRight',
|
||||||
|
});
|
||||||
|
expect(onValueChange).toHaveBeenCalledWith(2.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type InputHTMLAttributes,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
} from 'react';
|
||||||
|
import { GripHorizontal } from 'lucide-react';
|
||||||
|
|
||||||
|
function clamp(value: number, min?: number, max?: number): number {
|
||||||
|
return Math.min(
|
||||||
|
max ?? Number.POSITIVE_INFINITY,
|
||||||
|
Math.max(min ?? Number.NEGATIVE_INFINITY, value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decimals(step: number): number {
|
||||||
|
const source = String(step);
|
||||||
|
if (source.includes('e-')) return Math.min(8, Number(source.split('e-')[1]));
|
||||||
|
return Math.min(8, source.split('.')[1]?.length ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(value: number, step: number, min?: number, max?: number): number {
|
||||||
|
const bounded = clamp(value, min, max);
|
||||||
|
return Number(bounded.toFixed(decimals(step)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScrubbableNumberInputProps extends Omit<
|
||||||
|
InputHTMLAttributes<HTMLInputElement>,
|
||||||
|
'type' | 'value' | 'defaultValue' | 'step' | 'min' | 'max' | 'onChange'
|
||||||
|
> {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
step?: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
onValueChange: (value: number) => void;
|
||||||
|
containerClassName?: string;
|
||||||
|
labelClassName?: string;
|
||||||
|
inputClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工程编辑器数值输入:可键入,也可在字段标签上水平拖拽。
|
||||||
|
* Shift 为精细调节,Alt 为超精细调节,Ctrl/Meta 为粗调。
|
||||||
|
*/
|
||||||
|
export function ScrubbableNumberInput({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
step = 0.1,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
disabled,
|
||||||
|
onValueChange,
|
||||||
|
containerClassName = '',
|
||||||
|
labelClassName = '',
|
||||||
|
inputClassName = '',
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
onFocus: onInputFocus,
|
||||||
|
onBlur: onInputBlur,
|
||||||
|
...inputProps
|
||||||
|
}: ScrubbableNumberInputProps) {
|
||||||
|
const [text, setText] = useState(() => String(value));
|
||||||
|
const [scrubbing, setScrubbing] = useState(false);
|
||||||
|
const labelId = useId();
|
||||||
|
const editing = useRef(false);
|
||||||
|
const cleanup = useRef<() => void>(() => {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing.current && !scrubbing) setText(String(value));
|
||||||
|
}, [value, scrubbing]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
cleanup.current();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = (next: number, quantizationStep = step) => {
|
||||||
|
const normalized = normalize(next, quantizationStep, min, max);
|
||||||
|
setText(String(normalized));
|
||||||
|
onValueChange(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startScrub = (event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||||
|
if (disabled || event.button !== 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
cleanup.current();
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startValue = Number.isFinite(value) ? value : 0;
|
||||||
|
const pointerId = event.pointerId;
|
||||||
|
setScrubbing(true);
|
||||||
|
document.body.classList.add('is-scrubbing-number');
|
||||||
|
|
||||||
|
const move = (moveEvent: PointerEvent) => {
|
||||||
|
if (moveEvent.pointerId !== pointerId) return;
|
||||||
|
const modifier = moveEvent.altKey
|
||||||
|
? 0.01
|
||||||
|
: moveEvent.shiftKey
|
||||||
|
? 0.1
|
||||||
|
: moveEvent.ctrlKey || moveEvent.metaKey
|
||||||
|
? 10
|
||||||
|
: 1;
|
||||||
|
emit(startValue + (moveEvent.clientX - startX) * step * 0.25 * modifier, step * modifier);
|
||||||
|
};
|
||||||
|
const stop = (stopEvent: PointerEvent) => {
|
||||||
|
if (stopEvent.pointerId !== pointerId) return;
|
||||||
|
cleanup.current();
|
||||||
|
};
|
||||||
|
cleanup.current = () => {
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', stop);
|
||||||
|
window.removeEventListener('pointercancel', stop);
|
||||||
|
document.body.classList.remove('is-scrubbing-number');
|
||||||
|
setScrubbing(false);
|
||||||
|
cleanup.current = () => {};
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', stop);
|
||||||
|
window.addEventListener('pointercancel', stop);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`scrubbable-number ${scrubbing ? 'scrubbable-number-active' : ''} ${containerClassName}`.trim()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`scrubbable-number-label ${labelClassName}`.trim()}
|
||||||
|
aria-label="拖拽调节数值"
|
||||||
|
aria-describedby={labelId}
|
||||||
|
title={`拖拽调节 ${label};Shift 精调,Alt 超精调,Ctrl 粗调`}
|
||||||
|
disabled={disabled}
|
||||||
|
onPointerDown={startScrub}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
|
||||||
|
event.preventDefault();
|
||||||
|
const modifier = event.altKey
|
||||||
|
? 0.01
|
||||||
|
: event.shiftKey
|
||||||
|
? 0.1
|
||||||
|
: event.ctrlKey || event.metaKey
|
||||||
|
? 10
|
||||||
|
: 1;
|
||||||
|
emit(value + (event.key === 'ArrowRight' ? 1 : -1) * step * modifier, step * modifier);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span id={labelId} className="min-w-0 truncate">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<GripHorizontal className="h-3 w-3 shrink-0 opacity-45" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
{...inputProps}
|
||||||
|
aria-label={ariaLabel ?? label}
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={text}
|
||||||
|
step={step}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`scrubbable-number-input ${inputClassName}`.trim()}
|
||||||
|
onFocus={(event) => {
|
||||||
|
editing.current = true;
|
||||||
|
onInputFocus?.(event);
|
||||||
|
}}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextText = event.currentTarget.value;
|
||||||
|
setText(nextText);
|
||||||
|
const next = Number(nextText);
|
||||||
|
if (nextText !== '' && Number.isFinite(next)) onValueChange(clamp(next, min, max));
|
||||||
|
}}
|
||||||
|
onBlur={(event) => {
|
||||||
|
editing.current = false;
|
||||||
|
const next = Number(text);
|
||||||
|
if (text !== '' && Number.isFinite(next)) emit(next);
|
||||||
|
else setText(String(value));
|
||||||
|
onInputBlur?.(event);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') event.currentTarget.blur();
|
||||||
|
inputProps.onKeyDown?.(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { VerticalSplitPane } from './VerticalSplitPane';
|
||||||
|
|
||||||
|
const storageKey = 'test-vertical-split-ratio';
|
||||||
|
|
||||||
|
function renderSplit() {
|
||||||
|
render(
|
||||||
|
<VerticalSplitPane
|
||||||
|
first={<div>上方内容</div>}
|
||||||
|
second={<div>下方内容</div>}
|
||||||
|
storageKey={storageKey}
|
||||||
|
separatorLabel="调整上下区域高度"
|
||||||
|
defaultRatio={0.4}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const separator = screen.getByRole('separator', { name: '调整上下区域高度' });
|
||||||
|
Object.defineProperty(separator.parentElement, 'clientHeight', {
|
||||||
|
configurable: true,
|
||||||
|
value: 600,
|
||||||
|
});
|
||||||
|
return separator;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('VerticalSplitPane', () => {
|
||||||
|
beforeEach(() => localStorage.removeItem(storageKey));
|
||||||
|
|
||||||
|
it('支持通过指针上下拖动并持久化分割比例', () => {
|
||||||
|
const separator = renderSplit();
|
||||||
|
|
||||||
|
fireEvent.pointerDown(separator, { button: 0, clientY: 200, pointerId: 1 });
|
||||||
|
fireEvent.pointerMove(window, { clientY: 320, pointerId: 1 });
|
||||||
|
expect(separator).toHaveAttribute('aria-valuenow', '60');
|
||||||
|
expect(
|
||||||
|
Number.parseFloat((separator.previousElementSibling as HTMLElement).style.flexBasis),
|
||||||
|
).toBeCloseTo(60);
|
||||||
|
|
||||||
|
fireEvent.pointerUp(window, { pointerId: 1 });
|
||||||
|
expect(Number(localStorage.getItem(storageKey))).toBeCloseTo(0.6);
|
||||||
|
expect(document.body.style.cursor).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('支持键盘调整、边界限制和双击复位', () => {
|
||||||
|
const separator = renderSplit();
|
||||||
|
|
||||||
|
fireEvent.keyDown(separator, { key: 'End' });
|
||||||
|
expect(separator).toHaveAttribute('aria-valuenow', '72');
|
||||||
|
|
||||||
|
fireEvent.keyDown(separator, { key: 'Home' });
|
||||||
|
expect(separator).toHaveAttribute('aria-valuenow', '27');
|
||||||
|
|
||||||
|
fireEvent.doubleClick(separator);
|
||||||
|
expect(separator).toHaveAttribute('aria-valuenow', '40');
|
||||||
|
expect(Number(localStorage.getItem(storageKey))).toBeCloseTo(0.4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
const HANDLE_SIZE = 8;
|
||||||
|
const FALLBACK_MIN_RATIO = 0.15;
|
||||||
|
const FALLBACK_MAX_RATIO = 0.85;
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||||
|
|
||||||
|
function storedRatio(storageKey: string, fallback: number): number {
|
||||||
|
try {
|
||||||
|
const value = Number(localStorage.getItem(storageKey));
|
||||||
|
return clamp(
|
||||||
|
Number.isFinite(value) && value > 0 ? value : fallback,
|
||||||
|
FALLBACK_MIN_RATIO,
|
||||||
|
FALLBACK_MAX_RATIO,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return clamp(fallback, FALLBACK_MIN_RATIO, FALLBACK_MAX_RATIO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioBounds(height: number, minFirstSize: number, minSecondSize: number) {
|
||||||
|
if (height <= HANDLE_SIZE) {
|
||||||
|
return { min: FALLBACK_MIN_RATIO, max: FALLBACK_MAX_RATIO };
|
||||||
|
}
|
||||||
|
|
||||||
|
const min = minFirstSize / height;
|
||||||
|
const max = (height - HANDLE_SIZE - minSecondSize) / height;
|
||||||
|
if (min <= max) return { min, max };
|
||||||
|
|
||||||
|
const available = Math.max(0, height - HANDLE_SIZE);
|
||||||
|
const totalMinimum = minFirstSize + minSecondSize;
|
||||||
|
const shared = totalMinimum > 0 ? (available * minFirstSize) / totalMinimum / height : 0.5;
|
||||||
|
return { min: shared, max: shared };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VerticalSplitPane({
|
||||||
|
first,
|
||||||
|
second,
|
||||||
|
storageKey,
|
||||||
|
separatorLabel,
|
||||||
|
defaultRatio = 0.5,
|
||||||
|
minFirstSize = 160,
|
||||||
|
minSecondSize = 160,
|
||||||
|
className = '',
|
||||||
|
firstClassName = '',
|
||||||
|
secondClassName = '',
|
||||||
|
}: {
|
||||||
|
first: ReactNode;
|
||||||
|
second: ReactNode;
|
||||||
|
storageKey: string;
|
||||||
|
separatorLabel: string;
|
||||||
|
defaultRatio?: number;
|
||||||
|
minFirstSize?: number;
|
||||||
|
minSecondSize?: number;
|
||||||
|
className?: string;
|
||||||
|
firstClassName?: string;
|
||||||
|
secondClassName?: string;
|
||||||
|
}) {
|
||||||
|
const [ratio, setRatio] = useState(() => storedRatio(storageKey, defaultRatio));
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const ratioRef = useRef(ratio);
|
||||||
|
const cleanupRef = useRef<() => void>(() => {});
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
|
const constrain = (next: number, height = containerRef.current?.clientHeight ?? 0) => {
|
||||||
|
const bounds = ratioBounds(height, minFirstSize, minSecondSize);
|
||||||
|
return clamp(next, bounds.min, bounds.max);
|
||||||
|
};
|
||||||
|
|
||||||
|
const persist = (value: number) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, String(value));
|
||||||
|
} catch {
|
||||||
|
/* 无持久化权限时仍可调整 */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (next: number, save = false) => {
|
||||||
|
const value = constrain(next);
|
||||||
|
ratioRef.current = value;
|
||||||
|
setRatio(value);
|
||||||
|
if (save) persist(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
if (container.clientHeight <= HANDLE_SIZE) return;
|
||||||
|
const bounds = ratioBounds(container.clientHeight, minFirstSize, minSecondSize);
|
||||||
|
const value = clamp(ratioRef.current, bounds.min, bounds.max);
|
||||||
|
if (value === ratioRef.current) return;
|
||||||
|
ratioRef.current = value;
|
||||||
|
setRatio(value);
|
||||||
|
});
|
||||||
|
observer.observe(container);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [minFirstSize, minSecondSize]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
cleanupRef.current();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const start = (event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
cleanupRef.current();
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const origin = event.clientY;
|
||||||
|
const startRatio = ratioRef.current;
|
||||||
|
const height = container.clientHeight;
|
||||||
|
const pointerId = event.pointerId;
|
||||||
|
const previousCursor = document.body.style.cursor;
|
||||||
|
const previousUserSelect = document.body.style.userSelect;
|
||||||
|
|
||||||
|
document.body.style.cursor = 'row-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
setDragging(true);
|
||||||
|
|
||||||
|
const move = (moveEvent: PointerEvent) => {
|
||||||
|
if (moveEvent.pointerId !== pointerId) return;
|
||||||
|
moveEvent.preventDefault();
|
||||||
|
update(startRatio + (moveEvent.clientY - origin) / Math.max(height, 1));
|
||||||
|
};
|
||||||
|
const stop = (stopEvent: PointerEvent) => {
|
||||||
|
if (stopEvent.pointerId !== pointerId) return;
|
||||||
|
persist(ratioRef.current);
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', stop);
|
||||||
|
window.removeEventListener('pointercancel', stop);
|
||||||
|
document.body.style.cursor = previousCursor;
|
||||||
|
document.body.style.userSelect = previousUserSelect;
|
||||||
|
setDragging(false);
|
||||||
|
cleanupRef.current = () => {};
|
||||||
|
};
|
||||||
|
|
||||||
|
cleanupRef.current = cleanup;
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', stop);
|
||||||
|
window.addEventListener('pointercancel', stop);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
const height = containerRef.current?.clientHeight ?? 0;
|
||||||
|
const bounds = ratioBounds(height, minFirstSize, minSecondSize);
|
||||||
|
const step = (event.shiftKey ? 48 : 16) / Math.max(height, 320);
|
||||||
|
let next: number;
|
||||||
|
|
||||||
|
if (event.key === 'Home') next = bounds.min;
|
||||||
|
else if (event.key === 'End') next = bounds.max;
|
||||||
|
else if (event.key === 'ArrowUp') next = ratioRef.current - step;
|
||||||
|
else if (event.key === 'ArrowDown') next = ratioRef.current + step;
|
||||||
|
else return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
update(next, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={`flex min-h-0 flex-1 flex-col ${className}`}>
|
||||||
|
<div
|
||||||
|
id={`${id}-first`}
|
||||||
|
className={`min-h-0 shrink-0 overflow-hidden ${firstClassName}`}
|
||||||
|
style={{ flexBasis: `${ratio * 100}%` }}
|
||||||
|
>
|
||||||
|
{first}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="separator"
|
||||||
|
aria-label={separatorLabel}
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-controls={`${id}-first ${id}-second`}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={Math.round(ratio * 100)}
|
||||||
|
aria-valuetext={`上方区域占 ${Math.round(ratio * 100)}%`}
|
||||||
|
data-dragging={dragging || undefined}
|
||||||
|
onPointerDown={start}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onDoubleClick={() => update(defaultRatio, true)}
|
||||||
|
className="group relative z-20 h-2 w-full shrink-0 touch-none select-none cursor-row-resize bg-transparent outline-none before:absolute before:inset-x-0 before:top-1/2 before:h-px before:-translate-y-1/2 before:bg-border before:transition-colors hover:before:bg-accent focus-visible:before:h-0.5 focus-visible:before:bg-accent data-[dragging=true]:before:h-0.5 data-[dragging=true]:before:bg-accent"
|
||||||
|
>
|
||||||
|
<span className="absolute left-1/2 top-1/2 h-1 w-9 -translate-x-1/2 -translate-y-1/2 rounded-full border border-border bg-panel transition-colors group-hover:border-accent group-focus-visible:border-accent group-data-[dragging=true]:border-accent group-data-[dragging=true]:bg-accent-soft" />
|
||||||
|
</button>
|
||||||
|
<div id={`${id}-second`} className={`min-h-0 flex-1 overflow-hidden ${secondClassName}`}>
|
||||||
|
{second}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export * from './Separator';
|
|||||||
export * from './Skeleton';
|
export * from './Skeleton';
|
||||||
export * from './Tabs';
|
export * from './Tabs';
|
||||||
export * from './ResizablePanel';
|
export * from './ResizablePanel';
|
||||||
|
export * from './VerticalSplitPane';
|
||||||
export * from './Kbd';
|
export * from './Kbd';
|
||||||
export * from './PropertyRow';
|
export * from './PropertyRow';
|
||||||
export * from './CopyButton';
|
export * from './CopyButton';
|
||||||
@@ -22,3 +23,4 @@ export * from './DropdownMenu';
|
|||||||
export * from './ProgressBar';
|
export * from './ProgressBar';
|
||||||
export * from './LiveRegion';
|
export * from './LiveRegion';
|
||||||
export * from './SearchableCombobox';
|
export * from './SearchableCombobox';
|
||||||
|
export * from './ScrubbableNumberInput';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { MAP_ASSET_DRAG_MIME, MAP_ASSET_PLACEMENT_MIME } from './editor/assetCatalog';
|
import { decodeMapLibraryDragPayload, MAP_LIBRARY_DRAG_MIME } from './editor/assetCatalog';
|
||||||
import { MapAssetLibrary } from './MapAssetLibrary';
|
import { MapAssetLibrary } from './MapAssetLibrary';
|
||||||
|
|
||||||
const renderLibrary = (
|
const renderLibrary = (
|
||||||
@@ -16,7 +16,53 @@ const renderLibrary = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
describe('MapAssetLibrary', () => {
|
describe('MapAssetLibrary', () => {
|
||||||
it('按所选放置方式添加认证资产', () => {
|
it('只负责地图包、物理原语和预设地形的放置入口', () => {
|
||||||
|
expect(decodeMapLibraryDragPayload('not-json')).toBeNull();
|
||||||
|
expect(decodeMapLibraryDragPayload('{"kind":"terrain","preset":"unknown"}')).toBeNull();
|
||||||
|
renderLibrary();
|
||||||
|
const library = screen.getByLabelText('地图资产库');
|
||||||
|
expect(library).toHaveTextContent('场景资产库');
|
||||||
|
expect(library).toHaveTextContent('物理几何原语');
|
||||||
|
expect(library).toHaveTextContent('预设地形');
|
||||||
|
expect(library).not.toHaveTextContent('已放置地图');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('地图包使用与资产卡片一致的添加和拖放入口', () => {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
const onAddProjectMap = vi.fn();
|
||||||
|
const dataTransfer = {
|
||||||
|
effectAllowed: 'none',
|
||||||
|
setData: (type: string, value: string) => values.set(type, value),
|
||||||
|
} as unknown as DataTransfer;
|
||||||
|
render(
|
||||||
|
<MapAssetLibrary
|
||||||
|
disabled={false}
|
||||||
|
terrainSize={8}
|
||||||
|
maps={[
|
||||||
|
{
|
||||||
|
descriptorPath: 'maps/warehouse/map.json',
|
||||||
|
schemaVersion: 1,
|
||||||
|
id: 'warehouse',
|
||||||
|
name: '仓库',
|
||||||
|
spawnPoints: [],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onAdd={() => {}}
|
||||||
|
onAddProjectMap={onAddProjectMap}
|
||||||
|
onSelectTerrain={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const source = document.querySelector('[data-project-map="maps/warehouse/map.json"]')!;
|
||||||
|
fireEvent.dragStart(source, { dataTransfer });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '放置工程地图 仓库' }));
|
||||||
|
expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({
|
||||||
|
kind: 'project',
|
||||||
|
descriptorPath: 'maps/warehouse/map.json',
|
||||||
|
});
|
||||||
|
expect(onAddProjectMap).toHaveBeenCalledWith('maps/warehouse/map.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('按所选放置方式添加物理几何原语', () => {
|
||||||
const onAdd = vi.fn();
|
const onAdd = vi.fn();
|
||||||
renderLibrary(onAdd);
|
renderLibrary(onAdd);
|
||||||
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
|
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
|
||||||
@@ -26,7 +72,26 @@ describe('MapAssetLibrary', () => {
|
|||||||
expect(onAdd).toHaveBeenCalledWith('box', 'gravity');
|
expect(onAdd).toHaveBeenCalledWith('box', 'gravity');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('拖动资产时写入类型和放置方式', () => {
|
it('可与视口工具条共享受控放置方式', () => {
|
||||||
|
const onPlacementModeChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<MapAssetLibrary
|
||||||
|
disabled={false}
|
||||||
|
terrainSize={8}
|
||||||
|
placementMode="locked"
|
||||||
|
onPlacementModeChange={onPlacementModeChange}
|
||||||
|
onAdd={() => {}}
|
||||||
|
onSelectTerrain={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText('新增资产放置方式')).toHaveValue('locked');
|
||||||
|
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
|
||||||
|
target: { value: 'gravity' },
|
||||||
|
});
|
||||||
|
expect(onPlacementModeChange).toHaveBeenCalledWith('gravity');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拖动物理原语时写入类型和放置方式', () => {
|
||||||
const values = new Map<string, string>();
|
const values = new Map<string, string>();
|
||||||
const dataTransfer = {
|
const dataTransfer = {
|
||||||
effectAllowed: 'none',
|
effectAllowed: 'none',
|
||||||
@@ -37,15 +102,28 @@ describe('MapAssetLibrary', () => {
|
|||||||
target: { value: 'locked' },
|
target: { value: 'locked' },
|
||||||
});
|
});
|
||||||
fireEvent.dragStart(document.querySelector('[data-map-asset="ramp"]')!, { dataTransfer });
|
fireEvent.dragStart(document.querySelector('[data-map-asset="ramp"]')!, { dataTransfer });
|
||||||
expect(values.get(MAP_ASSET_DRAG_MIME)).toBe('ramp');
|
expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({
|
||||||
expect(values.get(MAP_ASSET_PLACEMENT_MIME)).toBe('locked');
|
kind: 'certified',
|
||||||
|
type: 'ramp',
|
||||||
|
placementMode: 'locked',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('以示意图卡片选择系统参数化地形', () => {
|
it('以示意图卡片添加或拖动预设地形', () => {
|
||||||
const onSelectTerrain = vi.fn();
|
const onSelectTerrain = vi.fn();
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
const dataTransfer = {
|
||||||
|
effectAllowed: 'none',
|
||||||
|
setData: (type: string, value: string) => values.set(type, value),
|
||||||
|
} as unknown as DataTransfer;
|
||||||
renderLibrary(undefined, onSelectTerrain);
|
renderLibrary(undefined, onSelectTerrain);
|
||||||
expect(screen.getAllByText('8.00 × 8.00 m')).toHaveLength(9);
|
expect(screen.getAllByText('8.00 × 8.00 m')).toHaveLength(9);
|
||||||
fireEvent.click(screen.getByRole('button', { name: '添加随机粗糙地形' }));
|
fireEvent.click(screen.getByRole('button', { name: '添加随机粗糙地形' }));
|
||||||
|
fireEvent.dragStart(document.querySelector('[data-system-terrain="wave"]')!, { dataTransfer });
|
||||||
expect(onSelectTerrain).toHaveBeenCalledWith('rough');
|
expect(onSelectTerrain).toHaveBeenCalledWith('rough');
|
||||||
|
expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({
|
||||||
|
kind: 'terrain',
|
||||||
|
preset: 'wave',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { BadgeCheck, CheckCircle2, Mountain, Plus } from 'lucide-react';
|
import { BadgeCheck, CheckCircle2, MapPinned, Mountain, Plus } from 'lucide-react';
|
||||||
import { Button, Select } from '../components/ui';
|
import { Button, Select } from '../components/ui';
|
||||||
import {
|
import {
|
||||||
CERTIFIED_MAP_ASSETS,
|
CERTIFIED_MAP_ASSETS,
|
||||||
MAP_ASSET_DRAG_MIME,
|
encodeMapLibraryDragPayload,
|
||||||
MAP_ASSET_PLACEMENT_MIME,
|
MAP_LIBRARY_DRAG_MIME,
|
||||||
} from './editor/assetCatalog';
|
} from './editor/assetCatalog';
|
||||||
import {
|
import {
|
||||||
MAP_OBJECT_PLACEMENT_LABELS,
|
MAP_OBJECT_PLACEMENT_LABELS,
|
||||||
type EditableMapDocument,
|
|
||||||
type EditableMapObjectType,
|
type EditableMapObjectType,
|
||||||
type MapObjectPlacementMode,
|
type MapObjectPlacementMode,
|
||||||
} from './editor/types';
|
} from './editor/types';
|
||||||
|
import type { MapEntry } from '../project/types';
|
||||||
import {
|
import {
|
||||||
PHYSICAL_MAP_PRESET_LABELS,
|
PHYSICAL_MAP_PRESET_LABELS,
|
||||||
SYSTEM_TERRAIN_PRESETS,
|
SYSTEM_TERRAIN_PRESETS,
|
||||||
@@ -135,58 +135,94 @@ function AddAction({ label }: { label: string }) {
|
|||||||
export function MapAssetLibrary({
|
export function MapAssetLibrary({
|
||||||
disabled,
|
disabled,
|
||||||
terrainSize,
|
terrainSize,
|
||||||
document,
|
maps = [],
|
||||||
|
placementMode: controlledPlacementMode,
|
||||||
|
onPlacementModeChange,
|
||||||
onAdd,
|
onAdd,
|
||||||
|
onAddProjectMap,
|
||||||
onSelectTerrain,
|
onSelectTerrain,
|
||||||
onSelectObject,
|
|
||||||
}: {
|
}: {
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
terrainSize: number;
|
terrainSize: number;
|
||||||
document?: EditableMapDocument | null;
|
maps?: MapEntry[];
|
||||||
|
placementMode?: MapObjectPlacementMode;
|
||||||
|
onPlacementModeChange?: (mode: MapObjectPlacementMode) => void;
|
||||||
onAdd: (
|
onAdd: (
|
||||||
type: EditableMapObjectType,
|
type: EditableMapObjectType,
|
||||||
placementMode: MapObjectPlacementMode,
|
placementMode: MapObjectPlacementMode,
|
||||||
) => void | Promise<void>;
|
) => void | Promise<void>;
|
||||||
|
onAddProjectMap?: (descriptorPath: string) => void;
|
||||||
onSelectTerrain: (preset: SystemTerrainPreset) => void;
|
onSelectTerrain: (preset: SystemTerrainPreset) => void;
|
||||||
onSelectObject?: (id: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [placementMode, setPlacementMode] = useState<MapObjectPlacementMode>('auto_ground');
|
const [internalPlacementMode, setInternalPlacementMode] =
|
||||||
|
useState<MapObjectPlacementMode>('auto_ground');
|
||||||
|
const placementMode = controlledPlacementMode ?? internalPlacementMode;
|
||||||
|
const changePlacementMode = (mode: MapObjectPlacementMode) => {
|
||||||
|
setInternalPlacementMode(mode);
|
||||||
|
onPlacementModeChange?.(mode);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<section aria-label="认证资产" className="space-y-3 p-3">
|
<section aria-label="地图资产库" className="space-y-3 p-3">
|
||||||
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
|
<header>
|
||||||
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-text-primary">
|
||||||
<span>场景结构</span>
|
<MapPinned className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
|
||||||
<span className="technical-value text-[10px] text-text-tertiary">
|
场景资产库
|
||||||
{document?.objects.length ?? 0} 个对象
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{document?.objects.length ? (
|
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||||
<div className="mt-2 max-h-36 space-y-0.5 overflow-auto panel-scroll">
|
从地图包、物理几何原语和预设地形拖放到视口;已放置实例统一在上方场景大纲管理。
|
||||||
{document.objects.map((object) => (
|
</p>
|
||||||
<button
|
</header>
|
||||||
key={object.id}
|
{maps.length > 0 && (
|
||||||
type="button"
|
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
|
||||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] text-text-secondary hover:bg-element-hover hover:text-text-primary"
|
<div className="text-xs font-medium text-text-primary">地图包</div>
|
||||||
onClick={() => onSelectObject?.(object.id)}
|
<p className="mt-1 text-[10px] text-text-tertiary">
|
||||||
|
点击添加或拖到画布定位;实例位姿独立,源内容由同源实例共享。
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{maps.map((map) => (
|
||||||
|
<div
|
||||||
|
key={map.descriptorPath}
|
||||||
|
draggable={!disabled}
|
||||||
|
data-project-map={map.descriptorPath}
|
||||||
|
className={`flex items-center gap-2 rounded border border-border-subtle px-2 py-1.5 ${disabled ? 'opacity-50' : 'cursor-grab hover:border-accent active:cursor-grabbing'}`}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
if (disabled) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.dataTransfer.effectAllowed = 'copy';
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
MAP_LIBRARY_DRAG_MIME,
|
||||||
|
encodeMapLibraryDragPayload({
|
||||||
|
kind: 'project',
|
||||||
|
descriptorPath: map.descriptorPath,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
event.dataTransfer.setData('text/plain', map.name);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />
|
<MapPinned className="h-3.5 w-3.5 shrink-0 text-accent" aria-hidden="true" />
|
||||||
<span className="min-w-0 flex-1 truncate">{object.name}</span>
|
<span className="min-w-0 flex-1 truncate text-[11px] text-text-secondary">
|
||||||
<span className="text-[9px] text-text-tertiary">{object.type}</span>
|
{map.name}
|
||||||
</button>
|
</span>
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`放置工程地图 ${map.name}`}
|
||||||
|
onClick={() => onAddProjectMap?.(map.descriptorPath)}
|
||||||
|
>
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
|
|
||||||
选择工程地图后,这里显示场景对象层级;可从下方资产库创建场景。
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-end justify-between gap-3">
|
<div className="flex items-end justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
|
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
|
||||||
<BadgeCheck className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
|
<BadgeCheck className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
|
||||||
认证资产
|
物理几何原语
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||||
点击添加,或按住资产拖到画布落位。
|
点击添加,或按住资产拖到画布落位。
|
||||||
@@ -199,7 +235,7 @@ export function MapAssetLibrary({
|
|||||||
className="mt-1 w-full"
|
className="mt-1 w-full"
|
||||||
value={placementMode}
|
value={placementMode}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(event) => setPlacementMode(event.target.value as MapObjectPlacementMode)}
|
onChange={(event) => changePlacementMode(event.target.value as MapObjectPlacementMode)}
|
||||||
>
|
>
|
||||||
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map((mode) => (
|
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map((mode) => (
|
||||||
<option key={mode} value={mode}>
|
<option key={mode} value={mode}>
|
||||||
@@ -222,8 +258,14 @@ export function MapAssetLibrary({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.dataTransfer.effectAllowed = 'copy';
|
event.dataTransfer.effectAllowed = 'copy';
|
||||||
event.dataTransfer.setData(MAP_ASSET_DRAG_MIME, asset.type);
|
event.dataTransfer.setData(
|
||||||
event.dataTransfer.setData(MAP_ASSET_PLACEMENT_MIME, placementMode);
|
MAP_LIBRARY_DRAG_MIME,
|
||||||
|
encodeMapLibraryDragPayload({
|
||||||
|
kind: 'certified',
|
||||||
|
type: asset.type,
|
||||||
|
placementMode,
|
||||||
|
}),
|
||||||
|
);
|
||||||
event.dataTransfer.setData('text/plain', asset.name);
|
event.dataTransfer.setData('text/plain', asset.name);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -254,18 +296,31 @@ export function MapAssetLibrary({
|
|||||||
<div className="border-t border-border-subtle pt-3">
|
<div className="border-t border-border-subtle pt-3">
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
|
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
|
||||||
<Mountain className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
|
<Mountain className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
|
||||||
系统参数化地形
|
预设地形
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||||
本地确定性生成;点击“添加”后在右侧“地图”调整尺寸、难度和随机种子。
|
点击添加或拖到画布定位。新增先进入轻量预览,可连续放置后一次编译。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{SYSTEM_TERRAIN_PRESETS.map((preset) => (
|
{SYSTEM_TERRAIN_PRESETS.map((preset) => (
|
||||||
<article
|
<article
|
||||||
key={preset}
|
key={preset}
|
||||||
|
draggable={!disabled}
|
||||||
data-system-terrain={preset}
|
data-system-terrain={preset}
|
||||||
className={`group relative overflow-hidden rounded-lg border border-border-subtle bg-panel-muted shadow-sm transition-colors ${disabled ? 'opacity-50' : 'hover:border-accent'}`}
|
className={`group relative overflow-hidden rounded-lg border border-border-subtle bg-panel-muted shadow-sm transition-colors ${disabled ? 'opacity-50' : 'cursor-grab hover:border-accent active:cursor-grabbing'}`}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
if (disabled) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.dataTransfer.effectAllowed = 'copy';
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
MAP_LIBRARY_DRAG_MIME,
|
||||||
|
encodeMapLibraryDragPayload({ kind: 'terrain', preset }),
|
||||||
|
);
|
||||||
|
event.dataTransfer.setData('text/plain', PHYSICAL_MAP_PRESET_LABELS[preset]);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute right-2 top-2 z-10 rounded-full bg-panel-bg/90 p-0.5 text-success">
|
<div className="absolute right-2 top-2 z-10 rounded-full bg-panel-bg/90 p-0.5 text-success">
|
||||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
|||||||
@@ -87,6 +87,39 @@ describe('composeProjectMap', () => {
|
|||||||
expect(result.warnings.join(' ')).toContain('入口');
|
expect(result.warnings.join(' ')).toContain('入口');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('对工程地图物理层和出生点应用同一个实例变换', () => {
|
||||||
|
const result = composeProjectMap(robot, 'robot/scene.xml', manifest(), definition, {
|
||||||
|
kind: 'project',
|
||||||
|
descriptorPath: definition.descriptorPath,
|
||||||
|
positionX: 2,
|
||||||
|
positionY: -3,
|
||||||
|
yawDeg: 90,
|
||||||
|
spawnPointId: 'door',
|
||||||
|
});
|
||||||
|
const document = new DOMParser().parseFromString(
|
||||||
|
decoder.decode(result.data),
|
||||||
|
'application/xml',
|
||||||
|
);
|
||||||
|
const instanceRoot = document.querySelector(
|
||||||
|
'body[name="__platform_map_warehouse_instance_root"]',
|
||||||
|
);
|
||||||
|
expect(instanceRoot?.getAttribute('pos')).toBe('2 -3 0');
|
||||||
|
expect(instanceRoot?.getAttribute('quat')?.split(/\s+/).map(Number)).toEqual([
|
||||||
|
expect.closeTo(Math.SQRT1_2),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
expect.closeTo(Math.SQRT1_2),
|
||||||
|
]);
|
||||||
|
const position = document
|
||||||
|
.querySelector('body[name="robot"]')!
|
||||||
|
.getAttribute('pos')!
|
||||||
|
.split(/\s+/)
|
||||||
|
.map(Number);
|
||||||
|
expect(position[0]).toBeCloseTo(-19);
|
||||||
|
expect(position[1]).toBeCloseTo(7);
|
||||||
|
expect(position[2]).toBeCloseTo(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
it('地图明确提供地面时替换平台基础地面', () => {
|
it('地图明确提供地面时替换平台基础地面', () => {
|
||||||
const floorMap = manifest(
|
const floorMap = manifest(
|
||||||
'<mujoco><worldbody><geom name="floor" type="box" pos="0 0 -.1" size="5 5 .1"/></worldbody></mujoco>',
|
'<mujoco><worldbody><geom name="floor" type="box" pos="0 0 -.1" size="5 5 .1"/></worldbody></mujoco>',
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import type { ProjectManifest } from '../project/types';
|
import type { ProjectManifest } from '../project/types';
|
||||||
import { resolveProjectAssetPath, relativeAssetPath } from './mapPaths';
|
import { resolveProjectAssetPath, relativeAssetPath } from './mapPaths';
|
||||||
import type { MapSelection, ResolvedProjectMap, SpawnPoint } from './types';
|
import {
|
||||||
|
mapLocalPointToWorld,
|
||||||
|
mapSelectionTransform,
|
||||||
|
type MapSelection,
|
||||||
|
type ResolvedProjectMap,
|
||||||
|
type SpawnPoint,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
const decoder = new TextDecoder('utf-8');
|
const decoder = new TextDecoder('utf-8');
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
@@ -23,6 +29,13 @@ export interface ProjectMapComposition {
|
|||||||
summary: string;
|
summary: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectMapCompositionOptions {
|
||||||
|
/** 场景实例命名空间;允许同一 map.json 被重复放置。 */
|
||||||
|
instanceId?: string;
|
||||||
|
/** 多地图场景中只允许一个实例移动机器人到出生点。 */
|
||||||
|
applySpawnPoint?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function xml(data: Uint8Array, label: string): Document {
|
function xml(data: Uint8Array, label: string): Document {
|
||||||
const document = new DOMParser().parseFromString(decoder.decode(data), 'application/xml');
|
const document = new DOMParser().parseFromString(decoder.decode(data), 'application/xml');
|
||||||
if (document.querySelector('parsererror')) throw new Error(`${label} XML 无法解析`);
|
if (document.querySelector('parsererror')) throw new Error(`${label} XML 无法解析`);
|
||||||
@@ -46,6 +59,19 @@ function multiplyQuaternion(a: number[], b: number[]): [number, number, number,
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function transformSpawnPoint(
|
||||||
|
spawn: SpawnPoint | undefined,
|
||||||
|
selection: Extract<MapSelection, { kind: 'project' }>,
|
||||||
|
): SpawnPoint | undefined {
|
||||||
|
if (!spawn) return undefined;
|
||||||
|
const transform = mapSelectionTransform(selection);
|
||||||
|
return {
|
||||||
|
...spawn,
|
||||||
|
position: mapLocalPointToWorld(spawn.position, selection),
|
||||||
|
yawDeg: spawn.yawDeg + transform.yawDeg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function applySpawnPoint(
|
function applySpawnPoint(
|
||||||
document: Document,
|
document: Document,
|
||||||
spawn: SpawnPoint | undefined,
|
spawn: SpawnPoint | undefined,
|
||||||
@@ -145,6 +171,22 @@ function mapProvidesGround(document: Document): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyMapInstanceTransform(
|
||||||
|
document: Document,
|
||||||
|
selection: Extract<MapSelection, { kind: 'project' }>,
|
||||||
|
): void {
|
||||||
|
const worldbody = document.querySelector('mujoco > worldbody');
|
||||||
|
if (!worldbody) throw new Error('物理地图缺少 worldbody');
|
||||||
|
const transform = mapSelectionTransform(selection);
|
||||||
|
const root = document.createElement('body');
|
||||||
|
const halfYaw = (transform.yawDeg * Math.PI) / 360;
|
||||||
|
root.setAttribute('name', 'instance_root');
|
||||||
|
root.setAttribute('pos', `${transform.positionX} ${transform.positionY} 0`);
|
||||||
|
root.setAttribute('quat', `${Math.cos(halfYaw)} 0 0 ${Math.sin(halfYaw)}`);
|
||||||
|
for (const child of Array.from(worldbody.children)) root.append(child);
|
||||||
|
worldbody.append(root);
|
||||||
|
}
|
||||||
|
|
||||||
function namespaceMap(document: Document, mapId: string): void {
|
function namespaceMap(document: Document, mapId: string): void {
|
||||||
const prefix = `${MAP_PREFIX}${mapId.replace(/[^a-zA-Z0-9_-]/g, '_')}_`;
|
const prefix = `${MAP_PREFIX}${mapId.replace(/[^a-zA-Z0-9_-]/g, '_')}_`;
|
||||||
const names = new Map<string, string>();
|
const names = new Map<string, string>();
|
||||||
@@ -203,19 +245,22 @@ export function composeProjectMap(
|
|||||||
manifest: ProjectManifest,
|
manifest: ProjectManifest,
|
||||||
resolvedMap: ResolvedProjectMap,
|
resolvedMap: ResolvedProjectMap,
|
||||||
selection: Extract<MapSelection, { kind: 'project' }>,
|
selection: Extract<MapSelection, { kind: 'project' }>,
|
||||||
|
options: ProjectMapCompositionOptions = {},
|
||||||
): ProjectMapComposition {
|
): ProjectMapComposition {
|
||||||
const robot = xml(robotSource, '机器人 MJCF');
|
const robot = xml(robotSource, '机器人 MJCF');
|
||||||
const robotWorldbody = robot.querySelector('mujoco > worldbody');
|
const robotWorldbody = robot.querySelector('mujoco > worldbody');
|
||||||
if (!robotWorldbody) throw new Error('机器人 MJCF 缺少 worldbody');
|
if (!robotWorldbody) throw new Error('机器人 MJCF 缺少 worldbody');
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const spawn = selection.spawnPointId
|
const shouldApplySpawn = options.applySpawnPoint ?? true;
|
||||||
|
const spawn =
|
||||||
|
shouldApplySpawn && selection.spawnPointId
|
||||||
? resolvedMap.definition.spawnPoints.find(
|
? resolvedMap.definition.spawnPoints.find(
|
||||||
(candidate) => candidate.id === selection.spawnPointId,
|
(candidate) => candidate.id === selection.spawnPointId,
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (selection.spawnPointId && !spawn)
|
if (shouldApplySpawn && selection.spawnPointId && !spawn)
|
||||||
throw new Error(`地图中不存在出生点:${selection.spawnPointId}`);
|
throw new Error(`地图中不存在出生点:${selection.spawnPointId}`);
|
||||||
applySpawnPoint(robot, spawn, selection.robotRootBody, warnings);
|
applySpawnPoint(robot, transformSpawnPoint(spawn, selection), selection.robotRootBody, warnings);
|
||||||
|
|
||||||
let geomCount = 0;
|
let geomCount = 0;
|
||||||
if (resolvedMap.physicsPath) {
|
if (resolvedMap.physicsPath) {
|
||||||
@@ -225,7 +270,13 @@ export function composeProjectMap(
|
|||||||
validateMapStructure(map);
|
validateMapStructure(map);
|
||||||
const replacesGround = mapProvidesGround(map);
|
const replacesGround = mapProvidesGround(map);
|
||||||
rebaseAssets(map, robot, manifest, resolvedMap.physicsPath, generatedScenePath);
|
rebaseAssets(map, robot, manifest, resolvedMap.physicsPath, generatedScenePath);
|
||||||
namespaceMap(map, resolvedMap.definition.id);
|
applyMapInstanceTransform(map, selection);
|
||||||
|
namespaceMap(
|
||||||
|
map,
|
||||||
|
options.instanceId
|
||||||
|
? `${resolvedMap.definition.id}_${options.instanceId}`
|
||||||
|
: resolvedMap.definition.id,
|
||||||
|
);
|
||||||
if (selection.frictionOverride !== undefined) {
|
if (selection.frictionOverride !== undefined) {
|
||||||
const friction = Math.min(5, Math.max(0.05, selection.frictionOverride));
|
const friction = Math.min(5, Math.max(0.05, selection.frictionOverride));
|
||||||
for (const geom of Array.from(map.querySelectorAll('worldbody geom')))
|
for (const geom of Array.from(map.querySelectorAll('worldbody geom')))
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import type { EditableMapDocument } from './editor/types';
|
import type { EditableMapDocument, MapEditorInteractionCallbacks } from './editor/types';
|
||||||
import { MapEditorPanel } from './MapEditorPanel';
|
import { MapEditorPanel } from './MapEditorPanel';
|
||||||
|
|
||||||
const interactionProps = {
|
const interactionProps = {
|
||||||
|
descriptorPath: 'maps/map/map.json',
|
||||||
|
onDraftChange: () => {},
|
||||||
onConvert: async () => true,
|
onConvert: async () => true,
|
||||||
canConvert: true,
|
canConvert: true,
|
||||||
onBindInteraction: () => {},
|
onBindInteraction: () => {},
|
||||||
onSelectPreview: () => {},
|
onSelectPreview: () => {},
|
||||||
onTransformMode: () => {},
|
onSessionStateChange: () => {},
|
||||||
onSnapping: () => {},
|
|
||||||
};
|
};
|
||||||
const document: EditableMapDocument = {
|
const document: EditableMapDocument = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -34,34 +35,139 @@ describe('MapEditorPanel', () => {
|
|||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole('button', { name: '新增' }));
|
fireEvent.click(screen.getByRole('button', { name: '新增' }));
|
||||||
fireEvent.change(screen.getByLabelText('对象位置X'), { target: { value: '2' } });
|
fireEvent.change(screen.getByLabelText('对象位置X'), { target: { value: '2' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('对象滑动摩擦'), { target: { value: '0.8' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('对象颜色'), { target: { value: '#ff0000' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||||
await waitFor(() => expect(apply).toHaveBeenCalled());
|
await waitFor(() => expect(apply).toHaveBeenCalled());
|
||||||
|
expect(preview).not.toHaveBeenLastCalledWith(null);
|
||||||
expect(apply.mock.calls[0][0]).toEqual(
|
expect(apply.mock.calls[0][0]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
revision: 1,
|
revision: 1,
|
||||||
objects: [
|
objects: [
|
||||||
expect.objectContaining({ pose: expect.objectContaining({ position: [2, 0, 0.5] }) }),
|
expect.objectContaining({
|
||||||
|
pose: expect.objectContaining({ position: [2, 0, 0.5] }),
|
||||||
|
friction: [0.8, 0.005, 0.0001],
|
||||||
|
rgba: [1, 0, 0, 1],
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('锁定放置方式后禁用位姿编辑', () => {
|
it('把认证资产编辑实时上报为场景级草稿', () => {
|
||||||
|
const onDraftChange = vi.fn();
|
||||||
render(
|
render(
|
||||||
<MapEditorPanel
|
<MapEditorPanel
|
||||||
{...interactionProps}
|
{...interactionProps}
|
||||||
document={document}
|
document={document}
|
||||||
loading={false}
|
loading={false}
|
||||||
onPreview={() => {}}
|
onPreview={() => {}}
|
||||||
|
onDraftChange={onDraftChange}
|
||||||
onApply={async () => true}
|
onApply={async () => true}
|
||||||
onExport={() => {}}
|
onExport={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole('button', { name: '新增' }));
|
fireEvent.click(screen.getByRole('button', { name: '新增' }));
|
||||||
fireEvent.change(screen.getByLabelText('对象放置方式'), { target: { value: 'locked' } });
|
expect(onDraftChange).toHaveBeenLastCalledWith(
|
||||||
|
'maps/map/map.json',
|
||||||
|
expect.objectContaining({ objects: [expect.objectContaining({ type: 'box' })] }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('把外部拾取同步到属性表单和视口操纵器', async () => {
|
||||||
|
const onSelectPreview = vi.fn();
|
||||||
|
let interaction: MapEditorInteractionCallbacks | null = null;
|
||||||
|
render(
|
||||||
|
<MapEditorPanel
|
||||||
|
{...interactionProps}
|
||||||
|
document={{
|
||||||
|
...document,
|
||||||
|
objects: [
|
||||||
|
{
|
||||||
|
id: 'box_1',
|
||||||
|
name: '认证方盒',
|
||||||
|
type: 'box',
|
||||||
|
pose: { position: [0, 0, 0.5], quaternion: [1, 0, 0, 0] },
|
||||||
|
parameters: { sizeX: 1, sizeY: 1, sizeZ: 1 },
|
||||||
|
friction: [1, 0.005, 0.0001],
|
||||||
|
rgba: [0.55, 0.6, 0.68, 1],
|
||||||
|
placementMode: 'auto_ground',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
loading={false}
|
||||||
|
onPreview={() => {}}
|
||||||
|
onSelectPreview={onSelectPreview}
|
||||||
|
onBindInteraction={(callbacks) => {
|
||||||
|
interaction = callbacks;
|
||||||
|
}}
|
||||||
|
onApply={async () => true}
|
||||||
|
onExport={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => interaction?.onSelect('box_1'));
|
||||||
|
await waitFor(() => expect(onSelectPreview).toHaveBeenLastCalledWith('box_1'));
|
||||||
|
expect(screen.getByLabelText('对象名称')).toHaveValue('认证方盒');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('视口贴地命令通过会话端口锁定位姿编辑', async () => {
|
||||||
|
let interaction: MapEditorInteractionCallbacks | null = null;
|
||||||
|
let selectedId: string | null = null;
|
||||||
|
render(
|
||||||
|
<MapEditorPanel
|
||||||
|
{...interactionProps}
|
||||||
|
document={document}
|
||||||
|
loading={false}
|
||||||
|
onPreview={() => {}}
|
||||||
|
onSelectPreview={(id) => {
|
||||||
|
selectedId = id;
|
||||||
|
}}
|
||||||
|
onBindInteraction={(callbacks) => {
|
||||||
|
interaction = callbacks;
|
||||||
|
}}
|
||||||
|
onApply={async () => true}
|
||||||
|
onExport={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '新增' }));
|
||||||
|
expect(selectedId).toBeTruthy();
|
||||||
|
act(() =>
|
||||||
|
(interaction as unknown as MapEditorInteractionCallbacks).onSetPlacementMode(
|
||||||
|
selectedId!,
|
||||||
|
'locked',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.getByText('锁定位姿')).toBeVisible());
|
||||||
expect(screen.getByLabelText('对象位置X')).toBeDisabled();
|
expect(screen.getByLabelText('对象位置X')).toBeDisabled();
|
||||||
expect(screen.getByLabelText('对象绕Z旋转')).toBeDisabled();
|
expect(screen.getByLabelText('对象绕Z旋转')).toBeDisabled();
|
||||||
expect(screen.getByRole('button', { name: '对齐地面' })).toBeDisabled();
|
});
|
||||||
|
|
||||||
|
it('把表面材质预设与三轴摩擦写入同一对象草稿', () => {
|
||||||
|
const onDraftChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<MapEditorPanel
|
||||||
|
{...interactionProps}
|
||||||
|
document={document}
|
||||||
|
loading={false}
|
||||||
|
onPreview={() => {}}
|
||||||
|
onDraftChange={onDraftChange}
|
||||||
|
onApply={async () => true}
|
||||||
|
onExport={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '新增' }));
|
||||||
|
fireEvent.change(screen.getByLabelText('对象表面材质'), { target: { value: 'rubber' } });
|
||||||
|
expect(screen.getByLabelText('对象滑动摩擦')).toHaveValue(1.5);
|
||||||
|
expect(onDraftChange).toHaveBeenLastCalledWith(
|
||||||
|
'maps/map/map.json',
|
||||||
|
expect.objectContaining({
|
||||||
|
objects: [expect.objectContaining({ friction: [1.5, 0.008, 0.0002] })],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('编辑出生点并包含在应用文档中', async () => {
|
it('编辑出生点并包含在应用文档中', async () => {
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Button, Select } from '../components/ui';
|
import { Button, ScrubbableNumberInput, Select } from '../components/ui';
|
||||||
import { MapEditSession } from './editor/MapEditSession';
|
import { MapEditSession } from './editor/MapEditSession';
|
||||||
import {
|
import type {
|
||||||
editableObjectGroundHeight,
|
EditableMapDocument,
|
||||||
MAP_OBJECT_PLACEMENT_LABELS,
|
EditableMapObject,
|
||||||
type EditableMapDocument,
|
EditableMapObjectType,
|
||||||
type EditableMapObject,
|
MapEditorInteractionCallbacks,
|
||||||
type EditableMapObjectType,
|
MapEditorSessionState,
|
||||||
type MapEditorInteractionCallbacks,
|
|
||||||
type MapEditorTransformMode,
|
|
||||||
type MapObjectPlacementMode,
|
|
||||||
} from './editor/types';
|
} from './editor/types';
|
||||||
|
import { MapObjectInspector } from './MapObjectInspector';
|
||||||
|
|
||||||
const labels: Record<EditableMapObjectType, string> = {
|
const labels: Record<EditableMapObjectType, string> = {
|
||||||
box: '方盒',
|
box: '方盒',
|
||||||
@@ -19,10 +17,6 @@ const labels: Record<EditableMapObjectType, string> = {
|
|||||||
ramp: '坡道',
|
ramp: '坡道',
|
||||||
stairs: '楼梯',
|
stairs: '楼梯',
|
||||||
};
|
};
|
||||||
function yawDegrees(object: EditableMapObject): number {
|
|
||||||
const [w, x, y, z] = object.pose.quaternion;
|
|
||||||
return (Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)) * 180) / Math.PI;
|
|
||||||
}
|
|
||||||
function scaledParameters(
|
function scaledParameters(
|
||||||
object: EditableMapObject,
|
object: EditableMapObject,
|
||||||
scale: [number, number, number],
|
scale: [number, number, number],
|
||||||
@@ -55,80 +49,178 @@ function scaledParameters(
|
|||||||
}
|
}
|
||||||
export function MapEditorPanel({
|
export function MapEditorPanel({
|
||||||
document,
|
document,
|
||||||
|
draftDocument,
|
||||||
|
descriptorPath,
|
||||||
loading,
|
loading,
|
||||||
onPreview,
|
onPreview,
|
||||||
|
onDraftChange,
|
||||||
onApply,
|
onApply,
|
||||||
onExport,
|
onExport,
|
||||||
onConvert,
|
onConvert,
|
||||||
canConvert,
|
canConvert,
|
||||||
|
selectedObjectId,
|
||||||
onBindInteraction,
|
onBindInteraction,
|
||||||
onSelectPreview,
|
onSelectPreview,
|
||||||
onTransformMode,
|
onSessionStateChange,
|
||||||
onSnapping,
|
onSurfaceHeight,
|
||||||
}: {
|
}: {
|
||||||
document: EditableMapDocument | null;
|
document: EditableMapDocument | null;
|
||||||
|
draftDocument?: EditableMapDocument;
|
||||||
|
descriptorPath: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onPreview: (document: EditableMapDocument | null) => void;
|
onPreview: (document: EditableMapDocument | null) => void;
|
||||||
|
onDraftChange: (descriptorPath: string, document: EditableMapDocument, dirty: boolean) => void;
|
||||||
onApply: (document: EditableMapDocument) => Promise<boolean>;
|
onApply: (document: EditableMapDocument) => Promise<boolean>;
|
||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
onConvert: () => Promise<boolean>;
|
onConvert: () => Promise<boolean>;
|
||||||
canConvert: boolean;
|
canConvert: boolean;
|
||||||
|
selectedObjectId?: string;
|
||||||
onBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
|
onBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
|
||||||
onSelectPreview: (id: string | null) => void;
|
onSelectPreview: (id: string | null) => void;
|
||||||
onTransformMode: (mode: MapEditorTransformMode) => void;
|
onSessionStateChange?: (descriptorPath: string, state: MapEditorSessionState | null) => void;
|
||||||
onSnapping: (translation: number | null, rotationDegrees: number | null) => void;
|
onSurfaceHeight?: (position: readonly [number, number, number]) => number | null;
|
||||||
}) {
|
}) {
|
||||||
const session = useMemo(() => (document ? new MapEditSession(document) : null), [document]);
|
// draftDocument 仅用于恢复重新挂载的编辑器;会话存活期间由 MapEditSession 持有最新草稿。
|
||||||
|
const session = useMemo(
|
||||||
|
() =>
|
||||||
|
document ? new MapEditSession(document, draftDocument ?? document, onSurfaceHeight) : null,
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[document, descriptorPath, onSurfaceHeight],
|
||||||
|
);
|
||||||
const [, render] = useState(0),
|
const [, render] = useState(0),
|
||||||
[selected, setSelected] = useState<string>(),
|
[selected, setSelected] = useState<string | undefined>(selectedObjectId),
|
||||||
[selectedType, setSelectedType] = useState<EditableMapObjectType>('box'),
|
[selectedType, setSelectedType] = useState<EditableMapObjectType>('box'),
|
||||||
[transformMode, setTransformMode] = useState<MapEditorTransformMode>('translate'),
|
|
||||||
[snapping, setSnapping] = useState(true),
|
|
||||||
[converting, setConverting] = useState(false);
|
[converting, setConverting] = useState(false);
|
||||||
|
const selectedId = selectedObjectId ?? selected;
|
||||||
|
const reportSessionState = () =>
|
||||||
|
onSessionStateChange?.(
|
||||||
|
descriptorPath,
|
||||||
|
session
|
||||||
|
? {
|
||||||
|
dirty: session.dirty,
|
||||||
|
changeCount: session.changeCount,
|
||||||
|
canUndo: session.canUndo,
|
||||||
|
canRedo: session.canRedo,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
|
const next = session?.document ?? null;
|
||||||
render((value) => value + 1);
|
render((value) => value + 1);
|
||||||
onPreview(session?.document ?? null);
|
onPreview(next);
|
||||||
|
if (session && next) onDraftChange(descriptorPath, next, session.dirty);
|
||||||
|
reportSessionState();
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onPreview(session?.document ?? null);
|
const next = session?.document ?? null;
|
||||||
return () => onPreview(null);
|
onPreview(next);
|
||||||
}, [session, onPreview]);
|
if (session && next) onDraftChange(descriptorPath, next, session.dirty);
|
||||||
|
onSessionStateChange?.(
|
||||||
|
descriptorPath,
|
||||||
|
session
|
||||||
|
? {
|
||||||
|
dirty: session.dirty,
|
||||||
|
changeCount: session.changeCount,
|
||||||
|
canUndo: session.canUndo,
|
||||||
|
canRedo: session.canRedo,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
onPreview(null);
|
||||||
|
onSessionStateChange?.(descriptorPath, null);
|
||||||
|
};
|
||||||
|
}, [session, descriptorPath, onPreview, onDraftChange, onSessionStateChange]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
onBindInteraction(null);
|
onBindInteraction(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const publish = () => {
|
||||||
|
const next = session.document;
|
||||||
|
render((value) => value + 1);
|
||||||
|
onPreview(next);
|
||||||
|
onDraftChange(descriptorPath, next, session.dirty);
|
||||||
|
onSessionStateChange?.(descriptorPath, {
|
||||||
|
dirty: session.dirty,
|
||||||
|
changeCount: session.changeCount,
|
||||||
|
canUndo: session.canUndo,
|
||||||
|
canRedo: session.canRedo,
|
||||||
|
});
|
||||||
|
};
|
||||||
const callbacks: MapEditorInteractionCallbacks = {
|
const callbacks: MapEditorInteractionCallbacks = {
|
||||||
onSelect: (id) => setSelected(id ?? undefined),
|
onSelect: (id) => {
|
||||||
|
session.selectedId = id ?? undefined;
|
||||||
|
setSelected(id ?? undefined);
|
||||||
|
},
|
||||||
onTransform: ({ id, position, quaternion, scale }) => {
|
onTransform: ({ id, position, quaternion, scale }) => {
|
||||||
|
if (loading) return;
|
||||||
const object = session.document.objects.find((item) => item.id === id);
|
const object = session.document.objects.find((item) => item.id === id);
|
||||||
if (!object) return;
|
if (!object) return;
|
||||||
session.update(id, {
|
session.update(id, {
|
||||||
pose: { position, quaternion },
|
pose: { position, quaternion },
|
||||||
parameters: scaledParameters(object, scale),
|
parameters: scaledParameters(object, scale),
|
||||||
});
|
});
|
||||||
|
session.selectedId = id;
|
||||||
setSelected(id);
|
setSelected(id);
|
||||||
render((value) => value + 1);
|
publish();
|
||||||
onPreview(session.document);
|
|
||||||
},
|
},
|
||||||
onAddAsset: (type, position, placementMode) => {
|
onAddAsset: (type, position, placementMode, externalSupportTop) => {
|
||||||
const object = session.addAsset(type, position, placementMode);
|
if (loading) return;
|
||||||
|
const object = session.addAsset(type, position, placementMode, externalSupportTop);
|
||||||
setSelected(object.id);
|
setSelected(object.id);
|
||||||
render((value) => value + 1);
|
publish();
|
||||||
onPreview(session.document);
|
|
||||||
onSelectPreview(object.id);
|
onSelectPreview(object.id);
|
||||||
},
|
},
|
||||||
|
onSetPlacementMode: (id, placementMode) => {
|
||||||
|
if (loading || !session.document.objects.some((object) => object.id === id)) return;
|
||||||
|
session.update(id, { placementMode });
|
||||||
|
session.selectedId = id;
|
||||||
|
setSelected(id);
|
||||||
|
publish();
|
||||||
|
},
|
||||||
|
onAlignToSurface: (id) => {
|
||||||
|
if (loading) return;
|
||||||
|
const object = session.document.objects.find((candidate) => candidate.id === id);
|
||||||
|
if (!object || object.placementMode === 'locked') return;
|
||||||
|
session.update(id, { placementMode: object.placementMode });
|
||||||
|
session.selectedId = id;
|
||||||
|
setSelected(id);
|
||||||
|
publish();
|
||||||
|
},
|
||||||
|
onDelete: (id) => {
|
||||||
|
if (loading || !session.document.objects.some((object) => object.id === id)) return;
|
||||||
|
session.remove(id);
|
||||||
|
setSelected(undefined);
|
||||||
|
publish();
|
||||||
|
onSelectPreview(null);
|
||||||
|
},
|
||||||
|
onDiscard: () => {
|
||||||
|
session.discard();
|
||||||
|
setSelected(undefined);
|
||||||
|
publish();
|
||||||
|
onSelectPreview(null);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
onBindInteraction(callbacks);
|
onBindInteraction(callbacks);
|
||||||
return () => onBindInteraction(null);
|
return () => onBindInteraction(null);
|
||||||
}, [session, onBindInteraction, onPreview, onSelectPreview]);
|
}, [
|
||||||
|
session,
|
||||||
|
descriptorPath,
|
||||||
|
loading,
|
||||||
|
onBindInteraction,
|
||||||
|
onPreview,
|
||||||
|
onDraftChange,
|
||||||
|
onSelectPreview,
|
||||||
|
onSessionStateChange,
|
||||||
|
]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onTransformMode(transformMode);
|
if (session && selectedId) onSelectPreview(selectedId);
|
||||||
onSnapping(snapping ? 0.1 : null, snapping ? 5 : null);
|
}, [session, selectedId, onSelectPreview]);
|
||||||
}, [transformMode, snapping, onTransformMode, onSnapping]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const keydown = (event: KeyboardEvent) => {
|
const keydown = (event: KeyboardEvent) => {
|
||||||
|
if (loading) return;
|
||||||
const target = event.target as HTMLElement | null;
|
const target = event.target as HTMLElement | null;
|
||||||
if (target?.matches('input, textarea, select') || target?.isContentEditable || event.altKey)
|
if (target?.matches('input, textarea, select') || target?.isContentEditable || event.altKey)
|
||||||
return;
|
return;
|
||||||
@@ -137,35 +229,48 @@ export function MapEditorPanel({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (event.shiftKey) session.redo();
|
if (event.shiftKey) session.redo();
|
||||||
else session.undo();
|
else session.undo();
|
||||||
if (selected && !session.document.objects.some((object) => object.id === selected)) {
|
if (selectedId && !session.document.objects.some((object) => object.id === selectedId)) {
|
||||||
setSelected(undefined);
|
setSelected(undefined);
|
||||||
onSelectPreview(null);
|
onSelectPreview(null);
|
||||||
}
|
}
|
||||||
render((value) => value + 1);
|
render((value) => value + 1);
|
||||||
onPreview(session.document);
|
onPreview(session.document);
|
||||||
|
onDraftChange(descriptorPath, session.document, session.dirty);
|
||||||
|
onSessionStateChange?.(descriptorPath, {
|
||||||
|
dirty: session.dirty,
|
||||||
|
changeCount: session.changeCount,
|
||||||
|
canUndo: session.canUndo,
|
||||||
|
canRedo: session.canRedo,
|
||||||
|
});
|
||||||
} else if ((event.ctrlKey || event.metaKey) && key === 'y') {
|
} else if ((event.ctrlKey || event.metaKey) && key === 'y') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
session.redo();
|
session.redo();
|
||||||
render((value) => value + 1);
|
render((value) => value + 1);
|
||||||
onPreview(session.document);
|
onPreview(session.document);
|
||||||
} else if (!event.ctrlKey && !event.metaKey && key === 'w') setTransformMode('translate');
|
onDraftChange(descriptorPath, session.document, session.dirty);
|
||||||
else if (!event.ctrlKey && !event.metaKey && key === 'e') setTransformMode('rotate');
|
onSessionStateChange?.(descriptorPath, {
|
||||||
else if (!event.ctrlKey && !event.metaKey && key === 's') setTransformMode('scale');
|
dirty: session.dirty,
|
||||||
else if (event.key === 'Escape') {
|
changeCount: session.changeCount,
|
||||||
|
canUndo: session.canUndo,
|
||||||
|
canRedo: session.canRedo,
|
||||||
|
});
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
setSelected(undefined);
|
setSelected(undefined);
|
||||||
onSelectPreview(null);
|
onSelectPreview(null);
|
||||||
} else if ((event.key === 'Delete' || event.key === 'Backspace') && selected) {
|
|
||||||
event.preventDefault();
|
|
||||||
session.remove(selected);
|
|
||||||
setSelected(undefined);
|
|
||||||
render((value) => value + 1);
|
|
||||||
onPreview(session.document);
|
|
||||||
onSelectPreview(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', keydown);
|
window.addEventListener('keydown', keydown);
|
||||||
return () => window.removeEventListener('keydown', keydown);
|
return () => window.removeEventListener('keydown', keydown);
|
||||||
}, [session, selected, onPreview, onSelectPreview]);
|
}, [
|
||||||
|
session,
|
||||||
|
descriptorPath,
|
||||||
|
selectedId,
|
||||||
|
loading,
|
||||||
|
onPreview,
|
||||||
|
onDraftChange,
|
||||||
|
onSelectPreview,
|
||||||
|
onSessionStateChange,
|
||||||
|
]);
|
||||||
if (!session)
|
if (!session)
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-tertiary">
|
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-tertiary">
|
||||||
@@ -193,29 +298,14 @@ export function MapEditorPanel({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const current = session.document;
|
const current = session.document;
|
||||||
const active = current.objects.find((object) => object.id === selected);
|
const active = current.objects.find((object) => object.id === selectedId);
|
||||||
const poseLocked = active?.placementMode === 'locked';
|
|
||||||
const updatePosition = (index: number, value: number) => {
|
|
||||||
if (!active || !Number.isFinite(value)) return;
|
|
||||||
const position = [...active.pose.position] as [number, number, number];
|
|
||||||
position[index] = value;
|
|
||||||
session.update(active.id, { pose: { ...active.pose, position } });
|
|
||||||
refresh();
|
|
||||||
};
|
|
||||||
const updateParameter = (key: string, value: number) => {
|
|
||||||
if (!active || !Number.isFinite(value) || value <= 0) return;
|
|
||||||
session.update(active.id, {
|
|
||||||
parameters: { ...active.parameters, [key]: key === 'count' ? Math.round(value) : value },
|
|
||||||
});
|
|
||||||
refresh();
|
|
||||||
};
|
|
||||||
const updateSpawnPosition = (
|
const updateSpawnPosition = (
|
||||||
id: string,
|
id: string,
|
||||||
position: [number, number, number],
|
position: [number, number, number],
|
||||||
index: number,
|
index: number,
|
||||||
value: number,
|
value: number,
|
||||||
) => {
|
) => {
|
||||||
if (!Number.isFinite(value)) return;
|
if (loading || !Number.isFinite(value)) return;
|
||||||
const next = [...position] as [number, number, number];
|
const next = [...position] as [number, number, number];
|
||||||
next[index] = value;
|
next[index] = value;
|
||||||
session.updateSpawn(id, { position: next });
|
session.updateSpawn(id, { position: next });
|
||||||
@@ -224,44 +314,32 @@ export function MapEditorPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-3 gap-2 rounded border border-border-subtle p-2">
|
{active && (
|
||||||
<Button
|
<MapObjectInspector
|
||||||
variant={transformMode === 'translate' ? 'primary' : 'secondary'}
|
object={active}
|
||||||
aria-pressed={transformMode === 'translate'}
|
loading={loading}
|
||||||
disabled={loading}
|
onUpdate={(patch) => {
|
||||||
onClick={() => setTransformMode('translate')}
|
session.update(active.id, patch);
|
||||||
>
|
refresh();
|
||||||
移动工具 W
|
}}
|
||||||
</Button>
|
onDuplicate={() => {
|
||||||
<Button
|
const copy = session.duplicate(active.id);
|
||||||
variant={transformMode === 'rotate' ? 'primary' : 'secondary'}
|
setSelected(copy.id);
|
||||||
aria-pressed={transformMode === 'rotate'}
|
refresh();
|
||||||
disabled={loading}
|
onSelectPreview(copy.id);
|
||||||
onClick={() => setTransformMode('rotate')}
|
}}
|
||||||
>
|
onDelete={() => {
|
||||||
旋转工具 E
|
session.remove(active.id);
|
||||||
</Button>
|
setSelected(undefined);
|
||||||
<Button
|
refresh();
|
||||||
variant={transformMode === 'scale' ? 'primary' : 'secondary'}
|
onSelectPreview(null);
|
||||||
aria-pressed={transformMode === 'scale'}
|
}}
|
||||||
disabled={loading}
|
|
||||||
onClick={() => setTransformMode('scale')}
|
|
||||||
>
|
|
||||||
缩放工具 S
|
|
||||||
</Button>
|
|
||||||
<label className="col-span-3 flex items-center justify-between text-[11px] text-text-secondary">
|
|
||||||
<span>吸附(移动 0.1 m / 旋转 5°)</span>
|
|
||||||
<input
|
|
||||||
aria-label="地图编辑吸附"
|
|
||||||
type="checkbox"
|
|
||||||
checked={snapping}
|
|
||||||
disabled={loading}
|
|
||||||
onChange={(event) => setSnapping(event.target.checked)}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
)}
|
||||||
<p className="col-span-3 text-[10px] text-text-tertiary">
|
|
||||||
在视口点击对象后拖动操纵轴;只允许绕世界 Z 轴旋转,缩放会写入原语尺寸。
|
<section className="space-y-2 rounded-lg border border-border-subtle p-2.5">
|
||||||
</p>
|
<div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-text-tertiary">
|
||||||
|
地图对象
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select
|
<Select
|
||||||
@@ -294,7 +372,7 @@ export function MapEditorPanel({
|
|||||||
<button
|
<button
|
||||||
key={object.id}
|
key={object.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={`w-full rounded border px-2 py-1 text-left text-xs ${selected === object.id ? 'border-accent bg-accent/10' : 'border-border-subtle'}`}
|
className={`w-full rounded border px-2 py-1 text-left text-xs ${selectedId === object.id ? 'border-accent bg-accent/10' : 'border-border-subtle'}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelected(object.id);
|
setSelected(object.id);
|
||||||
onSelectPreview(object.id);
|
onSelectPreview(object.id);
|
||||||
@@ -304,162 +382,11 @@ export function MapEditorPanel({
|
|||||||
{object.placementMode === 'locked' ? ' · 已锁定' : ''}
|
{object.placementMode === 'locked' ? ' · 已锁定' : ''}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{!current.objects.length && <div className="text-xs text-text-tertiary">暂无对象</div>}
|
{!current.objects.length && (
|
||||||
</div>
|
<div className="text-xs text-text-tertiary">暂无对象,可从左侧资产库拖入视口</div>
|
||||||
{active && (
|
|
||||||
<div className="space-y-2 rounded border border-border-subtle p-2">
|
|
||||||
<label className="block text-[11px] text-text-secondary">
|
|
||||||
名称
|
|
||||||
<input
|
|
||||||
aria-label="对象名称"
|
|
||||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
|
||||||
value={active.name}
|
|
||||||
onChange={(event) => {
|
|
||||||
if (!event.target.value.trim()) return;
|
|
||||||
session.update(active.id, { name: event.target.value });
|
|
||||||
refresh();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block text-[11px] text-text-secondary">
|
|
||||||
放置方式
|
|
||||||
<Select
|
|
||||||
aria-label="对象放置方式"
|
|
||||||
className="mt-1 w-full"
|
|
||||||
value={active.placementMode}
|
|
||||||
disabled={loading}
|
|
||||||
onChange={(event) => {
|
|
||||||
session.update(active.id, {
|
|
||||||
placementMode: event.target.value as MapObjectPlacementMode,
|
|
||||||
});
|
|
||||||
refresh();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map(
|
|
||||||
(mode) => (
|
|
||||||
<option key={mode} value={mode}>
|
|
||||||
{MAP_OBJECT_PLACEMENT_LABELS[mode]}
|
|
||||||
</option>
|
|
||||||
),
|
|
||||||
)}
|
)}
|
||||||
</Select>
|
|
||||||
<span className="mt-1 block text-[9px] leading-3.5 text-text-tertiary">
|
|
||||||
{active.placementMode === 'auto_ground'
|
|
||||||
? '始终将对象底部对齐 z=0。'
|
|
||||||
: active.placementMode === 'gravity'
|
|
||||||
? '沿世界 -Z 落到 XY 范围内最高的静态承载面。'
|
|
||||||
: '保持当前位姿,并禁用表单和视口变换。'}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
{active.pose.position.map((value, index) => (
|
|
||||||
<label key={index} className="text-[11px] text-text-secondary">
|
|
||||||
{['X', 'Y', 'Z'][index]}
|
|
||||||
<input
|
|
||||||
aria-label={`对象位置${['X', 'Y', 'Z'][index]}`}
|
|
||||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={value}
|
|
||||||
disabled={loading || poseLocked || index === 2}
|
|
||||||
onChange={(event) => updatePosition(index, Number(event.target.value))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<label className="block text-[11px] text-text-secondary">
|
</section>
|
||||||
绕 Z 旋转(°)
|
|
||||||
<input
|
|
||||||
aria-label="对象绕Z旋转"
|
|
||||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
|
||||||
type="number"
|
|
||||||
step="1"
|
|
||||||
value={Number(yawDegrees(active).toFixed(4))}
|
|
||||||
disabled={loading || poseLocked}
|
|
||||||
onChange={(event) => {
|
|
||||||
const value = Number(event.target.value);
|
|
||||||
if (!Number.isFinite(value)) return;
|
|
||||||
const half = (value * Math.PI) / 360;
|
|
||||||
session.update(active.id, {
|
|
||||||
pose: { ...active.pose, quaternion: [Math.cos(half), 0, 0, Math.sin(half)] },
|
|
||||||
});
|
|
||||||
refresh();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{Object.entries(active.parameters).map(([key, value]) => (
|
|
||||||
<label key={key} className="text-[11px] text-text-secondary">
|
|
||||||
{key}
|
|
||||||
<input
|
|
||||||
aria-label={`对象参数${key}`}
|
|
||||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
|
||||||
type="number"
|
|
||||||
min="0.001"
|
|
||||||
step={key === 'count' ? 1 : 0.1}
|
|
||||||
value={value}
|
|
||||||
onChange={(event) => updateParameter(key, Number(event.target.value))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center justify-between text-[11px] text-text-secondary">
|
|
||||||
<span>启用对象</span>
|
|
||||||
<input
|
|
||||||
aria-label="启用地图对象"
|
|
||||||
type="checkbox"
|
|
||||||
checked={active.enabled}
|
|
||||||
onChange={(event) => {
|
|
||||||
session.update(active.id, { enabled: event.target.checked });
|
|
||||||
refresh();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
disabled={loading || poseLocked}
|
|
||||||
onClick={() => {
|
|
||||||
session.update(active.id, {
|
|
||||||
pose: {
|
|
||||||
...active.pose,
|
|
||||||
position: [
|
|
||||||
active.pose.position[0],
|
|
||||||
active.pose.position[1],
|
|
||||||
editableObjectGroundHeight(active),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
refresh();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
对齐地面
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => {
|
|
||||||
const copy = session.duplicate(active.id);
|
|
||||||
setSelected(copy.id);
|
|
||||||
refresh();
|
|
||||||
onSelectPreview(copy.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
复制对象
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => {
|
|
||||||
session.remove(active.id);
|
|
||||||
setSelected(undefined);
|
|
||||||
refresh();
|
|
||||||
onSelectPreview(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
删除对象
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2 rounded border border-border-subtle p-2">
|
<div className="space-y-2 rounded border border-border-subtle p-2">
|
||||||
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
|
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
|
||||||
@@ -480,6 +407,7 @@ export function MapEditorPanel({
|
|||||||
aria-label={`出生点名称 ${spawn.id}`}
|
aria-label={`出生点名称 ${spawn.id}`}
|
||||||
className="h-7 w-full rounded border border-border-strong bg-input px-1 text-xs"
|
className="h-7 w-full rounded border border-border-strong bg-input px-1 text-xs"
|
||||||
value={spawn.name}
|
value={spawn.name}
|
||||||
|
disabled={loading}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
if (!event.target.value.trim()) return;
|
if (!event.target.value.trim()) return;
|
||||||
session.updateSpawn(spawn.id, { name: event.target.value });
|
session.updateSpawn(spawn.id, { name: event.target.value });
|
||||||
@@ -488,41 +416,29 @@ export function MapEditorPanel({
|
|||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-4 gap-1">
|
<div className="grid grid-cols-4 gap-1">
|
||||||
{spawn.position.map((value, index) => (
|
{spawn.position.map((value, index) => (
|
||||||
<label key={index} className="text-[10px] text-text-secondary">
|
<ScrubbableNumberInput
|
||||||
{['X', 'Y', 'Z'][index]}
|
key={index}
|
||||||
<input
|
label={['X', 'Y', 'Z'][index]}
|
||||||
aria-label={`出生点${spawn.id}位置${['X', 'Y', 'Z'][index]}`}
|
aria-label={`出生点${spawn.id}位置${['X', 'Y', 'Z'][index]}`}
|
||||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) =>
|
step={0.1}
|
||||||
updateSpawnPosition(
|
disabled={loading}
|
||||||
spawn.id,
|
onValueChange={(next) =>
|
||||||
spawn.position,
|
updateSpawnPosition(spawn.id, spawn.position, index, next)
|
||||||
index,
|
|
||||||
Number(event.target.value),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
))}
|
))}
|
||||||
<label className="text-[10px] text-text-secondary">
|
<ScrubbableNumberInput
|
||||||
Yaw°
|
label="Yaw°"
|
||||||
<input
|
|
||||||
aria-label={`出生点${spawn.id}朝向`}
|
aria-label={`出生点${spawn.id}朝向`}
|
||||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
|
||||||
type="number"
|
|
||||||
step="1"
|
|
||||||
value={spawn.yawDeg}
|
value={spawn.yawDeg}
|
||||||
onChange={(event) => {
|
step={1}
|
||||||
const value = Number(event.target.value);
|
disabled={loading}
|
||||||
if (!Number.isFinite(value)) return;
|
onValueChange={(yawDeg) => {
|
||||||
session.updateSpawn(spawn.id, { yawDeg: value });
|
session.updateSpawn(spawn.id, { yawDeg });
|
||||||
refresh();
|
refresh();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
@@ -581,8 +497,7 @@ export function MapEditorPanel({
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const latest = session.document;
|
const latest = session.document;
|
||||||
const candidate = { ...latest, revision: latest.revision + 1 };
|
const candidate = { ...latest, revision: latest.revision + 1 };
|
||||||
if (await onApply(candidate)) onPreview(null);
|
if (!(await onApply(candidate))) onPreview(session.document);
|
||||||
else onPreview(session.document);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
应用并重新编译
|
应用并重新编译
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { discoverMapEntries, resolveProjectMap } from './MapLoader';
|
import { discoverMapEntries, resolveProjectMap, visualMapAssets } from './MapLoader';
|
||||||
import type { ProjectFile, ProjectManifest } from '../project/types';
|
import type { ProjectFile, ProjectManifest } from '../project/types';
|
||||||
|
|
||||||
const encode = (value: string) => new TextEncoder().encode(value);
|
const encode = (value: string) => new TextEncoder().encode(value);
|
||||||
@@ -52,6 +52,47 @@ describe('MapLoader', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('只为已放置实例解析视觉地图,并保留实例 id', () => {
|
||||||
|
const files = [
|
||||||
|
file('maps/warehouse/map.json', descriptor),
|
||||||
|
file('maps/warehouse/physics/world.xml', '<mujoco><worldbody/></mujoco>'),
|
||||||
|
file('maps/warehouse/visuals/scene.glb', 'glb'),
|
||||||
|
];
|
||||||
|
const projectManifest: ProjectManifest = {
|
||||||
|
id: 'test',
|
||||||
|
name: 'test',
|
||||||
|
files,
|
||||||
|
entries: [],
|
||||||
|
maps: discoverMapEntries(files),
|
||||||
|
totalBytes: files.reduce((sum, item) => sum + item.size, 0),
|
||||||
|
};
|
||||||
|
expect(visualMapAssets(projectManifest, [])).toEqual([]);
|
||||||
|
expect(
|
||||||
|
visualMapAssets(projectManifest, [
|
||||||
|
{
|
||||||
|
id: 'instance-a',
|
||||||
|
name: '仓库副本',
|
||||||
|
selection: {
|
||||||
|
kind: 'project',
|
||||||
|
descriptorPath: 'maps/warehouse/map.json',
|
||||||
|
positionX: 2,
|
||||||
|
positionY: -1,
|
||||||
|
yawDeg: 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'instance-a',
|
||||||
|
name: '仓库副本',
|
||||||
|
path: 'maps/warehouse/visuals/scene.glb',
|
||||||
|
positionX: 2,
|
||||||
|
positionY: -1,
|
||||||
|
yawDeg: 30,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('发现 schema V2 authoring 创作层', () => {
|
it('发现 schema V2 authoring 创作层', () => {
|
||||||
const editable = JSON.stringify({
|
const editable = JSON.stringify({
|
||||||
...JSON.parse(descriptor),
|
...JSON.parse(descriptor),
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import type { MapEntry, ProjectFile, ProjectManifest } from '../project/types';
|
import type { MapEntry, ProjectFile, ProjectManifest } from '../project/types';
|
||||||
import { decodeMapDefinition } from './mapSchema';
|
import { decodeMapDefinition } from './mapSchema';
|
||||||
import { resolveProjectAssetPath } from './mapPaths';
|
import { resolveProjectAssetPath } from './mapPaths';
|
||||||
import type { MapSelection, ResolvedProjectMap, VisualMapAsset } from './types';
|
import {
|
||||||
|
mapSelectionTransform,
|
||||||
|
type MapSelection,
|
||||||
|
type PlacedMapAsset,
|
||||||
|
type ResolvedProjectMap,
|
||||||
|
type VisualMapAsset,
|
||||||
|
} from './types';
|
||||||
import { decodeEditableMapDocument } from './editor/editorSchema';
|
import { decodeEditableMapDocument } from './editor/editorSchema';
|
||||||
|
|
||||||
function projectFile(manifest: ProjectManifest, path: string): ProjectFile {
|
function projectFile(manifest: ProjectManifest, path: string): ProjectFile {
|
||||||
@@ -92,6 +98,7 @@ export function visualMapAsset(
|
|||||||
const resolved = resolveProjectMap(manifest, selection.descriptorPath);
|
const resolved = resolveProjectMap(manifest, selection.descriptorPath);
|
||||||
if (!resolved.visualPath || !resolved.definition.visual) return null;
|
if (!resolved.visualPath || !resolved.definition.visual) return null;
|
||||||
const visual = projectFile(manifest, resolved.visualPath);
|
const visual = projectFile(manifest, resolved.visualPath);
|
||||||
|
const transform = mapSelectionTransform(selection);
|
||||||
return {
|
return {
|
||||||
id: resolved.definition.id,
|
id: resolved.definition.id,
|
||||||
name: resolved.definition.name,
|
name: resolved.definition.name,
|
||||||
@@ -99,5 +106,17 @@ export function visualMapAsset(
|
|||||||
data: visual.data,
|
data: visual.data,
|
||||||
castShadow: resolved.definition.visual.castShadow ?? true,
|
castShadow: resolved.definition.visual.castShadow ?? true,
|
||||||
receiveShadow: resolved.definition.visual.receiveShadow ?? true,
|
receiveShadow: resolved.definition.visual.receiveShadow ?? true,
|
||||||
|
...transform,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将已放置的 project 地图实例解析为可同时挂载的视觉层。 */
|
||||||
|
export function visualMapAssets(
|
||||||
|
manifest: ProjectManifest,
|
||||||
|
assets: readonly PlacedMapAsset[],
|
||||||
|
): VisualMapAsset[] {
|
||||||
|
return assets.flatMap((asset) => {
|
||||||
|
const visual = visualMapAsset(manifest, asset.selection);
|
||||||
|
return visual ? [{ ...visual, id: asset.id, name: asset.name }] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import { Copy, LockKeyhole, Trash2 } from 'lucide-react';
|
||||||
|
import { Button, ScrubbableNumberInput, Select } from '../components/ui';
|
||||||
|
import { MAP_OBJECT_PLACEMENT_LABELS, type EditableMapObject } from './editor/types';
|
||||||
|
|
||||||
|
const PARAMETER_LABELS: Record<string, string> = {
|
||||||
|
sizeX: '尺寸 X(m)',
|
||||||
|
sizeY: '尺寸 Y(m)',
|
||||||
|
sizeZ: '尺寸 Z(m)',
|
||||||
|
radius: '半径(m)',
|
||||||
|
height: '高度(m)',
|
||||||
|
length: '长度(m)',
|
||||||
|
width: '宽度(m)',
|
||||||
|
rise: '抬升(m)',
|
||||||
|
thickness: '厚度(m)',
|
||||||
|
stepDepth: '踏步深度(m)',
|
||||||
|
stepHeight: '踏步高度(m)',
|
||||||
|
count: '踏步数量',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SURFACE_PRESETS = {
|
||||||
|
standard: {
|
||||||
|
label: '标准防滑材质',
|
||||||
|
rgb: [0.55, 0.6, 0.68] as const,
|
||||||
|
friction: [1, 0.005, 0.0001] as const,
|
||||||
|
},
|
||||||
|
concrete: {
|
||||||
|
label: '混凝土',
|
||||||
|
rgb: [0.55, 0.58, 0.62] as const,
|
||||||
|
friction: [1, 0.005, 0.0001] as const,
|
||||||
|
},
|
||||||
|
rubber: {
|
||||||
|
label: '橡胶',
|
||||||
|
rgb: [0.12, 0.14, 0.16] as const,
|
||||||
|
friction: [1.5, 0.008, 0.0002] as const,
|
||||||
|
},
|
||||||
|
metal: {
|
||||||
|
label: '金属',
|
||||||
|
rgb: [0.42, 0.48, 0.54] as const,
|
||||||
|
friction: [0.45, 0.003, 0.0001] as const,
|
||||||
|
},
|
||||||
|
ice: {
|
||||||
|
label: '低摩擦冰面',
|
||||||
|
rgb: [0.58, 0.82, 0.94] as const,
|
||||||
|
friction: [0.08, 0.001, 0.00001] as const,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type SurfacePreset = keyof typeof SURFACE_PRESETS;
|
||||||
|
|
||||||
|
function close(a: number, b: number): boolean {
|
||||||
|
return Math.abs(a - b) < 0.015;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedSurfacePreset(object: EditableMapObject): SurfacePreset | 'custom' {
|
||||||
|
for (const [key, preset] of Object.entries(SURFACE_PRESETS) as [
|
||||||
|
SurfacePreset,
|
||||||
|
(typeof SURFACE_PRESETS)[SurfacePreset],
|
||||||
|
][]) {
|
||||||
|
if (
|
||||||
|
preset.rgb.every((channel, index) => close(channel, object.rgba[index])) &&
|
||||||
|
close(preset.friction[0], object.friction[0])
|
||||||
|
)
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
return 'custom';
|
||||||
|
}
|
||||||
|
|
||||||
|
function yawDegrees(object: EditableMapObject): number {
|
||||||
|
const [w, x, y, z] = object.pose.quaternion;
|
||||||
|
return (Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)) * 180) / Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorValue(object: EditableMapObject): string {
|
||||||
|
return `#${object.rgba
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((channel) =>
|
||||||
|
Math.round(Math.min(1, Math.max(0, channel)) * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0'),
|
||||||
|
)
|
||||||
|
.join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="engineering-card rounded-lg border p-2.5">
|
||||||
|
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-text-tertiary">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapObjectInspector({
|
||||||
|
object,
|
||||||
|
loading,
|
||||||
|
onUpdate,
|
||||||
|
onDuplicate,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
object: EditableMapObject;
|
||||||
|
loading: boolean;
|
||||||
|
onUpdate: (patch: Partial<Omit<EditableMapObject, 'id' | 'type'>>) => void;
|
||||||
|
onDuplicate: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const poseLocked = object.placementMode === 'locked';
|
||||||
|
const updatePosition = (index: number, value: number) => {
|
||||||
|
if (!Number.isFinite(value)) return;
|
||||||
|
const position = [...object.pose.position] as [number, number, number];
|
||||||
|
position[index] = value;
|
||||||
|
onUpdate({ pose: { ...object.pose, position } });
|
||||||
|
};
|
||||||
|
const updateParameter = (key: string, value: number) => {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return;
|
||||||
|
onUpdate({
|
||||||
|
parameters: {
|
||||||
|
...object.parameters,
|
||||||
|
[key]: key === 'count' ? Math.round(value) : value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const updateFriction = (index: number, value: number) => {
|
||||||
|
if (!Number.isFinite(value) || value < 0) return;
|
||||||
|
const friction = [...object.friction] as [number, number, number];
|
||||||
|
friction[index] = value;
|
||||||
|
onUpdate({ friction });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2" aria-label="地图物体检查器">
|
||||||
|
<Section title="对象">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-[11px] text-text-secondary">
|
||||||
|
名称
|
||||||
|
<input
|
||||||
|
aria-label="对象名称"
|
||||||
|
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-2"
|
||||||
|
value={object.name}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (event.target.value.trim()) onUpdate({ name: event.target.value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between text-[11px] text-text-secondary">
|
||||||
|
<span>启用碰撞与渲染</span>
|
||||||
|
<input
|
||||||
|
aria-label="启用地图对象"
|
||||||
|
type="checkbox"
|
||||||
|
checked={object.enabled}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={(event) => onUpdate({ enabled: event.target.checked })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="位姿">
|
||||||
|
<div className="mb-2 flex items-center justify-between rounded bg-surface/55 px-2 py-1.5 text-[10px] text-text-secondary">
|
||||||
|
<span>{MAP_OBJECT_PLACEMENT_LABELS[object.placementMode]}</span>
|
||||||
|
<span className="flex items-center gap-1 text-text-tertiary">
|
||||||
|
{poseLocked && <LockKeyhole className="h-3 w-3" aria-hidden="true" />}
|
||||||
|
在视口工具条切换
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{object.pose.position.map((value, index) => (
|
||||||
|
<ScrubbableNumberInput
|
||||||
|
key={index}
|
||||||
|
label={`${['X', 'Y', 'Z'][index]}(m)`}
|
||||||
|
aria-label={`对象位置${['X', 'Y', 'Z'][index]}`}
|
||||||
|
value={value}
|
||||||
|
step={0.1}
|
||||||
|
disabled={loading || poseLocked || index === 2}
|
||||||
|
onValueChange={(next) => updatePosition(index, next)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ScrubbableNumberInput
|
||||||
|
containerClassName="mt-2"
|
||||||
|
label="绕 Z 旋转(°)"
|
||||||
|
aria-label="对象绕Z旋转"
|
||||||
|
value={Number(yawDegrees(object).toFixed(4))}
|
||||||
|
step={1}
|
||||||
|
disabled={loading || poseLocked}
|
||||||
|
onValueChange={(next) => {
|
||||||
|
const half = (next * Math.PI) / 360;
|
||||||
|
onUpdate({
|
||||||
|
pose: {
|
||||||
|
...object.pose,
|
||||||
|
quaternion: [Math.cos(half), 0, 0, Math.sin(half)],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="尺寸">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{Object.entries(object.parameters).map(([key, value]) => (
|
||||||
|
<ScrubbableNumberInput
|
||||||
|
key={key}
|
||||||
|
label={PARAMETER_LABELS[key] ?? key}
|
||||||
|
aria-label={`对象参数${key}`}
|
||||||
|
value={value}
|
||||||
|
min={0.001}
|
||||||
|
step={key === 'count' ? 1 : 0.1}
|
||||||
|
disabled={loading}
|
||||||
|
onValueChange={(next) => updateParameter(key, next)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="表面材质">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<label className="text-[10px] text-text-secondary">
|
||||||
|
材质预设
|
||||||
|
<Select
|
||||||
|
aria-label="对象表面材质"
|
||||||
|
className="mt-1 w-full"
|
||||||
|
value={selectedSurfacePreset(object)}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (event.target.value === 'custom') return;
|
||||||
|
const preset = SURFACE_PRESETS[event.target.value as SurfacePreset];
|
||||||
|
onUpdate({
|
||||||
|
rgba: [...preset.rgb, object.rgba[3]],
|
||||||
|
friction: [...preset.friction],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="custom">自定义</option>
|
||||||
|
{(
|
||||||
|
Object.entries(SURFACE_PRESETS) as [
|
||||||
|
SurfacePreset,
|
||||||
|
(typeof SURFACE_PRESETS)[SurfacePreset],
|
||||||
|
][]
|
||||||
|
).map(([value, preset]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{preset.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<label className="text-[10px] text-text-secondary">
|
||||||
|
表面颜色
|
||||||
|
<input
|
||||||
|
aria-label="对象颜色"
|
||||||
|
className="mt-1 h-7 w-full rounded border border-border-strong bg-input p-0.5"
|
||||||
|
type="color"
|
||||||
|
value={colorValue(object)}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
if (!/^#[0-9a-f]{6}$/i.test(value)) return;
|
||||||
|
onUpdate({
|
||||||
|
rgba: [
|
||||||
|
Number.parseInt(value.slice(1, 3), 16) / 255,
|
||||||
|
Number.parseInt(value.slice(3, 5), 16) / 255,
|
||||||
|
Number.parseInt(value.slice(5, 7), 16) / 255,
|
||||||
|
object.rgba[3],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<ScrubbableNumberInput
|
||||||
|
containerClassName="mt-2"
|
||||||
|
label="不透明度"
|
||||||
|
aria-label="对象不透明度"
|
||||||
|
value={object.rgba[3]}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
disabled={loading}
|
||||||
|
onValueChange={(alpha) =>
|
||||||
|
onUpdate({
|
||||||
|
rgba: [object.rgba[0], object.rgba[1], object.rgba[2], alpha],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="摩擦力">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{object.friction.map((value, index) => (
|
||||||
|
<ScrubbableNumberInput
|
||||||
|
key={index}
|
||||||
|
label={['滑动', '扭转', '滚动'][index]}
|
||||||
|
aria-label={`对象${['滑动摩擦', '扭转摩擦', '滚动摩擦'][index]}`}
|
||||||
|
value={value}
|
||||||
|
min={0}
|
||||||
|
max={5}
|
||||||
|
step={index === 0 ? 0.05 : 0.0001}
|
||||||
|
disabled={loading}
|
||||||
|
onValueChange={(next) => updateFriction(index, next)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button disabled={loading} icon={<Copy className="h-3 w-3" />} onClick={onDuplicate}>
|
||||||
|
复制对象
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
disabled={loading}
|
||||||
|
icon={<Trash2 className="h-3 w-3" />}
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
删除对象
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { composePlacedMapAssets } from './MapStackComposer';
|
||||||
|
import { DEFAULT_PHYSICAL_MAP_CONFIG, type PlacedMapAsset } from './types';
|
||||||
|
import type { ProjectFile, ProjectManifest } from '../project/types';
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const file = (path: string, text: string): ProjectFile => ({
|
||||||
|
path,
|
||||||
|
data: encoder.encode(text),
|
||||||
|
size: encoder.encode(text).byteLength,
|
||||||
|
source: 'directory',
|
||||||
|
mimeType: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const robot = encoder.encode(`
|
||||||
|
<mujoco>
|
||||||
|
<worldbody>
|
||||||
|
<geom name="__platform_ground__" type="plane" size="2 2 .1"/>
|
||||||
|
<body name="robot"><freejoint/><geom type="box" size=".1 .1 .1"/></body>
|
||||||
|
</worldbody>
|
||||||
|
</mujoco>`);
|
||||||
|
|
||||||
|
function manifest(): ProjectManifest {
|
||||||
|
const files = [
|
||||||
|
file(
|
||||||
|
'maps/warehouse/map.json',
|
||||||
|
JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
id: 'warehouse',
|
||||||
|
name: '仓库',
|
||||||
|
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||||
|
physics: { source: 'physics/world.xml' },
|
||||||
|
spawnPoints: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
file(
|
||||||
|
'maps/warehouse/physics/world.xml',
|
||||||
|
'<mujoco><worldbody><body name="building"><geom name="wall" type="box" size="1 .1 1"/></body></worldbody></mujoco>',
|
||||||
|
),
|
||||||
|
file(
|
||||||
|
'maps/overlay/map.json',
|
||||||
|
JSON.stringify({
|
||||||
|
schemaVersion: 2,
|
||||||
|
id: 'overlay',
|
||||||
|
name: '体素层',
|
||||||
|
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||||
|
physics: { source: 'physics/world.xml' },
|
||||||
|
authoring: { source: 'authoring/map.scene.json' },
|
||||||
|
spawnPoints: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
file(
|
||||||
|
'maps/overlay/physics/world.xml',
|
||||||
|
'<mujoco><worldbody><body name="edit_body_box"><geom name="edit_box" type="box" size=".5 .5 .5"/></body></worldbody></mujoco>',
|
||||||
|
),
|
||||||
|
file(
|
||||||
|
'maps/overlay/authoring/map.scene.json',
|
||||||
|
JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
mapId: 'overlay',
|
||||||
|
revision: 0,
|
||||||
|
objects: [],
|
||||||
|
spawnPoints: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
id: 'test',
|
||||||
|
name: 'test',
|
||||||
|
files,
|
||||||
|
entries: [{ path: 'robot.xml', format: 'mjcf', label: 'robot' }],
|
||||||
|
maps: [],
|
||||||
|
totalBytes: files.reduce((sum, item) => sum + item.size, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentOf(data: Uint8Array): Document {
|
||||||
|
return new DOMParser().parseFromString(decoder.decode(data), 'application/xml');
|
||||||
|
}
|
||||||
|
|
||||||
|
function project(id: string, descriptorPath = 'maps/warehouse/map.json'): PlacedMapAsset {
|
||||||
|
return { id, name: `仓库 ${id}`, selection: { kind: 'project', descriptorPath } };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('composePlacedMapAssets', () => {
|
||||||
|
it('同时保留工程地图、编辑方盒层和系统参数地形', () => {
|
||||||
|
const assets: PlacedMapAsset[] = [
|
||||||
|
project('warehouse-a'),
|
||||||
|
project('voxel-a', 'maps/overlay/map.json'),
|
||||||
|
{
|
||||||
|
id: 'terrain-a',
|
||||||
|
name: '粗糙地形',
|
||||||
|
selection: {
|
||||||
|
kind: 'builtin',
|
||||||
|
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough', size: 6 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = composePlacedMapAssets(robot, 'scene.xml', manifest(), assets);
|
||||||
|
const document = documentOf(result.data);
|
||||||
|
expect(
|
||||||
|
document.querySelector('[name="__platform_map_warehouse_warehouse-a_wall"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
document.querySelector('[name="__platform_map_overlay_voxel-a_edit_box"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
document.querySelector('[name^="__platform_map_terrain-a__"][type="hfield"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(document.querySelector('[name="__platform_ground__"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无论场景顺序如何,程序地形都会移除覆盖坑洞的工程无限平面', () => {
|
||||||
|
const projectManifest = manifest();
|
||||||
|
const physics = projectManifest.files.find(
|
||||||
|
(candidate) => candidate.path === 'maps/warehouse/physics/world.xml',
|
||||||
|
)!;
|
||||||
|
physics.data = encoder.encode(
|
||||||
|
'<mujoco><worldbody><geom name="floor" type="plane" size="8 8 .1"/></worldbody></mujoco>',
|
||||||
|
);
|
||||||
|
physics.size = physics.data.byteLength;
|
||||||
|
const result = composePlacedMapAssets(robot, 'scene.xml', projectManifest, [
|
||||||
|
{
|
||||||
|
id: 'pit-a',
|
||||||
|
name: '深坑',
|
||||||
|
selection: {
|
||||||
|
kind: 'builtin',
|
||||||
|
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'pit' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
project('warehouse-a'),
|
||||||
|
]);
|
||||||
|
const document = documentOf(result.data);
|
||||||
|
expect(document.querySelectorAll('geom[type="plane"]')).toHaveLength(0);
|
||||||
|
expect(document.querySelector('[name^="__platform_map_pit-a__pit-bottom"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非系统内置地图复用已有承载面,不生成重叠地面', () => {
|
||||||
|
const result = composePlacedMapAssets(robot, 'scene.xml', manifest(), [
|
||||||
|
{
|
||||||
|
id: 'stairs-a',
|
||||||
|
name: '楼梯',
|
||||||
|
selection: {
|
||||||
|
kind: 'builtin',
|
||||||
|
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'stairs', stairCount: 4 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'obstacles-a',
|
||||||
|
name: '障碍物',
|
||||||
|
selection: {
|
||||||
|
kind: 'builtin',
|
||||||
|
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'obstacles', obstacleCount: 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const document = documentOf(result.data);
|
||||||
|
expect(document.querySelectorAll('geom[type="plane"]')).toHaveLength(1);
|
||||||
|
expect(document.querySelector('[name$="__ground__"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同一工程地图重复放置时使用实例命名空间和独立位姿', () => {
|
||||||
|
const first = project('copy-a');
|
||||||
|
first.selection = {
|
||||||
|
kind: 'project',
|
||||||
|
descriptorPath: 'maps/warehouse/map.json',
|
||||||
|
positionX: 2,
|
||||||
|
positionY: -1,
|
||||||
|
yawDeg: 30,
|
||||||
|
};
|
||||||
|
const second = project('copy-b');
|
||||||
|
second.selection = {
|
||||||
|
kind: 'project',
|
||||||
|
descriptorPath: 'maps/warehouse/map.json',
|
||||||
|
positionX: -3,
|
||||||
|
positionY: 4,
|
||||||
|
yawDeg: -45,
|
||||||
|
};
|
||||||
|
const result = composePlacedMapAssets(robot, 'scene.xml', manifest(), [first, second]);
|
||||||
|
const document = documentOf(result.data);
|
||||||
|
expect(document.querySelector('[name="__platform_map_warehouse_copy-a_wall"]')).not.toBeNull();
|
||||||
|
expect(document.querySelector('[name="__platform_map_warehouse_copy-b_wall"]')).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
document
|
||||||
|
.querySelector('[name="__platform_map_warehouse_copy-a_instance_root"]')
|
||||||
|
?.getAttribute('pos'),
|
||||||
|
).toBe('2 -1 0');
|
||||||
|
expect(
|
||||||
|
document
|
||||||
|
.querySelector('[name="__platform_map_warehouse_copy-b_instance_root"]')
|
||||||
|
?.getAttribute('pos'),
|
||||||
|
).toBe('-3 4 0');
|
||||||
|
const names = Array.from(document.querySelectorAll('[name]'), (element) =>
|
||||||
|
element.getAttribute('name'),
|
||||||
|
);
|
||||||
|
expect(new Set(names).size).toBe(names.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('多个实例声明出生点时只确定性应用第一个', () => {
|
||||||
|
const projectManifest = manifest();
|
||||||
|
const descriptorFile = projectManifest.files.find(
|
||||||
|
(candidate) => candidate.path === 'maps/warehouse/map.json',
|
||||||
|
)!;
|
||||||
|
const descriptorValue = JSON.parse(decoder.decode(descriptorFile.data));
|
||||||
|
descriptorValue.spawnPoints = [
|
||||||
|
{ id: 'first', name: '第一出生点', position: [1, 0, 0], yawDeg: 0 },
|
||||||
|
{ id: 'second', name: '第二出生点', position: [10, 0, 0], yawDeg: 0 },
|
||||||
|
];
|
||||||
|
descriptorFile.data = encoder.encode(JSON.stringify(descriptorValue));
|
||||||
|
descriptorFile.size = descriptorFile.data.byteLength;
|
||||||
|
const result = composePlacedMapAssets(robot, 'scene.xml', projectManifest, [
|
||||||
|
{
|
||||||
|
...project('copy-a'),
|
||||||
|
selection: {
|
||||||
|
kind: 'project',
|
||||||
|
descriptorPath: 'maps/warehouse/map.json',
|
||||||
|
spawnPointId: 'first',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...project('copy-b'),
|
||||||
|
selection: {
|
||||||
|
kind: 'project',
|
||||||
|
descriptorPath: 'maps/warehouse/map.json',
|
||||||
|
spawnPointId: 'second',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
documentOf(result.data)
|
||||||
|
.querySelector('body[name="robot"]')
|
||||||
|
?.getAttribute('pos')
|
||||||
|
?.split(/\s+/)
|
||||||
|
.map(Number),
|
||||||
|
).toEqual([1, 0, 0]);
|
||||||
|
expect(result.warnings.join(' ')).toContain('第一出生点');
|
||||||
|
expect(result.warnings.join(' ')).not.toContain('第二出生点');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('多个内置实例同时存在且名称不冲突', () => {
|
||||||
|
const result = composePlacedMapAssets(robot, 'scene.xml', manifest(), [
|
||||||
|
{
|
||||||
|
id: 'stairs-a',
|
||||||
|
name: '楼梯',
|
||||||
|
selection: {
|
||||||
|
kind: 'builtin',
|
||||||
|
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'stairs', stairCount: 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'obstacles-a',
|
||||||
|
name: '障碍物',
|
||||||
|
selection: {
|
||||||
|
kind: 'builtin',
|
||||||
|
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'obstacles', obstacleCount: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const document = documentOf(result.data);
|
||||||
|
expect(document.querySelectorAll('[name^="__platform_map_stairs-a__stair_"]')).toHaveLength(3);
|
||||||
|
expect(
|
||||||
|
document.querySelectorAll('[name^="__platform_map_obstacles-a__obstacle_"]'),
|
||||||
|
).toHaveLength(2);
|
||||||
|
expect(document.querySelector('[name="__platform_map_stairs-a__root__"]')).not.toBeNull();
|
||||||
|
expect(document.querySelector('[name="__platform_map_obstacles-a__root__"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { ProjectManifest } from '../project/types';
|
||||||
|
import { resolveProjectMap } from './MapLoader';
|
||||||
|
import { composeProjectMap } from './MapComposer';
|
||||||
|
import { composePhysicalMap } from './physicalMap';
|
||||||
|
import { isSystemTerrainPreset, type PlacedMapAsset } from './types';
|
||||||
|
|
||||||
|
export interface MapStackComposition {
|
||||||
|
data: Uint8Array;
|
||||||
|
geomCount: number;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将全部已放置地图实例依次合成到同一 MJCF;源地图条目本身不会自动进入场景。 */
|
||||||
|
export function composePlacedMapAssets(
|
||||||
|
source: Uint8Array,
|
||||||
|
generatedScenePath: string,
|
||||||
|
manifest: ProjectManifest,
|
||||||
|
assets: readonly PlacedMapAsset[],
|
||||||
|
): MapStackComposition {
|
||||||
|
let data = source;
|
||||||
|
let geomCount = 0;
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const spawnOwnerId = assets.find(
|
||||||
|
(asset) => asset.selection.kind === 'project' && asset.selection.spawnPointId,
|
||||||
|
)?.id;
|
||||||
|
// 程序地形最后合成,确保工程地图或机器人自带的无限 plane 不会覆盖沟壑/深坑。
|
||||||
|
const orderedAssets = [
|
||||||
|
...assets.filter(
|
||||||
|
(asset) =>
|
||||||
|
asset.selection.kind !== 'builtin' || !isSystemTerrainPreset(asset.selection.config.preset),
|
||||||
|
),
|
||||||
|
...assets.filter(
|
||||||
|
(asset) =>
|
||||||
|
asset.selection.kind === 'builtin' && isSystemTerrainPreset(asset.selection.config.preset),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const asset of orderedAssets) {
|
||||||
|
if (asset.selection.kind === 'builtin') {
|
||||||
|
const composed = composePhysicalMap(data, asset.selection.config, {
|
||||||
|
instanceId: asset.id,
|
||||||
|
preserveExistingGround: true,
|
||||||
|
});
|
||||||
|
data = composed.data;
|
||||||
|
geomCount += composed.geomCount;
|
||||||
|
if (composed.summary) warnings.push(`${asset.name}:${composed.summary}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedMap = resolveProjectMap(manifest, asset.selection.descriptorPath);
|
||||||
|
const composed = composeProjectMap(
|
||||||
|
data,
|
||||||
|
generatedScenePath,
|
||||||
|
manifest,
|
||||||
|
resolvedMap,
|
||||||
|
asset.selection,
|
||||||
|
{
|
||||||
|
instanceId: asset.id,
|
||||||
|
applySpawnPoint: asset.id === spawnOwnerId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
data = composed.data;
|
||||||
|
geomCount += composed.geomCount;
|
||||||
|
warnings.push(...composed.warnings, `${asset.name}:${composed.summary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data, geomCount, warnings };
|
||||||
|
}
|
||||||
@@ -11,21 +11,50 @@ const common = {
|
|||||||
showMapCollision: false,
|
showMapCollision: false,
|
||||||
editorDocument: null,
|
editorDocument: null,
|
||||||
onEditorPreview: () => {},
|
onEditorPreview: () => {},
|
||||||
|
onEditorDraftChange: () => {},
|
||||||
onEditorApply: async () => true,
|
onEditorApply: async () => true,
|
||||||
onEditorExport: () => {},
|
onEditorExport: () => {},
|
||||||
onEditorConvert: async () => true,
|
onEditorConvert: async () => true,
|
||||||
onEditorBindInteraction: () => {},
|
onEditorBindInteraction: () => {},
|
||||||
onEditorSelect: () => {},
|
onEditorSelect: () => {},
|
||||||
onEditorTransformMode: () => {},
|
onEditorSessionStateChange: () => {},
|
||||||
onEditorSnapping: () => {},
|
|
||||||
onMapDisplay: () => {},
|
onMapDisplay: () => {},
|
||||||
|
onDraft: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('PhysicalMapPanel', () => {
|
describe('PhysicalMapPanel', () => {
|
||||||
|
it('无选中实例时显示引导空态', () => {
|
||||||
|
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={() => {}} />);
|
||||||
|
expect(screen.getByText('未选择地图实例')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('地图来源')).toHaveValue('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('已有场景草稿时无需再次改参数也可一次编译', () => {
|
||||||
|
const onApply = vi.fn();
|
||||||
|
render(
|
||||||
|
<PhysicalMapPanel
|
||||||
|
{...common}
|
||||||
|
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough' } }}
|
||||||
|
sceneDirty
|
||||||
|
onApply={onApply}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/场景草稿正在轻量预览/)).toBeInTheDocument();
|
||||||
|
const apply = screen.getByRole('button', { name: '应用并重新编译' });
|
||||||
|
expect(apply).toBeEnabled();
|
||||||
|
fireEvent.click(apply);
|
||||||
|
expect(onApply).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('选择楼梯参数后请求重新编译', () => {
|
it('选择楼梯参数后请求重新编译', () => {
|
||||||
const onApply = vi.fn();
|
const onApply = vi.fn();
|
||||||
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
|
render(
|
||||||
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
|
<PhysicalMapPanel
|
||||||
|
{...common}
|
||||||
|
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'flat' } }}
|
||||||
|
onApply={onApply}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'stairs' } });
|
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'stairs' } });
|
||||||
fireEvent.change(screen.getByLabelText('台阶数量'), { target: { value: '6' } });
|
fireEvent.change(screen.getByLabelText('台阶数量'), { target: { value: '6' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||||
@@ -37,8 +66,13 @@ describe('PhysicalMapPanel', () => {
|
|||||||
|
|
||||||
it('在右侧参数面板调整系统地形并提交', () => {
|
it('在右侧参数面板调整系统地形并提交', () => {
|
||||||
const onApply = vi.fn();
|
const onApply = vi.fn();
|
||||||
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
|
render(
|
||||||
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
|
<PhysicalMapPanel
|
||||||
|
{...common}
|
||||||
|
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'flat' } }}
|
||||||
|
onApply={onApply}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'rough' } });
|
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'rough' } });
|
||||||
fireEvent.change(screen.getByLabelText('地形难度(0–1)'), { target: { value: '0.8' } });
|
fireEvent.change(screen.getByLabelText('地形难度(0–1)'), { target: { value: '0.8' } });
|
||||||
fireEvent.change(screen.getByLabelText('随机种子'), { target: { value: '9' } });
|
fireEvent.change(screen.getByLabelText('随机种子'), { target: { value: '9' } });
|
||||||
@@ -53,10 +87,64 @@ describe('PhysicalMapPanel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('参数地形属性不再承载视口变换工具', () => {
|
||||||
|
render(
|
||||||
|
<PhysicalMapPanel
|
||||||
|
{...common}
|
||||||
|
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough' } }}
|
||||||
|
onApply={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByLabelText('地图变换模式')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/已统一到视口浮动工具条/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('表单参数与外部视口变换合并到同一份草稿', () => {
|
||||||
|
const onDraft = vi.fn();
|
||||||
|
const onApply = vi.fn();
|
||||||
|
const initial = {
|
||||||
|
kind: 'builtin' as const,
|
||||||
|
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough' as const },
|
||||||
|
};
|
||||||
|
const view = render(
|
||||||
|
<PhysicalMapPanel
|
||||||
|
{...common}
|
||||||
|
value={initial}
|
||||||
|
sceneDirty
|
||||||
|
onDraft={onDraft}
|
||||||
|
onApply={onApply}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByLabelText('地形边长(m)'), { target: { value: '7' } });
|
||||||
|
const staged = onDraft.mock.calls.at(-1)?.[0];
|
||||||
|
expect(staged).toMatchObject({ kind: 'builtin', config: { size: 7 } });
|
||||||
|
const moved = {
|
||||||
|
...staged,
|
||||||
|
config: { ...staged.config, positionX: 2.4, positionY: -1.2 },
|
||||||
|
};
|
||||||
|
view.rerender(
|
||||||
|
<PhysicalMapPanel {...common} value={moved} sceneDirty onDraft={onDraft} onApply={onApply} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText('地形边长(m)')).toHaveValue(7);
|
||||||
|
expect(screen.getByLabelText('位置 X(m)')).toHaveValue(2.4);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||||
|
expect(onApply).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: 'builtin',
|
||||||
|
config: expect.objectContaining({ size: 7, positionX: 2.4, positionY: -1.2 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('可以移动并旋转资产地图', () => {
|
it('可以移动并旋转资产地图', () => {
|
||||||
const onApply = vi.fn();
|
const onApply = vi.fn();
|
||||||
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
|
render(
|
||||||
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
|
<PhysicalMapPanel
|
||||||
|
{...common}
|
||||||
|
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'flat' } }}
|
||||||
|
onApply={onApply}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
fireEvent.change(screen.getByLabelText('位置 X(m)'), { target: { value: '2.5' } });
|
fireEvent.change(screen.getByLabelText('位置 X(m)'), { target: { value: '2.5' } });
|
||||||
fireEvent.change(screen.getByLabelText('位置 Y(m)'), { target: { value: '-1.5' } });
|
fireEvent.change(screen.getByLabelText('位置 Y(m)'), { target: { value: '-1.5' } });
|
||||||
fireEvent.change(screen.getByLabelText('旋转 Z(°)'), { target: { value: '45' } });
|
fireEvent.change(screen.getByLabelText('旋转 Z(°)'), { target: { value: '45' } });
|
||||||
@@ -67,12 +155,13 @@ describe('PhysicalMapPanel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('显示工程地图、出生点和根 Body', () => {
|
it('显示工程地图、实例变换、出生点和根 Body', () => {
|
||||||
const onApply = vi.fn();
|
const onApply = vi.fn();
|
||||||
|
const onDraft = vi.fn();
|
||||||
render(
|
render(
|
||||||
<PhysicalMapPanel
|
<PhysicalMapPanel
|
||||||
{...common}
|
{...common}
|
||||||
value={{ kind: 'none' }}
|
value={{ kind: 'project', descriptorPath: 'maps/warehouse/map.json' }}
|
||||||
maps={[
|
maps={[
|
||||||
{
|
{
|
||||||
descriptorPath: 'maps/warehouse/map.json',
|
descriptorPath: 'maps/warehouse/map.json',
|
||||||
@@ -83,12 +172,14 @@ describe('PhysicalMapPanel', () => {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
rootBodies={['robot', 'payload']}
|
rootBodies={['robot', 'payload']}
|
||||||
|
onDraft={onDraft}
|
||||||
onApply={onApply}
|
onApply={onApply}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
fireEvent.change(screen.getByLabelText('地图来源'), {
|
fireEvent.change(screen.getByLabelText('位置 X(m)'), { target: { value: '2.5' } });
|
||||||
target: { value: 'project:maps/warehouse/map.json' },
|
fireEvent.change(screen.getByLabelText('位置 Y(m)'), { target: { value: '-1.5' } });
|
||||||
});
|
fireEvent.change(screen.getByLabelText('旋转 Z(°)'), { target: { value: '45' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('地图出生点'), { target: { value: 'door' } });
|
||||||
expect(screen.getByLabelText('地图出生点')).toHaveValue('door');
|
expect(screen.getByLabelText('地图出生点')).toHaveValue('door');
|
||||||
fireEvent.change(screen.getByLabelText('机器人根 Body'), { target: { value: 'robot' } });
|
fireEvent.change(screen.getByLabelText('机器人根 Body'), { target: { value: 'robot' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||||
@@ -96,10 +187,16 @@ describe('PhysicalMapPanel', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
kind: 'project',
|
kind: 'project',
|
||||||
descriptorPath: 'maps/warehouse/map.json',
|
descriptorPath: 'maps/warehouse/map.json',
|
||||||
|
positionX: 2.5,
|
||||||
|
positionY: -1.5,
|
||||||
|
yawDeg: 45,
|
||||||
spawnPointId: 'door',
|
spawnPointId: 'door',
|
||||||
robotRootBody: 'robot',
|
robotRootBody: 'robot',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(onDraft).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ kind: 'project', robotRootBody: 'robot' }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('原生 URDF 模式只允许移除地图', () => {
|
it('原生 URDF 模式只允许移除地图', () => {
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Select } from '../components/ui';
|
import { Button, ScrubbableNumberInput, Select } from '../components/ui';
|
||||||
import type { MapEntry } from '../project/types';
|
import type { MapEntry } from '../project/types';
|
||||||
import {
|
import {
|
||||||
DEFAULT_PHYSICAL_MAP_CONFIG,
|
DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||||
PHYSICAL_MAP_PRESET_LABELS,
|
PHYSICAL_MAP_PRESET_LABELS,
|
||||||
isSystemTerrainPreset,
|
isSystemTerrainPreset,
|
||||||
|
mapSelectionTransform,
|
||||||
type MapSelection,
|
type MapSelection,
|
||||||
type PhysicalMapConfig,
|
type PhysicalMapConfig,
|
||||||
type PhysicalMapPreset,
|
type PhysicalMapPreset,
|
||||||
|
type PlacedMapSelection,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { normalizePhysicalMapConfig } from './physicalMap';
|
import { normalizePhysicalMapConfig } from './physicalMap';
|
||||||
import type {
|
import type {
|
||||||
EditableMapDocument,
|
EditableMapDocument,
|
||||||
MapEditorInteractionCallbacks,
|
MapEditorInteractionCallbacks,
|
||||||
MapEditorTransformMode,
|
MapEditorSessionState,
|
||||||
} from './editor/types';
|
} from './editor/types';
|
||||||
import { MapEditorPanel } from './MapEditorPanel';
|
import { MapEditorPanel } from './MapEditorPanel';
|
||||||
|
|
||||||
@@ -35,19 +37,15 @@ function NumberField({
|
|||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<label className="block text-xs text-text-secondary">
|
<ScrubbableNumberInput
|
||||||
<span className="mb-1 block">{label}</span>
|
label={label}
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
value={value}
|
value={value}
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(event) => onChange(Number(event.target.value))}
|
onValueChange={onChange}
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +54,7 @@ function normalizeSelection(selection: MapSelection): MapSelection {
|
|||||||
const friction = selection.frictionOverride;
|
const friction = selection.frictionOverride;
|
||||||
return {
|
return {
|
||||||
...selection,
|
...selection,
|
||||||
|
...mapSelectionTransform(selection),
|
||||||
frictionOverride:
|
frictionOverride:
|
||||||
friction === undefined || !Number.isFinite(friction)
|
friction === undefined || !Number.isFinite(friction)
|
||||||
? undefined
|
? undefined
|
||||||
@@ -76,15 +75,20 @@ export function PhysicalMapPanel({
|
|||||||
showVisualMap,
|
showVisualMap,
|
||||||
showMapCollision,
|
showMapCollision,
|
||||||
editorDocument,
|
editorDocument,
|
||||||
|
editorDraftDocument,
|
||||||
|
sceneDirty = false,
|
||||||
onEditorPreview,
|
onEditorPreview,
|
||||||
|
onEditorDraftChange,
|
||||||
onEditorApply,
|
onEditorApply,
|
||||||
onEditorExport,
|
onEditorExport,
|
||||||
onEditorConvert,
|
onEditorConvert,
|
||||||
|
selectedEditorObjectId,
|
||||||
onEditorBindInteraction,
|
onEditorBindInteraction,
|
||||||
onEditorSelect,
|
onEditorSelect,
|
||||||
onEditorTransformMode,
|
onEditorSessionStateChange,
|
||||||
onEditorSnapping,
|
onEditorSurfaceHeight,
|
||||||
onMapDisplay,
|
onMapDisplay,
|
||||||
|
onDraft,
|
||||||
onApply,
|
onApply,
|
||||||
}: {
|
}: {
|
||||||
value: MapSelection;
|
value: MapSelection;
|
||||||
@@ -95,20 +99,37 @@ export function PhysicalMapPanel({
|
|||||||
showVisualMap: boolean;
|
showVisualMap: boolean;
|
||||||
showMapCollision: boolean;
|
showMapCollision: boolean;
|
||||||
editorDocument: EditableMapDocument | null;
|
editorDocument: EditableMapDocument | null;
|
||||||
|
editorDraftDocument?: EditableMapDocument;
|
||||||
|
sceneDirty?: boolean;
|
||||||
onEditorPreview: (document: EditableMapDocument | null) => void;
|
onEditorPreview: (document: EditableMapDocument | null) => void;
|
||||||
|
onEditorDraftChange: (
|
||||||
|
descriptorPath: string,
|
||||||
|
document: EditableMapDocument,
|
||||||
|
dirty: boolean,
|
||||||
|
) => void;
|
||||||
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
|
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
|
||||||
onEditorExport: () => void;
|
onEditorExport: () => void;
|
||||||
onEditorConvert: () => Promise<boolean>;
|
onEditorConvert: () => Promise<boolean>;
|
||||||
|
selectedEditorObjectId?: string;
|
||||||
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
|
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
|
||||||
onEditorSelect: (id: string | null) => void;
|
onEditorSelect: (id: string | null) => void;
|
||||||
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
|
onEditorSessionStateChange?: (
|
||||||
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
|
descriptorPath: string,
|
||||||
|
state: MapEditorSessionState | null,
|
||||||
|
) => void;
|
||||||
|
onEditorSurfaceHeight?: (position: readonly [number, number, number]) => number | null;
|
||||||
onMapDisplay: (visual: boolean, collision: boolean) => void;
|
onMapDisplay: (visual: boolean, collision: boolean) => void;
|
||||||
|
onDraft: (value: PlacedMapSelection) => void;
|
||||||
onApply: (value: MapSelection) => void;
|
onApply: (value: MapSelection) => void;
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState<MapSelection>(value);
|
const [draft, setDraft] = useState<MapSelection>(value),
|
||||||
|
[previousValue, setPreviousValue] = useState(value);
|
||||||
|
if (previousValue !== value) {
|
||||||
|
setPreviousValue(value);
|
||||||
|
setDraft(value);
|
||||||
|
}
|
||||||
const normalized = normalizeSelection(draft);
|
const normalized = normalizeSelection(draft);
|
||||||
const changed = JSON.stringify(normalized) !== JSON.stringify(value);
|
const changed = JSON.stringify(normalized) !== JSON.stringify(normalizeSelection(value));
|
||||||
const compileDisabled = loading || (nativeUrdf && normalized.kind !== 'none');
|
const compileDisabled = loading || (nativeUrdf && normalized.kind !== 'none');
|
||||||
const sourceValue =
|
const sourceValue =
|
||||||
draft.kind === 'none'
|
draft.kind === 'none'
|
||||||
@@ -120,9 +141,18 @@ export function PhysicalMapPanel({
|
|||||||
draft.kind === 'project'
|
draft.kind === 'project'
|
||||||
? maps.find((entry) => entry.descriptorPath === draft.descriptorPath)
|
? maps.find((entry) => entry.descriptorPath === draft.descriptorPath)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const replaceBuiltin = (config: PhysicalMapConfig) => {
|
||||||
|
const next: Extract<MapSelection, { kind: 'builtin' }> = { kind: 'builtin', config };
|
||||||
|
setDraft(next);
|
||||||
|
if (value.kind === 'builtin') onDraft(next);
|
||||||
|
};
|
||||||
const updateBuiltin = <K extends keyof PhysicalMapConfig>(key: K, next: PhysicalMapConfig[K]) => {
|
const updateBuiltin = <K extends keyof PhysicalMapConfig>(key: K, next: PhysicalMapConfig[K]) => {
|
||||||
if (draft.kind !== 'builtin') return;
|
if (draft.kind !== 'builtin') return;
|
||||||
setDraft({ kind: 'builtin', config: { ...draft.config, [key]: next } });
|
replaceBuiltin({ ...draft.config, [key]: next });
|
||||||
|
};
|
||||||
|
const replaceProject = (next: Extract<MapSelection, { kind: 'project' }>) => {
|
||||||
|
setDraft(next);
|
||||||
|
if (value.kind === 'project' && value.descriptorPath === next.descriptorPath) onDraft(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const changeSource = (source: string) => {
|
const changeSource = (source: string) => {
|
||||||
@@ -141,20 +171,104 @@ export function PhysicalMapPanel({
|
|||||||
setDraft({
|
setDraft({
|
||||||
kind: 'project',
|
kind: 'project',
|
||||||
descriptorPath,
|
descriptorPath,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
yawDeg: 0,
|
||||||
spawnPointId: map?.spawnPoints[0]?.id,
|
spawnPointId: map?.spawnPoints[0]?.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const editorPanel =
|
||||||
|
draft.kind === 'project' &&
|
||||||
|
draft.descriptorPath === (value.kind === 'project' ? value.descriptorPath : '') ? (
|
||||||
|
<div className="space-y-2 rounded border border-border-subtle p-3">
|
||||||
|
<div className="text-xs font-medium text-text-primary">
|
||||||
|
{selectedEditorObjectId ? '选中物体属性' : '认证资产与场景对象属性'}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] leading-relaxed text-text-tertiary">
|
||||||
|
{selectedEditorObjectId
|
||||||
|
? '尺寸、位姿、表面材质与摩擦力会实时写入 MapEditSession 草稿。'
|
||||||
|
: '此处编辑地图源内容;同源实例共享内容,实例位姿彼此独立。'}
|
||||||
|
</p>
|
||||||
|
<MapEditorPanel
|
||||||
|
document={editorDocument}
|
||||||
|
draftDocument={editorDraftDocument}
|
||||||
|
descriptorPath={draft.descriptorPath}
|
||||||
|
loading={loading}
|
||||||
|
onPreview={onEditorPreview}
|
||||||
|
onDraftChange={onEditorDraftChange}
|
||||||
|
onApply={onEditorApply}
|
||||||
|
onExport={onEditorExport}
|
||||||
|
onConvert={onEditorConvert}
|
||||||
|
canConvert={Boolean(projectMap?.physicsPath)}
|
||||||
|
selectedObjectId={selectedEditorObjectId}
|
||||||
|
onBindInteraction={onEditorBindInteraction}
|
||||||
|
onSelectPreview={onEditorSelect}
|
||||||
|
onSessionStateChange={onEditorSessionStateChange}
|
||||||
|
onSurfaceHeight={onEditorSurfaceHeight}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (value.kind === 'none' && draft.kind === 'none')
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 p-3">
|
||||||
|
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-3">
|
||||||
|
<div className="text-xs font-semibold text-text-primary">未选择地图实例</div>
|
||||||
|
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||||
|
请从左侧“资产”页放置或选择地图。参数修改只会作用于当前选中的实例。
|
||||||
|
</p>
|
||||||
|
<label className="mt-3 block text-xs text-text-secondary">
|
||||||
|
<span className="mb-1 block">快速放置地图</span>
|
||||||
|
<Select
|
||||||
|
aria-label="地图来源"
|
||||||
|
className="w-full"
|
||||||
|
value="none"
|
||||||
|
disabled={loading}
|
||||||
|
onChange={(event) => changeSource(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="none">请选择地图源</option>
|
||||||
|
<option value="builtin">内置程序地图</option>
|
||||||
|
{maps.map((map) => (
|
||||||
|
<option key={map.descriptorPath} value={`project:${map.descriptorPath}`}>
|
||||||
|
{map.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-secondary">
|
||||||
|
<label className="flex items-center justify-between gap-3">
|
||||||
|
<span>显示视觉地图</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showVisualMap}
|
||||||
|
onChange={(event) => onMapDisplay(event.target.checked, showMapCollision)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between gap-3">
|
||||||
|
<span>显示地图碰撞层</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showMapCollision}
|
||||||
|
onChange={(event) => onMapDisplay(showVisualMap, event.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-3">
|
<div className="space-y-4 p-3">
|
||||||
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-3">
|
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-3">
|
||||||
<div className="text-xs font-semibold text-text-primary">地图参数</div>
|
<div className="text-xs font-semibold text-text-primary">地图与对象属性</div>
|
||||||
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||||
从左侧“资产”选择地形或添加场景对象,在这里调整物理参数与编辑属性。
|
参数地形与认证资产统一在此管理。视口选中对象后可调整变换和专属参数。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{editorPanel}
|
||||||
|
|
||||||
<label className="block text-xs text-text-secondary">
|
<label className="block text-xs text-text-secondary">
|
||||||
<span className="mb-1 block">地图来源</span>
|
<span className="mb-1 block">地图来源</span>
|
||||||
<Select
|
<Select
|
||||||
@@ -176,6 +290,10 @@ export function PhysicalMapPanel({
|
|||||||
|
|
||||||
{draft.kind === 'builtin' && (
|
{draft.kind === 'builtin' && (
|
||||||
<>
|
<>
|
||||||
|
<div className="text-xs font-medium text-text-primary">参数地形属性</div>
|
||||||
|
<p className="rounded border border-border-subtle bg-panel-muted/50 p-2 text-[10px] leading-relaxed text-text-tertiary">
|
||||||
|
移动、旋转与网格吸附已统一到视口浮动工具条;尺寸继续通过下方参数精确调整。
|
||||||
|
</p>
|
||||||
<label className="block text-xs text-text-secondary">
|
<label className="block text-xs text-text-secondary">
|
||||||
<span className="mb-1 block">地图预设</span>
|
<span className="mb-1 block">地图预设</span>
|
||||||
<Select
|
<Select
|
||||||
@@ -205,10 +323,7 @@ export function PhysicalMapPanel({
|
|||||||
draft.config.yawDeg === 0)
|
draft.config.yawDeg === 0)
|
||||||
}
|
}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setDraft({
|
replaceBuiltin({ ...draft.config, positionX: 0, positionY: 0, yawDeg: 0 })
|
||||||
kind: 'builtin',
|
|
||||||
config: { ...draft.config, positionX: 0, positionY: 0, yawDeg: 0 },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
复位
|
复位
|
||||||
@@ -349,6 +464,54 @@ export function PhysicalMapPanel({
|
|||||||
<div className="break-all text-[11px] text-text-tertiary">
|
<div className="break-all text-[11px] text-text-tertiary">
|
||||||
{projectMap.descriptorPath}
|
{projectMap.descriptorPath}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-lg border border-border-subtle bg-panel-bg/40 p-2.5">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-text-primary">实例变换</span>
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
loading ||
|
||||||
|
((draft.positionX ?? 0) === 0 &&
|
||||||
|
(draft.positionY ?? 0) === 0 &&
|
||||||
|
(draft.yawDeg ?? 0) === 0)
|
||||||
|
}
|
||||||
|
onClick={() => replaceProject({ ...draft, positionX: 0, positionY: 0, yawDeg: 0 })}
|
||||||
|
>
|
||||||
|
复位
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<NumberField
|
||||||
|
label="位置 X(m)"
|
||||||
|
value={draft.positionX ?? 0}
|
||||||
|
min={-100}
|
||||||
|
max={100}
|
||||||
|
step={0.1}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={(positionX) => replaceProject({ ...draft, positionX })}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="位置 Y(m)"
|
||||||
|
value={draft.positionY ?? 0}
|
||||||
|
min={-100}
|
||||||
|
max={100}
|
||||||
|
step={0.1}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={(positionY) => replaceProject({ ...draft, positionY })}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="旋转 Z(°)"
|
||||||
|
value={draft.yawDeg ?? 0}
|
||||||
|
min={-180}
|
||||||
|
max={180}
|
||||||
|
step={1}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={(yawDeg) => replaceProject({ ...draft, yawDeg })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
|
||||||
|
位姿属于当前场景实例;编辑下方源内容会同步影响引用同一地图的实例。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{projectMap.spawnPoints.length > 0 && (
|
{projectMap.spawnPoints.length > 0 && (
|
||||||
<label className="block text-xs text-text-secondary">
|
<label className="block text-xs text-text-secondary">
|
||||||
<span className="mb-1 block">出生点</span>
|
<span className="mb-1 block">出生点</span>
|
||||||
@@ -358,7 +521,7 @@ export function PhysicalMapPanel({
|
|||||||
value={draft.spawnPointId ?? ''}
|
value={draft.spawnPointId ?? ''}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDraft({ ...draft, spawnPointId: event.target.value || undefined })
|
replaceProject({ ...draft, spawnPointId: event.target.value || undefined })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="">不改变机器人位置</option>
|
<option value="">不改变机器人位置</option>
|
||||||
@@ -379,7 +542,7 @@ export function PhysicalMapPanel({
|
|||||||
value={draft.robotRootBody ?? ''}
|
value={draft.robotRootBody ?? ''}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDraft({ ...draft, robotRootBody: event.target.value || undefined })
|
replaceProject({ ...draft, robotRootBody: event.target.value || undefined })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="">自动识别</option>
|
<option value="">自动识别</option>
|
||||||
@@ -403,7 +566,7 @@ export function PhysicalMapPanel({
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary"
|
className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary"
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDraft({
|
replaceProject({
|
||||||
...draft,
|
...draft,
|
||||||
frictionOverride: event.target.value ? Number(event.target.value) : undefined,
|
frictionOverride: event.target.value ? Number(event.target.value) : undefined,
|
||||||
})
|
})
|
||||||
@@ -413,26 +576,6 @@ export function PhysicalMapPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{draft.kind === 'project' &&
|
|
||||||
draft.descriptorPath === (value.kind === 'project' ? value.descriptorPath : '') && (
|
|
||||||
<div className="space-y-2 rounded border border-border-subtle p-3">
|
|
||||||
<div className="text-xs font-medium text-text-primary">V3 地图编辑器</div>
|
|
||||||
<MapEditorPanel
|
|
||||||
document={editorDocument}
|
|
||||||
loading={loading}
|
|
||||||
onPreview={onEditorPreview}
|
|
||||||
onApply={onEditorApply}
|
|
||||||
onExport={onEditorExport}
|
|
||||||
onConvert={onEditorConvert}
|
|
||||||
canConvert={Boolean(projectMap?.physicsPath)}
|
|
||||||
onBindInteraction={onEditorBindInteraction}
|
|
||||||
onSelectPreview={onEditorSelect}
|
|
||||||
onTransformMode={onEditorTransformMode}
|
|
||||||
onSnapping={onEditorSnapping}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-secondary">
|
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-secondary">
|
||||||
<label className="flex items-center justify-between gap-3">
|
<label className="flex items-center justify-between gap-3">
|
||||||
<span>显示视觉地图</span>
|
<span>显示视觉地图</span>
|
||||||
@@ -461,10 +604,19 @@ export function PhysicalMapPanel({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{sceneDirty && (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className="rounded border border-accent/30 bg-accent-soft p-2 text-xs text-accent"
|
||||||
|
>
|
||||||
|
场景草稿正在轻量预览;工程地图、认证资产与参数地形会在确认后一次性编译。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={compileDisabled || !changed}
|
disabled={compileDisabled || (!changed && !sceneDirty)}
|
||||||
onClick={() => onApply(normalized)}
|
onClick={() => onApply(normalized)}
|
||||||
>
|
>
|
||||||
应用并重新编译
|
应用并重新编译
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { ProjectFile, ProjectManifest } from '../../project/types';
|
||||||
|
import { discoverMapEntries } from '../MapLoader';
|
||||||
|
import { materializeEditableMapDrafts } from './EditableMapDraftCommit';
|
||||||
|
import { decodeEditableMapDocument } from './editorSchema';
|
||||||
|
import { createEditableObject, type EditableMapDocument } from './types';
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
function file(path: string, text: string): ProjectFile {
|
||||||
|
const data = encoder.encode(text);
|
||||||
|
return { path, data, size: data.byteLength, source: 'zip', mimeType: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifest(): ProjectManifest {
|
||||||
|
const document: EditableMapDocument = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
mapId: 'scene',
|
||||||
|
revision: 2,
|
||||||
|
objects: [],
|
||||||
|
spawnPoints: [],
|
||||||
|
};
|
||||||
|
const files = [
|
||||||
|
file(
|
||||||
|
'maps/scene/map.json',
|
||||||
|
JSON.stringify({
|
||||||
|
schemaVersion: 2,
|
||||||
|
id: 'scene',
|
||||||
|
name: '场景',
|
||||||
|
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||||
|
physics: { source: 'physics/world.xml' },
|
||||||
|
authoring: { source: 'authoring/map.scene.json' },
|
||||||
|
spawnPoints: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
file('maps/scene/physics/world.xml', '<mujoco><worldbody/></mujoco>'),
|
||||||
|
file('maps/scene/authoring/map.scene.json', JSON.stringify(document)),
|
||||||
|
file('robot.xml', '<mujoco><worldbody/></mujoco>'),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
id: 'project',
|
||||||
|
name: 'project',
|
||||||
|
files,
|
||||||
|
entries: [{ path: 'robot.xml', format: 'mjcf', label: 'robot.xml' }],
|
||||||
|
maps: discoverMapEntries(files),
|
||||||
|
totalBytes: files.reduce((total, item) => total + item.size, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('认证资产场景草稿提交', () => {
|
||||||
|
it('在不修改原清单的前提下同步生成 authoring、physics 与出生点描述', () => {
|
||||||
|
const original = manifest();
|
||||||
|
const draft: EditableMapDocument = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
mapId: 'scene',
|
||||||
|
revision: 2,
|
||||||
|
objects: [createEditableObject('box', 'certified_box')],
|
||||||
|
spawnPoints: [{ id: 'start', name: '起点', position: [1, 2, 0], yawDeg: 90 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const candidate = materializeEditableMapDrafts(
|
||||||
|
original,
|
||||||
|
new Map([['maps/scene/map.json', draft]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(candidate).not.toBe(original);
|
||||||
|
expect(decoder.decode(original.files[1].data)).not.toContain('certified_box');
|
||||||
|
expect(decoder.decode(candidate.files[1].data)).toContain('edit_certified_box');
|
||||||
|
expect(decodeEditableMapDocument(candidate.files[2].data)).toMatchObject({
|
||||||
|
revision: 3,
|
||||||
|
objects: [{ id: 'certified_box' }],
|
||||||
|
});
|
||||||
|
expect(JSON.parse(decoder.decode(candidate.files[0].data))).toMatchObject({
|
||||||
|
spawnPoints: [{ id: 'start', name: '起点' }],
|
||||||
|
});
|
||||||
|
expect(candidate.maps[0].spawnPoints).toEqual([{ id: 'start', name: '起点' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拒绝把其他地图的草稿写入当前描述文件', () => {
|
||||||
|
const original = manifest();
|
||||||
|
const draft: EditableMapDocument = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
mapId: 'other',
|
||||||
|
revision: 0,
|
||||||
|
objects: [],
|
||||||
|
spawnPoints: [],
|
||||||
|
};
|
||||||
|
expect(() =>
|
||||||
|
materializeEditableMapDrafts(original, new Map([['maps/scene/map.json', draft]])),
|
||||||
|
).toThrow('mapId');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { ProjectManifest } from '../../project/types';
|
||||||
|
import { discoverMapEntries, resolveProjectMap } from '../MapLoader';
|
||||||
|
import { decodeMapDefinition } from '../mapSchema';
|
||||||
|
import { compileEditableMapDocument } from './MapDocumentCompiler';
|
||||||
|
import { decodeEditableMapDocument, encodeEditableMapDocument } from './editorSchema';
|
||||||
|
import type { EditableMapDocument } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将场景编辑草稿写入一个新的工程清单,供后续 MuJoCo 事务式编译使用。
|
||||||
|
* 原清单不会被修改;只有仿真编译成功后,调用方才应提交返回值。
|
||||||
|
*/
|
||||||
|
export function materializeEditableMapDrafts(
|
||||||
|
manifest: ProjectManifest,
|
||||||
|
drafts: ReadonlyMap<string, EditableMapDocument>,
|
||||||
|
): ProjectManifest {
|
||||||
|
if (!drafts.size) return manifest;
|
||||||
|
|
||||||
|
const replacements = new Map<string, Uint8Array>();
|
||||||
|
for (const [descriptorPath, requestedDocument] of drafts) {
|
||||||
|
const resolved = resolveProjectMap(manifest, descriptorPath);
|
||||||
|
if (!resolved.authoringPath || !resolved.physicsPath)
|
||||||
|
throw new Error(
|
||||||
|
`可编辑地图 ${descriptorPath} 必须同时声明 authoring.source 和 physics.source`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const authoringFile = manifest.files.find((file) => file.path === resolved.authoringPath);
|
||||||
|
if (!authoringFile) throw new Error(`地图创作层不存在:${resolved.authoringPath}`);
|
||||||
|
const savedDocument = decodeEditableMapDocument(authoringFile.data);
|
||||||
|
if (requestedDocument.mapId !== savedDocument.mapId)
|
||||||
|
throw new Error(`地图草稿 mapId 必须为 ${savedDocument.mapId}`);
|
||||||
|
|
||||||
|
const document: EditableMapDocument = {
|
||||||
|
...structuredClone(requestedDocument),
|
||||||
|
revision: Math.max(savedDocument.revision + 1, requestedDocument.revision),
|
||||||
|
};
|
||||||
|
const definition = decodeMapDefinition(
|
||||||
|
manifest.files.find((file) => file.path === resolved.descriptorPath)!.data,
|
||||||
|
);
|
||||||
|
definition.spawnPoints = document.spawnPoints;
|
||||||
|
|
||||||
|
replacements.set(resolved.authoringPath, encodeEditableMapDocument(document));
|
||||||
|
replacements.set(resolved.physicsPath, compileEditableMapDocument(document));
|
||||||
|
replacements.set(
|
||||||
|
resolved.descriptorPath,
|
||||||
|
new TextEncoder().encode(`${JSON.stringify(definition, null, 2)}\n`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = manifest.files.map((file) => {
|
||||||
|
const data = replacements.get(file.path);
|
||||||
|
return data ? { ...file, data, size: data.byteLength } : file;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...manifest,
|
||||||
|
files,
|
||||||
|
maps: discoverMapEntries(files),
|
||||||
|
totalBytes: files.reduce((total, file) => total + file.size, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,9 +23,17 @@ export class MapEditSession {
|
|||||||
private redoStack: HistoryEntry[] = [];
|
private redoStack: HistoryEntry[] = [];
|
||||||
selectedId?: string;
|
selectedId?: string;
|
||||||
|
|
||||||
constructor(document: EditableMapDocument) {
|
constructor(
|
||||||
this.current = clone(parseEditableMapDocument(document));
|
document: EditableMapDocument,
|
||||||
this.saved = clone(this.current);
|
draft: EditableMapDocument = document,
|
||||||
|
private readonly externalSurfaceHeight?: (
|
||||||
|
position: readonly [number, number, number],
|
||||||
|
) => number | null,
|
||||||
|
) {
|
||||||
|
this.saved = clone(parseEditableMapDocument(document));
|
||||||
|
this.current = clone(parseEditableMapDocument(draft));
|
||||||
|
if (this.current.mapId !== this.saved.mapId)
|
||||||
|
throw new Error(`地图草稿 mapId 必须为 ${this.saved.mapId}`);
|
||||||
}
|
}
|
||||||
get document(): EditableMapDocument {
|
get document(): EditableMapDocument {
|
||||||
return clone(this.current);
|
return clone(this.current);
|
||||||
@@ -33,12 +41,22 @@ export class MapEditSession {
|
|||||||
get dirty(): boolean {
|
get dirty(): boolean {
|
||||||
return JSON.stringify(this.current) !== JSON.stringify(this.saved);
|
return JSON.stringify(this.current) !== JSON.stringify(this.saved);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 会话内可撤销操作数。恢复自持久化草稿时历史栈为空,但仍至少记为一项改动。
|
||||||
|
*/
|
||||||
|
get changeCount(): number {
|
||||||
|
return this.dirty ? Math.max(1, this.undoStack.length) : 0;
|
||||||
|
}
|
||||||
get canUndo(): boolean {
|
get canUndo(): boolean {
|
||||||
return this.undoStack.length > 0;
|
return this.undoStack.length > 0;
|
||||||
}
|
}
|
||||||
get canRedo(): boolean {
|
get canRedo(): boolean {
|
||||||
return this.redoStack.length > 0;
|
return this.redoStack.length > 0;
|
||||||
}
|
}
|
||||||
|
private supportTop(object: EditableMapObject, fallback = 0): number {
|
||||||
|
if (object.placementMode !== 'gravity') return fallback;
|
||||||
|
return this.externalSurfaceHeight?.(object.pose.position) ?? fallback;
|
||||||
|
}
|
||||||
private commit(mutator: (draft: EditableMapDocument) => void): void {
|
private commit(mutator: (draft: EditableMapDocument) => void): void {
|
||||||
const before = clone(this.current),
|
const before = clone(this.current),
|
||||||
after = clone(this.current);
|
after = clone(this.current);
|
||||||
@@ -51,7 +69,7 @@ export class MapEditSession {
|
|||||||
}
|
}
|
||||||
add(type: EditableMapObjectType): EditableMapObject {
|
add(type: EditableMapObjectType): EditableMapObject {
|
||||||
const object = createEditableObject(type);
|
const object = createEditableObject(type);
|
||||||
applyObjectPlacement(object, this.current.objects);
|
applyObjectPlacement(object, this.current.objects, this.supportTop(object));
|
||||||
this.commit((draft) => draft.objects.push(object));
|
this.commit((draft) => draft.objects.push(object));
|
||||||
this.selectedId = object.id;
|
this.selectedId = object.id;
|
||||||
return structuredClone(object);
|
return structuredClone(object);
|
||||||
@@ -60,9 +78,10 @@ export class MapEditSession {
|
|||||||
type: EditableMapObjectType,
|
type: EditableMapObjectType,
|
||||||
position?: [number, number, number],
|
position?: [number, number, number],
|
||||||
placementMode: MapObjectPlacementMode = 'auto_ground',
|
placementMode: MapObjectPlacementMode = 'auto_ground',
|
||||||
|
externalSupportTop = 0,
|
||||||
): EditableMapObject {
|
): EditableMapObject {
|
||||||
const object = createPlacedMapAsset(type, this.current.objects.length, position, placementMode);
|
const object = createPlacedMapAsset(type, this.current.objects.length, position, placementMode);
|
||||||
applyObjectPlacement(object, this.current.objects);
|
applyObjectPlacement(object, this.current.objects, this.supportTop(object, externalSupportTop));
|
||||||
this.commit((draft) => draft.objects.push(object));
|
this.commit((draft) => draft.objects.push(object));
|
||||||
this.selectedId = object.id;
|
this.selectedId = object.id;
|
||||||
return structuredClone(object);
|
return structuredClone(object);
|
||||||
@@ -81,7 +100,7 @@ export class MapEditSession {
|
|||||||
copy.pose.position[1] + 0.2,
|
copy.pose.position[1] + 0.2,
|
||||||
copy.pose.position[2],
|
copy.pose.position[2],
|
||||||
];
|
];
|
||||||
applyObjectPlacement(copy, this.current.objects);
|
applyObjectPlacement(copy, this.current.objects, this.supportTop(copy));
|
||||||
this.commit((draft) => draft.objects.push(copy));
|
this.commit((draft) => draft.objects.push(copy));
|
||||||
this.selectedId = copy.id;
|
this.selectedId = copy.id;
|
||||||
return structuredClone(copy);
|
return structuredClone(copy);
|
||||||
@@ -93,7 +112,7 @@ export class MapEditSession {
|
|||||||
const next = structuredClone(patch);
|
const next = structuredClone(patch);
|
||||||
if (object.placementMode === 'locked' && next.placementMode === undefined) delete next.pose;
|
if (object.placementMode === 'locked' && next.placementMode === undefined) delete next.pose;
|
||||||
Object.assign(object, next);
|
Object.assign(object, next);
|
||||||
applyObjectPlacement(object, draft.objects);
|
applyObjectPlacement(object, draft.objects, this.supportTop(object));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
remove(id: string): void {
|
remove(id: string): void {
|
||||||
|
|||||||
@@ -1,13 +1,56 @@
|
|||||||
import {
|
import {
|
||||||
createEditableObject,
|
createEditableObject,
|
||||||
editableObjectGroundHeight,
|
editableObjectGroundHeight,
|
||||||
|
isMapObjectPlacementMode,
|
||||||
type EditableMapObject,
|
type EditableMapObject,
|
||||||
type EditableMapObjectType,
|
type EditableMapObjectType,
|
||||||
type MapObjectPlacementMode,
|
type MapObjectPlacementMode,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { isSystemTerrainPreset, type SystemTerrainPreset } from '../types';
|
||||||
|
|
||||||
export const MAP_ASSET_DRAG_MIME = 'application/x-mujoco-map-asset';
|
export const MAP_LIBRARY_DRAG_MIME = 'application/x-mujoco-map-library-item';
|
||||||
export const MAP_ASSET_PLACEMENT_MIME = 'application/x-mujoco-map-placement';
|
|
||||||
|
export type MapLibraryDragPayload =
|
||||||
|
| {
|
||||||
|
kind: 'certified';
|
||||||
|
type: EditableMapObjectType;
|
||||||
|
placementMode: MapObjectPlacementMode;
|
||||||
|
}
|
||||||
|
| { kind: 'terrain'; preset: SystemTerrainPreset }
|
||||||
|
| { kind: 'project'; descriptorPath: string };
|
||||||
|
|
||||||
|
export function encodeMapLibraryDragPayload(payload: MapLibraryDragPayload): string {
|
||||||
|
return JSON.stringify(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeMapLibraryDragPayload(value: string): MapLibraryDragPayload | null {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(value) as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
payload.kind === 'certified' &&
|
||||||
|
typeof payload.type === 'string' &&
|
||||||
|
isEditableMapObjectType(payload.type) &&
|
||||||
|
typeof payload.placementMode === 'string' &&
|
||||||
|
isMapObjectPlacementMode(payload.placementMode)
|
||||||
|
)
|
||||||
|
return { kind: 'certified', type: payload.type, placementMode: payload.placementMode };
|
||||||
|
if (
|
||||||
|
payload.kind === 'terrain' &&
|
||||||
|
typeof payload.preset === 'string' &&
|
||||||
|
isSystemTerrainPreset(payload.preset)
|
||||||
|
)
|
||||||
|
return { kind: 'terrain', preset: payload.preset };
|
||||||
|
if (
|
||||||
|
payload.kind === 'project' &&
|
||||||
|
typeof payload.descriptorPath === 'string' &&
|
||||||
|
payload.descriptorPath.trim()
|
||||||
|
)
|
||||||
|
return { kind: 'project', descriptorPath: payload.descriptorPath };
|
||||||
|
} catch {
|
||||||
|
// 非本平台的拖放数据交给文件导入分支处理。
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CertifiedMapAsset {
|
export interface CertifiedMapAsset {
|
||||||
type: EditableMapObjectType;
|
type: EditableMapObjectType;
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { ProjectFile, ProjectManifest } from '../../project/types';
|
||||||
|
import { discoverMapEntries } from '../MapLoader';
|
||||||
|
import type { PlacedMapAsset } from '../types';
|
||||||
|
import { encodeEditableMapDocument } from './editorSchema';
|
||||||
|
import { compiledEditableMapBodyName, findCompiledEditableMapPick } from './compiledMapPick';
|
||||||
|
import { createEditableObject, type EditableMapDocument } from './types';
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
function file(path: string, data: Uint8Array | string): ProjectFile {
|
||||||
|
const bytes = typeof data === 'string' ? encoder.encode(data) : data;
|
||||||
|
return { path, data: bytes, size: bytes.byteLength, source: 'zip', mimeType: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(): { manifest: ProjectManifest; assets: PlacedMapAsset[] } {
|
||||||
|
const document: EditableMapDocument = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
mapId: 'scene',
|
||||||
|
revision: 1,
|
||||||
|
objects: [createEditableObject('box', 'box_1')],
|
||||||
|
spawnPoints: [],
|
||||||
|
};
|
||||||
|
const files = [
|
||||||
|
file(
|
||||||
|
'maps/scene/map.json',
|
||||||
|
JSON.stringify({
|
||||||
|
schemaVersion: 2,
|
||||||
|
id: 'scene',
|
||||||
|
name: '场景',
|
||||||
|
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||||
|
physics: { source: 'physics/world.xml' },
|
||||||
|
authoring: { source: 'authoring/map.scene.json' },
|
||||||
|
spawnPoints: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
file('maps/scene/physics/world.xml', '<mujoco><worldbody/></mujoco>'),
|
||||||
|
file('maps/scene/authoring/map.scene.json', encodeEditableMapDocument(document)),
|
||||||
|
file('robot.xml', '<mujoco><worldbody/></mujoco>'),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
manifest: {
|
||||||
|
id: 'project',
|
||||||
|
name: 'project',
|
||||||
|
files,
|
||||||
|
entries: [{ path: 'robot.xml', format: 'mjcf', label: 'robot.xml' }],
|
||||||
|
maps: discoverMapEntries(files),
|
||||||
|
totalBytes: files.reduce((total, item) => total + item.size, 0),
|
||||||
|
},
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
id: 'map_instance-1',
|
||||||
|
name: '场景',
|
||||||
|
selection: { kind: 'project', descriptorPath: 'maps/scene/map.json' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('已编译认证资产拾取', () => {
|
||||||
|
it('按地图实例命名空间反查创作对象', () => {
|
||||||
|
const { manifest, assets } = fixture();
|
||||||
|
const bodyName = compiledEditableMapBodyName('scene', 'map_instance-1', 'box_1');
|
||||||
|
|
||||||
|
expect(bodyName).toBe('__platform_map_scene_map_instance-1_edit_body_box_1');
|
||||||
|
expect(findCompiledEditableMapPick(bodyName, manifest, assets)).toEqual({
|
||||||
|
mapAssetId: 'map_instance-1',
|
||||||
|
descriptorPath: 'maps/scene/map.json',
|
||||||
|
objectId: 'box_1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不接管机器人 Body、其他实例或已禁用对象', () => {
|
||||||
|
const { manifest, assets } = fixture();
|
||||||
|
expect(findCompiledEditableMapPick('robot_base', manifest, assets)).toBeNull();
|
||||||
|
expect(
|
||||||
|
findCompiledEditableMapPick(
|
||||||
|
compiledEditableMapBodyName('scene', 'other_instance', 'box_1'),
|
||||||
|
manifest,
|
||||||
|
assets,
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
const authoring = manifest.files.find(
|
||||||
|
(item) => item.path === 'maps/scene/authoring/map.scene.json',
|
||||||
|
)!;
|
||||||
|
const disabled = fixture().manifest;
|
||||||
|
const disabledAuthoring = disabled.files.find((item) => item.path === authoring.path)!;
|
||||||
|
const document: EditableMapDocument = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
mapId: 'scene',
|
||||||
|
revision: 2,
|
||||||
|
objects: [{ ...createEditableObject('box', 'box_1'), enabled: false }],
|
||||||
|
spawnPoints: [],
|
||||||
|
};
|
||||||
|
const data = encodeEditableMapDocument(document);
|
||||||
|
disabledAuthoring.data = data;
|
||||||
|
disabledAuthoring.size = data.byteLength;
|
||||||
|
expect(
|
||||||
|
findCompiledEditableMapPick(
|
||||||
|
compiledEditableMapBodyName('scene', 'map_instance-1', 'box_1'),
|
||||||
|
disabled,
|
||||||
|
assets,
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { ProjectManifest } from '../../project/types';
|
||||||
|
import { resolveProjectMap } from '../MapLoader';
|
||||||
|
import type { PlacedMapAsset } from '../types';
|
||||||
|
import { decodeEditableMapDocument } from './editorSchema';
|
||||||
|
|
||||||
|
export interface CompiledEditableMapPick {
|
||||||
|
mapAssetId: string;
|
||||||
|
descriptorPath: string;
|
||||||
|
objectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function namespaceToken(value: string): string {
|
||||||
|
return value.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与 MapComposer.namespaceMap 保持一致的已编译可编辑对象 Body 名。 */
|
||||||
|
export function compiledEditableMapBodyName(
|
||||||
|
mapId: string,
|
||||||
|
mapAssetId: string,
|
||||||
|
objectId: string,
|
||||||
|
): string {
|
||||||
|
const namespace = namespaceToken(`${mapId}_${mapAssetId}`);
|
||||||
|
return `__platform_map_${namespace}_edit_body_${objectId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 MuJoCo 中命中的静态 Body 反查为认证资产对象。这样当前选中的是参数地形或其他
|
||||||
|
* 地图实例时,点击已编译对象仍能激活正确的创作地图并重新挂载操纵器。
|
||||||
|
*/
|
||||||
|
export function findCompiledEditableMapPick(
|
||||||
|
bodyName: string,
|
||||||
|
manifest: ProjectManifest,
|
||||||
|
assets: readonly PlacedMapAsset[],
|
||||||
|
): CompiledEditableMapPick | null {
|
||||||
|
for (const asset of assets) {
|
||||||
|
if (asset.selection.kind !== 'project') continue;
|
||||||
|
try {
|
||||||
|
const resolved = resolveProjectMap(manifest, asset.selection.descriptorPath);
|
||||||
|
if (!resolved.authoringPath) continue;
|
||||||
|
const prefix = compiledEditableMapBodyName(resolved.definition.id, asset.id, '');
|
||||||
|
if (!bodyName.startsWith(prefix)) continue;
|
||||||
|
const objectId = bodyName.slice(prefix.length);
|
||||||
|
if (!objectId) continue;
|
||||||
|
const authoring = manifest.files.find((file) => file.path === resolved.authoringPath);
|
||||||
|
if (!authoring) continue;
|
||||||
|
const document = decodeEditableMapDocument(authoring.data);
|
||||||
|
if (!document.objects.some((object) => object.enabled && object.id === objectId)) continue;
|
||||||
|
return {
|
||||||
|
mapAssetId: asset.id,
|
||||||
|
descriptorPath: asset.selection.descriptorPath,
|
||||||
|
objectId,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// 单个损坏或只读地图不应阻断普通视口拾取。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -73,7 +73,9 @@ describe('地图 V3 编辑核心', () => {
|
|||||||
const session = new MapEditSession(document());
|
const session = new MapEditSession(document());
|
||||||
const object = session.add('box');
|
const object = session.add('box');
|
||||||
expect(session.dirty).toBe(true);
|
expect(session.dirty).toBe(true);
|
||||||
|
expect(session.changeCount).toBe(1);
|
||||||
session.update(object.id, { name: '墙' });
|
session.update(object.id, { name: '墙' });
|
||||||
|
expect(session.changeCount).toBe(2);
|
||||||
expect(session.document.objects[0].name).toBe('墙');
|
expect(session.document.objects[0].name).toBe('墙');
|
||||||
const copy = session.duplicate(object.id);
|
const copy = session.duplicate(object.id);
|
||||||
expect(copy).toMatchObject({ name: '墙 副本', pose: { position: [0.2, 0.2, 0.5] } });
|
expect(copy).toMatchObject({ name: '墙 副本', pose: { position: [0.2, 0.2, 0.5] } });
|
||||||
@@ -95,6 +97,20 @@ describe('地图 V3 编辑核心', () => {
|
|||||||
expect(session.document.spawnPoints).toHaveLength(1);
|
expect(session.document.spawnPoints).toHaveLength(1);
|
||||||
session.discard();
|
session.discard();
|
||||||
expect(session.dirty).toBe(false);
|
expect(session.dirty).toBe(false);
|
||||||
|
expect(session.changeCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('重新挂载时恢复未提交草稿并保持 dirty', () => {
|
||||||
|
const saved = document();
|
||||||
|
const draft = document();
|
||||||
|
draft.objects.push(createEditableObject('box', 'pending_box'));
|
||||||
|
const session = new MapEditSession(saved, draft);
|
||||||
|
expect(session.dirty).toBe(true);
|
||||||
|
expect(session.changeCount).toBe(1);
|
||||||
|
expect(session.document.objects[0].id).toBe('pending_box');
|
||||||
|
session.discard();
|
||||||
|
expect(session.dirty).toBe(false);
|
||||||
|
expect(session.document.objects).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('将认证资产按画布落点加入草稿并自动对齐地面', () => {
|
it('将认证资产按画布落点加入草稿并自动对齐地面', () => {
|
||||||
@@ -107,6 +123,20 @@ describe('地图 V3 编辑核心', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('重力落位可使用程序地图提供的外部承载高度', () => {
|
||||||
|
const session = new MapEditSession(document());
|
||||||
|
const settled = session.addAsset('box', [0, 0, 0], 'gravity', -0.8);
|
||||||
|
expect(settled.pose.position[2]).toBeCloseTo(-0.3);
|
||||||
|
|
||||||
|
const terrainSession = new MapEditSession(document(), document(), ([x]) => (x > 1 ? 2 : 1));
|
||||||
|
const terrainObject = terrainSession.addAsset('box', [0, 0, 0], 'gravity');
|
||||||
|
expect(terrainObject.pose.position[2]).toBeCloseTo(1.5);
|
||||||
|
terrainSession.update(terrainObject.id, {
|
||||||
|
pose: { ...terrainObject.pose, position: [2, 0, terrainObject.pose.position[2]] },
|
||||||
|
});
|
||||||
|
expect(terrainSession.document.objects[0].pose.position[2]).toBeCloseTo(2.5);
|
||||||
|
});
|
||||||
|
|
||||||
it('支持重力落位到最高承载面并锁定位姿', () => {
|
it('支持重力落位到最高承载面并锁定位姿', () => {
|
||||||
const session = new MapEditSession(document());
|
const session = new MapEditSession(document());
|
||||||
const support = session.addAsset('box', [0, 0, 0]);
|
const support = session.addAsset('box', [0, 0, 0]);
|
||||||
|
|||||||
@@ -54,28 +54,31 @@ function objectTop(object: EditableMapObject): number {
|
|||||||
export function gravitySettledPosition(
|
export function gravitySettledPosition(
|
||||||
object: EditableMapObject,
|
object: EditableMapObject,
|
||||||
objects: EditableMapObject[],
|
objects: EditableMapObject[],
|
||||||
|
externalSupportTop = 0,
|
||||||
): [number, number, number] {
|
): [number, number, number] {
|
||||||
const target = footprint(object);
|
const target = footprint(object);
|
||||||
let supportTop = 0;
|
let supportTop = externalSupportTop;
|
||||||
for (const candidate of objects) {
|
for (const candidate of objects) {
|
||||||
if (candidate.id === object.id || !candidate.enabled || !overlaps(target, footprint(candidate)))
|
if (candidate.id === object.id || !candidate.enabled || !overlaps(target, footprint(candidate)))
|
||||||
continue;
|
continue;
|
||||||
supportTop = Math.max(supportTop, objectTop(candidate));
|
supportTop = Math.max(supportTop, objectTop(candidate));
|
||||||
}
|
}
|
||||||
|
const settledZ = supportTop + editableObjectGroundHeight(object);
|
||||||
return [
|
return [
|
||||||
object.pose.position[0],
|
object.pose.position[0],
|
||||||
object.pose.position[1],
|
object.pose.position[1],
|
||||||
supportTop + editableObjectGroundHeight(object),
|
Math.round(settledZ * 1_000_000_000) / 1_000_000_000,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyObjectPlacement(
|
export function applyObjectPlacement(
|
||||||
object: EditableMapObject,
|
object: EditableMapObject,
|
||||||
objects: EditableMapObject[],
|
objects: EditableMapObject[],
|
||||||
|
externalSupportTop = 0,
|
||||||
): void {
|
): void {
|
||||||
if (object.placementMode === 'locked') return;
|
if (object.placementMode === 'locked') return;
|
||||||
object.pose.position =
|
object.pose.position =
|
||||||
object.placementMode === 'gravity'
|
object.placementMode === 'gravity'
|
||||||
? gravitySettledPosition(object, objects)
|
? gravitySettledPosition(object, objects, externalSupportTop)
|
||||||
: [object.pose.position[0], object.pose.position[1], editableObjectGroundHeight(object)];
|
: [object.pose.position[0], object.pose.position[1], editableObjectGroundHeight(object)];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export type MapObjectPlacementMode = 'auto_ground' | 'gravity' | 'locked';
|
|||||||
export const MAP_OBJECT_PLACEMENT_LABELS: Record<MapObjectPlacementMode, string> = {
|
export const MAP_OBJECT_PLACEMENT_LABELS: Record<MapObjectPlacementMode, string> = {
|
||||||
auto_ground: '自动贴地',
|
auto_ground: '自动贴地',
|
||||||
gravity: '自动落位(重力)',
|
gravity: '自动落位(重力)',
|
||||||
locked: '锁定',
|
locked: '锁定位姿',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isMapObjectPlacementMode(value: string): value is MapObjectPlacementMode {
|
export function isMapObjectPlacementMode(value: string): value is MapObjectPlacementMode {
|
||||||
@@ -21,6 +21,16 @@ export interface MapEditorTransform {
|
|||||||
scale: [number, number, number];
|
scale: [number, number, number];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MapEditorSessionState {
|
||||||
|
dirty: boolean;
|
||||||
|
changeCount: number;
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视口与 MapEditSession 之间的命令端口。视口层只发送意图,不直接修改地图文档。
|
||||||
|
*/
|
||||||
export interface MapEditorInteractionCallbacks {
|
export interface MapEditorInteractionCallbacks {
|
||||||
onSelect(id: string | null): void;
|
onSelect(id: string | null): void;
|
||||||
onTransform(transform: MapEditorTransform): void;
|
onTransform(transform: MapEditorTransform): void;
|
||||||
@@ -28,7 +38,12 @@ export interface MapEditorInteractionCallbacks {
|
|||||||
type: EditableMapObjectType,
|
type: EditableMapObjectType,
|
||||||
position?: [number, number, number],
|
position?: [number, number, number],
|
||||||
placementMode?: MapObjectPlacementMode,
|
placementMode?: MapObjectPlacementMode,
|
||||||
|
externalSupportTop?: number,
|
||||||
): void;
|
): void;
|
||||||
|
onSetPlacementMode(id: string, mode: MapObjectPlacementMode): void;
|
||||||
|
onAlignToSurface(id: string): void;
|
||||||
|
onDelete(id: string): void;
|
||||||
|
onDiscard(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditableMapObject {
|
export interface EditableMapObject {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { createEditableObject, type EditableMapDocument } from './editor/types';
|
||||||
|
import {
|
||||||
|
mapEditorDraftPreviewInstances,
|
||||||
|
resolveMapSceneLoadAssets,
|
||||||
|
restoreAppliedMapScene,
|
||||||
|
summarizeMapSceneDraft,
|
||||||
|
transformParametricMapAsset,
|
||||||
|
} from './mapSceneDraft';
|
||||||
|
import { DEFAULT_PHYSICAL_MAP_CONFIG, type PlacedMapAsset } from './types';
|
||||||
|
|
||||||
|
function terrain(id: string, x = 0): PlacedMapAsset {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: `地形 ${id}`,
|
||||||
|
selection: {
|
||||||
|
kind: 'builtin',
|
||||||
|
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough', positionX: x },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = (id: string): PlacedMapAsset => ({
|
||||||
|
id,
|
||||||
|
name: `工程 ${id}`,
|
||||||
|
selection: { kind: 'project', descriptorPath: `maps/${id}/map.json` },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('参数地图批量草稿', () => {
|
||||||
|
it('只统计新增、修改和删除的参数地图,并为新增/修改项生成预览', () => {
|
||||||
|
const applied = [terrain('same'), terrain('changed'), terrain('removed'), project('room')];
|
||||||
|
const current = [terrain('same'), terrain('changed', 2), terrain('added', -3), project('room')];
|
||||||
|
const summary = summarizeMapSceneDraft(current, applied);
|
||||||
|
expect(summary).toMatchObject({
|
||||||
|
dirty: true,
|
||||||
|
changeCount: 3,
|
||||||
|
changedIds: ['changed', 'added'],
|
||||||
|
});
|
||||||
|
expect(summary.previewAssets.map((asset) => asset.id)).toEqual(['changed', 'added']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('把工程地图增删也纳入同一个场景草稿', () => {
|
||||||
|
const applied = [project('removed')];
|
||||||
|
const summary = summarizeMapSceneDraft([project('new')], applied);
|
||||||
|
expect(summary).toEqual({
|
||||||
|
dirty: true,
|
||||||
|
changeCount: 2,
|
||||||
|
changedIds: ['new'],
|
||||||
|
previewAssets: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('放弃草稿时严格恢复完整应用基线', () => {
|
||||||
|
const applied = [terrain('terrain', 1), project('room')];
|
||||||
|
const current = [terrain('terrain', 8), project('room'), terrain('new'), project('overlay')];
|
||||||
|
expect(summarizeMapSceneDraft(current, applied).changeCount).toBe(3);
|
||||||
|
const restored = restoreAppliedMapScene(applied);
|
||||||
|
expect(restored.map((asset) => asset.id)).toEqual(['terrain', 'room']);
|
||||||
|
expect(restored[0].selection).toEqual(terrain('terrain', 1).selection);
|
||||||
|
expect(restored[0]).not.toBe(applied[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('普通重载保持应用基线,显式提交才使用候选场景', () => {
|
||||||
|
const applied = [terrain('applied', 1)];
|
||||||
|
const draft = [terrain('draft', 9)];
|
||||||
|
expect(resolveMapSceneLoadAssets(applied).map((asset) => asset.id)).toEqual(['applied']);
|
||||||
|
expect(resolveMapSceneLoadAssets(applied, draft).map((asset) => asset.id)).toEqual(['draft']);
|
||||||
|
expect(resolveMapSceneLoadAssets(applied)[0]).not.toBe(applied[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('切换到参数地形后仍保留待提交认证资产的只读预览', () => {
|
||||||
|
const certified: PlacedMapAsset = {
|
||||||
|
id: 'certified',
|
||||||
|
name: '工程 certified',
|
||||||
|
selection: {
|
||||||
|
kind: 'project',
|
||||||
|
descriptorPath: 'maps/certified/map.json',
|
||||||
|
positionX: 2,
|
||||||
|
positionY: -1,
|
||||||
|
yawDeg: 45,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const activeTerrain = terrain('terrain');
|
||||||
|
const document: EditableMapDocument = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
mapId: 'certified',
|
||||||
|
revision: 1,
|
||||||
|
objects: [createEditableObject('box', 'box_1')],
|
||||||
|
spawnPoints: [],
|
||||||
|
};
|
||||||
|
const documents = new Map([['maps/certified/map.json', document]]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
mapEditorDraftPreviewInstances(
|
||||||
|
[certified, activeTerrain],
|
||||||
|
['certified', 'terrain'],
|
||||||
|
documents,
|
||||||
|
'terrain',
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
id: 'certified',
|
||||||
|
document,
|
||||||
|
transform: { positionX: 2, positionY: -1, yawDeg: 45 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
mapEditorDraftPreviewInstances([certified], ['certified'], documents, 'certified'),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('视口移动只更新目标参数地形并保留操纵器结果', () => {
|
||||||
|
const terrainAsset = terrain('terrain');
|
||||||
|
if (terrainAsset.selection.kind === 'builtin') terrainAsset.selection.config.size = 9;
|
||||||
|
const assets = [terrainAsset, project('room')];
|
||||||
|
const transformed = transformParametricMapAsset(assets, 'terrain', [1.26, -2.34], 35);
|
||||||
|
expect(transformed).not.toBe(assets);
|
||||||
|
expect(transformed[0].selection).toMatchObject({
|
||||||
|
kind: 'builtin',
|
||||||
|
config: { size: 9, positionX: 1.26, positionY: -2.34, yawDeg: 35 },
|
||||||
|
});
|
||||||
|
expect(transformed[1]).toBe(assets[1]);
|
||||||
|
expect(transformParametricMapAsset(assets, 'room', [5, 5], 0)).toBe(assets);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { EditableMapDocument } from './editor/types';
|
||||||
|
import { normalizePhysicalMapConfig } from './physicalMap';
|
||||||
|
import {
|
||||||
|
mapSelectionTransform,
|
||||||
|
updatePlacedMapAsset,
|
||||||
|
type MapInstanceTransform,
|
||||||
|
type PlacedMapAsset,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
function sameAsset(left: PlacedMapAsset | undefined, right: PlacedMapAsset | undefined): boolean {
|
||||||
|
return Boolean(
|
||||||
|
left &&
|
||||||
|
right &&
|
||||||
|
left.id === right.id &&
|
||||||
|
left.name === right.name &&
|
||||||
|
JSON.stringify(left.selection) === JSON.stringify(right.selection),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneAsset(asset: PlacedMapAsset): PlacedMapAsset {
|
||||||
|
return { ...asset, selection: structuredClone(asset.selection) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapSceneDraftSummary {
|
||||||
|
dirty: boolean;
|
||||||
|
changeCount: number;
|
||||||
|
/** 当前仍存在且相对应用基线新增或发生修改的实例 ID。 */
|
||||||
|
changedIds: string[];
|
||||||
|
/** 可直接在 Three.js 中预览的新增或已修改程序地图。 */
|
||||||
|
previewAssets: PlacedMapAsset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapEditorDraftPreviewInstance {
|
||||||
|
id: string;
|
||||||
|
document: EditableMapDocument;
|
||||||
|
transform: MapInstanceTransform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前激活的认证场景由交互编辑层绘制;其余待提交工程地图必须保留只读预览,
|
||||||
|
* 否则切换到参数地形时会因为清空交互文档而从画布消失。
|
||||||
|
*/
|
||||||
|
export function mapEditorDraftPreviewInstances(
|
||||||
|
assets: readonly PlacedMapAsset[],
|
||||||
|
pendingIds: readonly string[],
|
||||||
|
documents: ReadonlyMap<string, EditableMapDocument>,
|
||||||
|
activeId?: string,
|
||||||
|
): MapEditorDraftPreviewInstance[] {
|
||||||
|
const pending = new Set(pendingIds);
|
||||||
|
return assets.flatMap((asset) => {
|
||||||
|
if (asset.id === activeId || !pending.has(asset.id) || asset.selection.kind !== 'project')
|
||||||
|
return [];
|
||||||
|
const document = documents.get(asset.selection.descriptorPath);
|
||||||
|
return document
|
||||||
|
? [{ id: asset.id, document, transform: mapSelectionTransform(asset.selection) }]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所有地图实例共享同一个场景草稿边界。新增、删除、替换和实例参数修改只更新
|
||||||
|
* `current`,显式应用成功后才推进 `applied` 基线。
|
||||||
|
*/
|
||||||
|
export function summarizeMapSceneDraft(
|
||||||
|
current: readonly PlacedMapAsset[],
|
||||||
|
applied: readonly PlacedMapAsset[],
|
||||||
|
): MapSceneDraftSummary {
|
||||||
|
const currentById = new Map(current.map((asset) => [asset.id, asset]));
|
||||||
|
const appliedById = new Map(applied.map((asset) => [asset.id, asset]));
|
||||||
|
const changed = current.filter((asset) => !sameAsset(asset, appliedById.get(asset.id)));
|
||||||
|
const removed = applied.filter((asset) => !currentById.has(asset.id));
|
||||||
|
return {
|
||||||
|
dirty: changed.length + removed.length > 0,
|
||||||
|
changeCount: changed.length + removed.length,
|
||||||
|
changedIds: changed.map((asset) => asset.id),
|
||||||
|
previewAssets: changed.filter((asset) => asset.selection.kind === 'builtin').map(cloneAsset),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 放弃完整场景草稿,严格恢复上一次成功编译的实例顺序和状态。 */
|
||||||
|
export function restoreAppliedMapScene(applied: readonly PlacedMapAsset[]): PlacedMapAsset[] {
|
||||||
|
return applied.map(cloneAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clonePlacedMapAssets(assets: readonly PlacedMapAsset[]): PlacedMapAsset[] {
|
||||||
|
return assets.map(cloneAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 普通模型重载使用应用基线;只有显式场景提交才传入候选实例。 */
|
||||||
|
export function resolveMapSceneLoadAssets(
|
||||||
|
applied: readonly PlacedMapAsset[],
|
||||||
|
requested?: readonly PlacedMapAsset[],
|
||||||
|
): PlacedMapAsset[] {
|
||||||
|
return clonePlacedMapAssets(requested ?? applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视口操纵器只更新参数地形草稿,不触发 MuJoCo 编译。 */
|
||||||
|
export function transformParametricMapAsset(
|
||||||
|
assets: readonly PlacedMapAsset[],
|
||||||
|
id: string,
|
||||||
|
position: readonly [number, number],
|
||||||
|
yawDeg: number,
|
||||||
|
): PlacedMapAsset[] | readonly PlacedMapAsset[] {
|
||||||
|
const target = assets.find((asset) => asset.id === id);
|
||||||
|
if (!target || target.selection.kind !== 'builtin' || target.selection.config.preset === 'none')
|
||||||
|
return assets;
|
||||||
|
const clean = (value: number) => Math.round(value * 1_000_000) / 1_000_000;
|
||||||
|
const config = normalizePhysicalMapConfig({
|
||||||
|
...target.selection.config,
|
||||||
|
positionX: clean(position[0]),
|
||||||
|
positionY: clean(position[1]),
|
||||||
|
yawDeg: clean(yawDeg),
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
config.positionX === target.selection.config.positionX &&
|
||||||
|
config.positionY === target.selection.config.positionY &&
|
||||||
|
config.yawDeg === target.selection.config.yawDeg
|
||||||
|
)
|
||||||
|
return assets;
|
||||||
|
return updatePlacedMapAsset(assets, id, { kind: 'builtin', config });
|
||||||
|
}
|
||||||
@@ -76,6 +76,36 @@ describe('composePhysicalMap', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('程序地形在实例叠加模式移除所有无限平面,避免填平坑洞和负高度', () => {
|
||||||
|
const source = encoder.encode(
|
||||||
|
'<mujoco><worldbody><geom name="__platform_ground__" type="plane"/><geom name="external_map_floor" type="plane"/></worldbody></mujoco>',
|
||||||
|
);
|
||||||
|
const document = documentOf(
|
||||||
|
composePhysicalMap(source, config({ preset: 'rough', size: 6 }), {
|
||||||
|
instanceId: 'terrain-a',
|
||||||
|
preserveExistingGround: true,
|
||||||
|
}).data,
|
||||||
|
);
|
||||||
|
expect(document.querySelector('[name="__platform_ground__"]')).toBeNull();
|
||||||
|
expect(document.querySelector('[name="external_map_floor"]')).toBeNull();
|
||||||
|
expect(document.querySelector('[name="__platform_map_terrain-a__root__"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('平地实例在已有地面上仍生成可移动的有限地块', () => {
|
||||||
|
const source = encoder.encode(
|
||||||
|
'<mujoco><worldbody><geom name="floor" type="plane"/></worldbody></mujoco>',
|
||||||
|
);
|
||||||
|
const document = documentOf(
|
||||||
|
composePhysicalMap(source, config({ preset: 'flat', positionX: 3 }), {
|
||||||
|
instanceId: 'flat-a',
|
||||||
|
preserveExistingGround: true,
|
||||||
|
}).data,
|
||||||
|
);
|
||||||
|
const root = document.querySelector('[name="__platform_map_flat-a__root__"]');
|
||||||
|
expect(root?.getAttribute('pos')).toBe('3 0 0');
|
||||||
|
expect(root?.querySelector('[name="__platform_map_flat-a__ground__"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('系统粗糙地形生成内联高度场并替换原平面', () => {
|
it('系统粗糙地形生成内联高度场并替换原平面', () => {
|
||||||
const source = encoder.encode(
|
const source = encoder.encode(
|
||||||
'<mujoco><worldbody><geom name="__platform_ground__" type="plane"/></worldbody></mujoco>',
|
'<mujoco><worldbody><geom name="__platform_ground__" type="plane"/></worldbody></mujoco>',
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ export interface PhysicalMapComposition {
|
|||||||
summary?: string;
|
summary?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PhysicalMapCompositionOptions {
|
||||||
|
/** 场景实例命名空间;省略时保留旧的单地图命名。 */
|
||||||
|
instanceId?: string;
|
||||||
|
/** 叠加模式保留机器人或其他地图已经提供的地面。 */
|
||||||
|
preserveExistingGround?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function instancePrefix(instanceId?: string): string {
|
||||||
|
if (!instanceId) return MAP_NAME_PREFIX;
|
||||||
|
return `${MAP_NAME_PREFIX}${instanceId.replace(/[^a-zA-Z0-9_-]/g, '_')}__`;
|
||||||
|
}
|
||||||
|
|
||||||
function clamp(value: number, minimum: number, maximum: number, fallback: number): number {
|
function clamp(value: number, minimum: number, maximum: number, fallback: number): number {
|
||||||
return Number.isFinite(value) ? Math.min(maximum, Math.max(minimum, value)) : fallback;
|
return Number.isFinite(value) ? Math.min(maximum, Math.max(minimum, value)) : fallback;
|
||||||
}
|
}
|
||||||
@@ -68,13 +80,21 @@ function isGroundPlane(element: Element): boolean {
|
|||||||
return Math.abs(position[2] ?? 0) < 1e-6;
|
return Math.abs(position[2] ?? 0) < 1e-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasSupportingGround(worldbody: Element): boolean {
|
||||||
|
return Array.from(worldbody.querySelectorAll('geom')).some((geom) => {
|
||||||
|
if (isGroundPlane(geom) || geom.getAttribute('type') === 'hfield') return true;
|
||||||
|
return /(?:^|[_-])(?:floor|ground)(?:__|[_-]|$)/i.test(geom.getAttribute('name') ?? '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function setCommonGeomAttributes(
|
function setCommonGeomAttributes(
|
||||||
geom: Element,
|
geom: Element,
|
||||||
name: string,
|
name: string,
|
||||||
config: PhysicalMapConfig,
|
config: PhysicalMapConfig,
|
||||||
rgba: string,
|
rgba: string,
|
||||||
|
prefix = MAP_NAME_PREFIX,
|
||||||
): void {
|
): void {
|
||||||
geom.setAttribute('name', `${MAP_NAME_PREFIX}${name}__`);
|
geom.setAttribute('name', `${prefix}${name}__`);
|
||||||
geom.setAttribute('friction', `${config.friction} 0.005 0.0001`);
|
geom.setAttribute('friction', `${config.friction} 0.005 0.0001`);
|
||||||
geom.setAttribute('group', '2');
|
geom.setAttribute('group', '2');
|
||||||
geom.setAttribute('rgba', rgba);
|
geom.setAttribute('rgba', rgba);
|
||||||
@@ -90,9 +110,10 @@ function addBox(
|
|||||||
halfSize: [number, number, number],
|
halfSize: [number, number, number],
|
||||||
rgba: string,
|
rgba: string,
|
||||||
quaternion?: [number, number, number, number],
|
quaternion?: [number, number, number, number],
|
||||||
|
prefix = MAP_NAME_PREFIX,
|
||||||
): void {
|
): void {
|
||||||
const geom = document.createElement('geom');
|
const geom = document.createElement('geom');
|
||||||
setCommonGeomAttributes(geom, name, config, rgba);
|
setCommonGeomAttributes(geom, name, config, rgba, prefix);
|
||||||
geom.setAttribute('type', 'box');
|
geom.setAttribute('type', 'box');
|
||||||
geom.setAttribute('pos', position.join(' '));
|
geom.setAttribute('pos', position.join(' '));
|
||||||
geom.setAttribute('size', halfSize.join(' '));
|
geom.setAttribute('size', halfSize.join(' '));
|
||||||
@@ -105,6 +126,7 @@ function addHeightfield(
|
|||||||
worldbody: Element,
|
worldbody: Element,
|
||||||
config: PhysicalMapConfig,
|
config: PhysicalMapConfig,
|
||||||
heightfield: GeneratedTerrainHeightfield,
|
heightfield: GeneratedTerrainHeightfield,
|
||||||
|
prefix = MAP_NAME_PREFIX,
|
||||||
): void {
|
): void {
|
||||||
let asset = document.querySelector('mujoco > asset');
|
let asset = document.querySelector('mujoco > asset');
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
@@ -116,7 +138,7 @@ function addHeightfield(
|
|||||||
const minimum = Math.min(...heightfield.heights),
|
const minimum = Math.min(...heightfield.heights),
|
||||||
maximum = Math.max(...heightfield.heights),
|
maximum = Math.max(...heightfield.heights),
|
||||||
range = Math.max(maximum - minimum, 1e-6),
|
range = Math.max(maximum - minimum, 1e-6),
|
||||||
name = `${MAP_NAME_PREFIX}${heightfield.name.replace(/[^a-zA-Z0-9_-]/g, '_')}__`;
|
name = `${prefix}${heightfield.name.replace(/[^a-zA-Z0-9_-]/g, '_')}__`;
|
||||||
const source = document.createElement('hfield');
|
const source = document.createElement('hfield');
|
||||||
source.setAttribute('name', name);
|
source.setAttribute('name', name);
|
||||||
source.setAttribute('nrow', String(heightfield.rowSegments + 1));
|
source.setAttribute('nrow', String(heightfield.rowSegments + 1));
|
||||||
@@ -129,17 +151,22 @@ function addHeightfield(
|
|||||||
asset.append(source);
|
asset.append(source);
|
||||||
|
|
||||||
const geom = document.createElement('geom');
|
const geom = document.createElement('geom');
|
||||||
setCommonGeomAttributes(geom, heightfield.name, config, '0.32 0.42 0.28 1');
|
setCommonGeomAttributes(geom, heightfield.name, config, '0.32 0.42 0.28 1', prefix);
|
||||||
geom.setAttribute('type', 'hfield');
|
geom.setAttribute('type', 'hfield');
|
||||||
geom.setAttribute('hfield', name);
|
geom.setAttribute('hfield', name);
|
||||||
geom.setAttribute('pos', `0 0 ${minimum}`);
|
geom.setAttribute('pos', `0 0 ${minimum}`);
|
||||||
worldbody.append(geom);
|
worldbody.append(geom);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMapRoot(document: Document, worldbody: Element, config: PhysicalMapConfig): Element {
|
function createMapRoot(
|
||||||
|
document: Document,
|
||||||
|
worldbody: Element,
|
||||||
|
config: PhysicalMapConfig,
|
||||||
|
prefix = MAP_NAME_PREFIX,
|
||||||
|
): Element {
|
||||||
const root = document.createElement('body');
|
const root = document.createElement('body');
|
||||||
const halfYaw = (config.yawDeg * Math.PI) / 360;
|
const halfYaw = (config.yawDeg * Math.PI) / 360;
|
||||||
root.setAttribute('name', `${MAP_NAME_PREFIX}root__`);
|
root.setAttribute('name', `${prefix}root__`);
|
||||||
root.setAttribute('pos', `${config.positionX} ${config.positionY} 0`);
|
root.setAttribute('pos', `${config.positionX} ${config.positionY} 0`);
|
||||||
root.setAttribute('quat', `${Math.cos(halfYaw)} 0 0 ${Math.sin(halfYaw)}`);
|
root.setAttribute('quat', `${Math.cos(halfYaw)} 0 0 ${Math.sin(halfYaw)}`);
|
||||||
worldbody.append(root);
|
worldbody.append(root);
|
||||||
@@ -155,6 +182,83 @@ function seededRandom(seed: number): () => number {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GeneratedPhysicalMapBox {
|
||||||
|
name: string;
|
||||||
|
position: [number, number, number];
|
||||||
|
/** 完整边长,与 Three.js BoxGeometry 约定一致。 */
|
||||||
|
size: [number, number, number];
|
||||||
|
rgba: string;
|
||||||
|
quaternion?: [number, number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单一确定性几何源同时供 MuJoCo 合成和 Three.js 草稿预览使用。 */
|
||||||
|
export function generateBuiltinMapBoxes(
|
||||||
|
requestedConfig: PhysicalMapConfig,
|
||||||
|
): GeneratedPhysicalMapBox[] {
|
||||||
|
const config = normalizePhysicalMapConfig(requestedConfig);
|
||||||
|
if (config.preset === 'none' || isSystemTerrainPreset(config.preset)) return [];
|
||||||
|
if (config.preset === 'flat')
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'ground',
|
||||||
|
position: [0, 0, -0.05],
|
||||||
|
size: [config.size * 2, config.size * 2, 0.1],
|
||||||
|
rgba: '0.18 0.24 0.2 1',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (config.preset === 'slope') {
|
||||||
|
const angleRadians = (config.slopeAngle * Math.PI) / 180;
|
||||||
|
const rampLength = Math.min(config.size * 0.42, 6);
|
||||||
|
const thickness = 0.12;
|
||||||
|
const startX = 1.25;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'slope',
|
||||||
|
position: [
|
||||||
|
startX + (rampLength * Math.cos(angleRadians)) / 2,
|
||||||
|
0,
|
||||||
|
thickness / 2 + (rampLength * Math.sin(angleRadians)) / 2,
|
||||||
|
],
|
||||||
|
size: [rampLength, Math.min(config.size * 0.44, 4), thickness],
|
||||||
|
rgba: '0.3 0.42 0.58 1',
|
||||||
|
quaternion: [Math.cos(angleRadians / 2), 0, -Math.sin(angleRadians / 2), 0],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (config.preset === 'stairs') {
|
||||||
|
const run = Math.min(0.42, Math.max(0.25, config.size / (config.stairCount * 3)));
|
||||||
|
const rise = Math.min(0.2, run * 0.5);
|
||||||
|
const width = Math.min(config.size * 0.22, 2);
|
||||||
|
return Array.from({ length: config.stairCount }, (_, offset) => {
|
||||||
|
const index = offset + 1;
|
||||||
|
const height = index * rise;
|
||||||
|
return {
|
||||||
|
name: `stair_${index}`,
|
||||||
|
position: [1 + (index - 0.5) * run, 0, height / 2],
|
||||||
|
size: [run, width * 2, height],
|
||||||
|
rgba: index % 2 ? '0.42 0.45 0.5 1' : '0.35 0.38 0.44 1',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const random = seededRandom(config.seed);
|
||||||
|
const radius = Math.max(2.2, config.size * 0.38);
|
||||||
|
return Array.from({ length: config.obstacleCount }, (_, index) => {
|
||||||
|
const angle = (index / config.obstacleCount) * Math.PI * 2 + (random() - 0.5) * 0.4;
|
||||||
|
const distance = 1.8 + random() * Math.max(0.4, radius - 1.8);
|
||||||
|
const width = 0.18 + random() * 0.42;
|
||||||
|
const depth = 0.18 + random() * 0.42;
|
||||||
|
const height = 0.25 + random() * 0.9;
|
||||||
|
const yaw = random() * Math.PI;
|
||||||
|
return {
|
||||||
|
name: `obstacle_${index + 1}`,
|
||||||
|
position: [Math.cos(angle) * distance, Math.sin(angle) * distance, height / 2],
|
||||||
|
size: [width * 2, depth * 2, height],
|
||||||
|
rgba: '0.52 0.33 0.2 1',
|
||||||
|
quaternion: [Math.cos(yaw / 2), 0, 0, Math.sin(yaw / 2)],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function ensureGround(document: Document, worldbody: Element, config: PhysicalMapConfig): number {
|
function ensureGround(document: Document, worldbody: Element, config: PhysicalMapConfig): number {
|
||||||
const existing = Array.from(worldbody.children).find(isGroundPlane);
|
const existing = Array.from(worldbody.children).find(isGroundPlane);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -177,6 +281,7 @@ function ensureGround(document: Document, worldbody: Element, config: PhysicalMa
|
|||||||
export function composePhysicalMap(
|
export function composePhysicalMap(
|
||||||
source: Uint8Array,
|
source: Uint8Array,
|
||||||
requestedConfig: PhysicalMapConfig,
|
requestedConfig: PhysicalMapConfig,
|
||||||
|
options: PhysicalMapCompositionOptions = {},
|
||||||
): PhysicalMapComposition {
|
): PhysicalMapComposition {
|
||||||
const config = normalizePhysicalMapConfig(requestedConfig);
|
const config = normalizePhysicalMapConfig(requestedConfig);
|
||||||
if (config.preset === 'none') return { data: source, config, geomCount: 0 };
|
if (config.preset === 'none') return { data: source, config, geomCount: 0 };
|
||||||
@@ -185,16 +290,20 @@ export function composePhysicalMap(
|
|||||||
if (document.querySelector('parsererror')) throw new Error('地图合成失败:MJCF XML 无法解析');
|
if (document.querySelector('parsererror')) throw new Error('地图合成失败:MJCF XML 无法解析');
|
||||||
const worldbody = document.querySelector('mujoco > worldbody');
|
const worldbody = document.querySelector('mujoco > worldbody');
|
||||||
if (!worldbody) throw new Error('地图合成失败:MJCF 缺少 worldbody');
|
if (!worldbody) throw new Error('地图合成失败:MJCF 缺少 worldbody');
|
||||||
|
const prefix = instancePrefix(options.instanceId);
|
||||||
|
|
||||||
for (const generated of Array.from(document.querySelectorAll(`[name^="${MAP_NAME_PREFIX}"]`)))
|
for (const generated of Array.from(document.querySelectorAll(`[name^="${prefix}"]`)))
|
||||||
generated.remove();
|
generated.remove();
|
||||||
for (const emptyAsset of Array.from(document.querySelectorAll('mujoco > asset:empty')))
|
for (const emptyAsset of Array.from(document.querySelectorAll('mujoco > asset:empty')))
|
||||||
emptyAsset.remove();
|
emptyAsset.remove();
|
||||||
|
|
||||||
let geomCount: number;
|
let geomCount = 0;
|
||||||
if (isSystemTerrainPreset(config.preset)) {
|
if (isSystemTerrainPreset(config.preset)) {
|
||||||
for (const child of Array.from(worldbody.children)) if (isGroundPlane(child)) child.remove();
|
// 无限平面会把深坑、沟壑及高度场的负高度全部填平。程序地形是明确的地面
|
||||||
const mapRoot = createMapRoot(document, worldbody, config);
|
// 提供者,因此不论平面来自机器人还是先前工程地图,都必须统一移除。
|
||||||
|
for (const geom of Array.from(worldbody.querySelectorAll('geom')))
|
||||||
|
if (isGroundPlane(geom)) geom.remove();
|
||||||
|
const mapRoot = createMapRoot(document, worldbody, config, prefix);
|
||||||
const terrain = generateSystemTerrain(config.preset, config);
|
const terrain = generateSystemTerrain(config.preset, config);
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
obstacle: '0.48 0.34 0.2 1',
|
obstacle: '0.48 0.34 0.2 1',
|
||||||
@@ -215,75 +324,38 @@ export function composePhysicalMap(
|
|||||||
item.position,
|
item.position,
|
||||||
[item.size[0] / 2, item.size[1] / 2, item.size[2] / 2],
|
[item.size[0] / 2, item.size[1] / 2, item.size[2] / 2],
|
||||||
colors[role],
|
colors[role],
|
||||||
|
undefined,
|
||||||
|
prefix,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (terrain.heightfield) addHeightfield(document, mapRoot, config, terrain.heightfield);
|
if (terrain.heightfield) addHeightfield(document, mapRoot, config, terrain.heightfield, prefix);
|
||||||
geomCount = terrain.boxes.length + (terrain.heightfield ? 1 : 0);
|
geomCount = terrain.boxes.length + (terrain.heightfield ? 1 : 0);
|
||||||
|
} else {
|
||||||
|
let boxes = generateBuiltinMapBoxes(config);
|
||||||
|
if (options.preserveExistingGround) {
|
||||||
|
// 场景实例中的平地必须是可移动的有限地块;其他旧版预设则复用已有承载面。
|
||||||
|
if (config.preset !== 'flat' && !hasSupportingGround(worldbody))
|
||||||
|
boxes = generateBuiltinMapBoxes({ ...config, preset: 'flat' }).concat(boxes);
|
||||||
} else {
|
} else {
|
||||||
geomCount = ensureGround(document, worldbody, config);
|
geomCount = ensureGround(document, worldbody, config);
|
||||||
|
boxes = boxes.filter((item) => item.name !== 'ground');
|
||||||
}
|
}
|
||||||
if (config.preset === 'slope') {
|
if (boxes.length) {
|
||||||
const mapRoot = createMapRoot(document, worldbody, config);
|
const mapRoot = createMapRoot(document, worldbody, config, prefix);
|
||||||
const angleRadians = (config.slopeAngle * Math.PI) / 180;
|
for (const item of boxes)
|
||||||
const rampLength = Math.min(config.size * 0.42, 6);
|
|
||||||
const thickness = 0.12;
|
|
||||||
const startX = 1.25;
|
|
||||||
addBox(
|
addBox(
|
||||||
document,
|
document,
|
||||||
mapRoot,
|
mapRoot,
|
||||||
config,
|
config,
|
||||||
'slope',
|
item.name,
|
||||||
[
|
item.position,
|
||||||
startX + (rampLength * Math.cos(angleRadians)) / 2,
|
[item.size[0] / 2, item.size[1] / 2, item.size[2] / 2],
|
||||||
0,
|
item.rgba,
|
||||||
thickness / 2 + (rampLength * Math.sin(angleRadians)) / 2,
|
item.quaternion,
|
||||||
],
|
prefix,
|
||||||
[rampLength / 2, Math.min(config.size * 0.22, 2), thickness / 2],
|
|
||||||
'0.3 0.42 0.58 1',
|
|
||||||
[Math.cos(angleRadians / 2), 0, -Math.sin(angleRadians / 2), 0],
|
|
||||||
);
|
|
||||||
geomCount += 1;
|
|
||||||
} else if (config.preset === 'stairs') {
|
|
||||||
const mapRoot = createMapRoot(document, worldbody, config);
|
|
||||||
const run = Math.min(0.42, Math.max(0.25, config.size / (config.stairCount * 3)));
|
|
||||||
const rise = Math.min(0.2, run * 0.5);
|
|
||||||
const width = Math.min(config.size * 0.22, 2);
|
|
||||||
for (let index = 1; index <= config.stairCount; index += 1) {
|
|
||||||
const height = index * rise;
|
|
||||||
addBox(
|
|
||||||
document,
|
|
||||||
mapRoot,
|
|
||||||
config,
|
|
||||||
`stair_${index}`,
|
|
||||||
[1 + (index - 0.5) * run, 0, height / 2],
|
|
||||||
[run / 2, width, height / 2],
|
|
||||||
index % 2 ? '0.42 0.45 0.5 1' : '0.35 0.38 0.44 1',
|
|
||||||
);
|
);
|
||||||
|
geomCount += boxes.length;
|
||||||
}
|
}
|
||||||
geomCount += config.stairCount;
|
|
||||||
} else if (config.preset === 'obstacles') {
|
|
||||||
const mapRoot = createMapRoot(document, worldbody, config);
|
|
||||||
const random = seededRandom(config.seed);
|
|
||||||
const radius = Math.max(2.2, config.size * 0.38);
|
|
||||||
for (let index = 0; index < config.obstacleCount; index += 1) {
|
|
||||||
const angle = (index / config.obstacleCount) * Math.PI * 2 + (random() - 0.5) * 0.4;
|
|
||||||
const distance = 1.8 + random() * Math.max(0.4, radius - 1.8);
|
|
||||||
const width = 0.18 + random() * 0.42;
|
|
||||||
const depth = 0.18 + random() * 0.42;
|
|
||||||
const height = 0.25 + random() * 0.9;
|
|
||||||
const yaw = random() * Math.PI;
|
|
||||||
addBox(
|
|
||||||
document,
|
|
||||||
mapRoot,
|
|
||||||
config,
|
|
||||||
`obstacle_${index + 1}`,
|
|
||||||
[Math.cos(angle) * distance, Math.sin(angle) * distance, height / 2],
|
|
||||||
[width, depth, height / 2],
|
|
||||||
'0.52 0.33 0.2 1',
|
|
||||||
[Math.cos(yaw / 2), 0, 0, Math.sin(yaw / 2)],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
geomCount += config.obstacleCount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { generateSystemTerrain } from './terrainGenerator';
|
||||||
|
import { mapSceneSurfaceHeightAt } from './sceneSurface';
|
||||||
|
import { DEFAULT_PHYSICAL_MAP_CONFIG, type PhysicalMapPreset, type PlacedMapAsset } from './types';
|
||||||
|
|
||||||
|
function terrain(
|
||||||
|
id: string,
|
||||||
|
preset: PhysicalMapPreset,
|
||||||
|
overrides: Partial<typeof DEFAULT_PHYSICAL_MAP_CONFIG> = {},
|
||||||
|
): PlacedMapAsset {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
selection: {
|
||||||
|
kind: 'builtin',
|
||||||
|
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset, ...overrides },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('统一地图表面查询', () => {
|
||||||
|
it('应用实例平移和旋转后查询有限平地', () => {
|
||||||
|
const asset = terrain('flat', 'flat', { size: 4, positionX: 3, positionY: -2, yawDeg: 90 });
|
||||||
|
expect(mapSceneSurfaceHeightAt([asset], 3, -2)).toBeCloseTo(0);
|
||||||
|
expect(mapSceneSurfaceHeightAt([asset], 20, 20)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('深坑中心返回负高度而不是被默认地面填平', () => {
|
||||||
|
const asset = terrain('pit', 'pit', { size: 6, terrainDifficulty: 1 });
|
||||||
|
expect(mapSceneSurfaceHeightAt([asset], 0, 0)).toBeLessThan(-0.5);
|
||||||
|
expect(mapSceneSurfaceHeightAt([asset], 2.5, 0)).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('高度场使用与生成器一致的采样高度', () => {
|
||||||
|
const config = {
|
||||||
|
...DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||||
|
preset: 'wave' as const,
|
||||||
|
size: 4,
|
||||||
|
seed: 7,
|
||||||
|
terrainHorizontalScale: 0.5,
|
||||||
|
};
|
||||||
|
const generated = generateSystemTerrain('wave', config).heightfield!;
|
||||||
|
const center =
|
||||||
|
generated.heights[
|
||||||
|
(generated.rowSegments / 2) * (generated.columnSegments + 1) + generated.columnSegments / 2
|
||||||
|
];
|
||||||
|
expect(mapSceneSurfaceHeightAt([terrain('wave', 'wave', config)], 0, 0)).toBeCloseTo(center);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('重叠地图选择最高承载面并支持倾斜坡道 OBB', () => {
|
||||||
|
const flat = terrain('flat', 'flat', { size: 5 });
|
||||||
|
const slope = terrain('slope', 'slope', { size: 5, slopeAngle: 20 });
|
||||||
|
const height = mapSceneSurfaceHeightAt([flat, slope], 2.2, 0);
|
||||||
|
expect(height).not.toBeNull();
|
||||||
|
expect(height!).toBeGreaterThan(0.1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
generateBuiltinMapBoxes,
|
||||||
|
normalizePhysicalMapConfig,
|
||||||
|
type GeneratedPhysicalMapBox,
|
||||||
|
} from './physicalMap';
|
||||||
|
import { generateSystemTerrain, type GeneratedTerrainBox } from './terrainGenerator';
|
||||||
|
import { isSystemTerrainPreset, mapSelectionTransform, type PlacedMapAsset } from './types';
|
||||||
|
|
||||||
|
type SurfaceBox = GeneratedTerrainBox | GeneratedPhysicalMapBox;
|
||||||
|
|
||||||
|
function mapLocalPoint(asset: PlacedMapAsset, worldX: number, worldY: number): [number, number] {
|
||||||
|
const transform = mapSelectionTransform(asset.selection);
|
||||||
|
const yaw = (-transform.yawDeg * Math.PI) / 180;
|
||||||
|
const cosine = Math.cos(yaw);
|
||||||
|
const sine = Math.sin(yaw);
|
||||||
|
const dx = worldX - transform.positionX;
|
||||||
|
const dy = worldY - transform.positionY;
|
||||||
|
return [cosine * dx - sine * dy, sine * dx + cosine * dy];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateByInverseQuaternion(
|
||||||
|
vector: readonly [number, number, number],
|
||||||
|
quaternion: readonly [number, number, number, number],
|
||||||
|
): [number, number, number] {
|
||||||
|
const [w, x, y, z] = quaternion;
|
||||||
|
const length = Math.hypot(w, x, y, z) || 1;
|
||||||
|
const qw = w / length;
|
||||||
|
const qx = -x / length;
|
||||||
|
const qy = -y / length;
|
||||||
|
const qz = -z / length;
|
||||||
|
const [vx, vy, vz] = vector;
|
||||||
|
const ix = qw * vx + qy * vz - qz * vy;
|
||||||
|
const iy = qw * vy + qz * vx - qx * vz;
|
||||||
|
const iz = qw * vz + qx * vy - qy * vx;
|
||||||
|
const iw = -qx * vx - qy * vy - qz * vz;
|
||||||
|
return [
|
||||||
|
ix * qw + iw * -qx + iy * -qz - iz * -qy,
|
||||||
|
iy * qw + iw * -qy + iz * -qx - ix * -qz,
|
||||||
|
iz * qw + iw * -qz + ix * -qy - iy * -qx,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从世界 +Z 沿 -Z 与一个地图局部 OBB 求最高交点。 */
|
||||||
|
function boxSurfaceHeight(box: SurfaceBox, x: number, y: number): number | null {
|
||||||
|
const originZ = 10_000;
|
||||||
|
const quaternion =
|
||||||
|
'quaternion' in box && box.quaternion ? box.quaternion : ([1, 0, 0, 0] as const);
|
||||||
|
const origin = rotateByInverseQuaternion(
|
||||||
|
[x - box.position[0], y - box.position[1], originZ - box.position[2]],
|
||||||
|
quaternion,
|
||||||
|
);
|
||||||
|
const direction = rotateByInverseQuaternion([0, 0, -1], quaternion);
|
||||||
|
const half = [box.size[0] / 2, box.size[1] / 2, box.size[2] / 2];
|
||||||
|
let near = Number.NEGATIVE_INFINITY;
|
||||||
|
let far = Number.POSITIVE_INFINITY;
|
||||||
|
for (let axis = 0; axis < 3; axis += 1) {
|
||||||
|
if (Math.abs(direction[axis]) < 1e-10) {
|
||||||
|
if (origin[axis] < -half[axis] || origin[axis] > half[axis]) return null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let first = (-half[axis] - origin[axis]) / direction[axis];
|
||||||
|
let second = (half[axis] - origin[axis]) / direction[axis];
|
||||||
|
if (first > second) [first, second] = [second, first];
|
||||||
|
near = Math.max(near, first);
|
||||||
|
far = Math.min(far, second);
|
||||||
|
if (near > far) return null;
|
||||||
|
}
|
||||||
|
const distance = near >= 0 ? near : far;
|
||||||
|
return distance >= 0 && Number.isFinite(distance)
|
||||||
|
? Math.round((originZ - distance) * 1_000_000_000) / 1_000_000_000
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function heightfieldSurfaceHeight(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
length: number,
|
||||||
|
rowSegments: number,
|
||||||
|
columnSegments: number,
|
||||||
|
heights: readonly number[],
|
||||||
|
): number | null {
|
||||||
|
const column = ((x + width / 2) / width) * columnSegments;
|
||||||
|
const row = ((y + length / 2) / length) * rowSegments;
|
||||||
|
if (column < 0 || column > columnSegments || row < 0 || row > rowSegments) return null;
|
||||||
|
const c0 = Math.min(columnSegments, Math.floor(column));
|
||||||
|
const c1 = Math.min(columnSegments, c0 + 1);
|
||||||
|
const r0 = Math.min(rowSegments, Math.floor(row));
|
||||||
|
const r1 = Math.min(rowSegments, r0 + 1);
|
||||||
|
const tx = column - c0;
|
||||||
|
const ty = row - r0;
|
||||||
|
const at = (r: number, c: number) => heights[r * (columnSegments + 1) + c] ?? 0;
|
||||||
|
const lower = at(r0, c0) * (1 - tx) + at(r0, c1) * tx;
|
||||||
|
const upper = at(r1, c0) * (1 - tx) + at(r1, c1) * tx;
|
||||||
|
return lower * (1 - ty) + upper * ty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前程序地图草稿在世界 XY 下的最高静态表面。工程地图任意 MJCF 不在浏览器
|
||||||
|
* 侧猜测;它们仍可通过可编辑原语文档参与 MapEditSession 的局部重力落位。
|
||||||
|
*/
|
||||||
|
export function mapSceneSurfaceHeightAt(
|
||||||
|
assets: readonly PlacedMapAsset[],
|
||||||
|
worldX: number,
|
||||||
|
worldY: number,
|
||||||
|
): number | null {
|
||||||
|
let highest: number | null = null;
|
||||||
|
for (const asset of assets) {
|
||||||
|
if (asset.selection.kind !== 'builtin' || asset.selection.config.preset === 'none') continue;
|
||||||
|
const config = normalizePhysicalMapConfig(asset.selection.config);
|
||||||
|
const [x, y] = mapLocalPoint(asset, worldX, worldY);
|
||||||
|
const generated = isSystemTerrainPreset(config.preset)
|
||||||
|
? generateSystemTerrain(config.preset, config)
|
||||||
|
: { boxes: generateBuiltinMapBoxes(config) };
|
||||||
|
for (const box of generated.boxes) {
|
||||||
|
const height = boxSurfaceHeight(box, x, y);
|
||||||
|
if (height !== null && (highest === null || height > highest)) highest = height;
|
||||||
|
}
|
||||||
|
if (generated.heightfield) {
|
||||||
|
const height = heightfieldSurfaceHeight(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
generated.heightfield.width,
|
||||||
|
generated.heightfield.length,
|
||||||
|
generated.heightfield.rowSegments,
|
||||||
|
generated.heightfield.columnSegments,
|
||||||
|
generated.heightfield.heights,
|
||||||
|
);
|
||||||
|
if (height !== null && (highest === null || height > highest)) highest = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return highest;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||||
|
createPlacedMapAsset,
|
||||||
|
mapLocalPointToWorld,
|
||||||
|
mapSelectionTransform,
|
||||||
|
mapWorldPointToLocal,
|
||||||
|
updatePlacedMapAsset,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
describe('地图场景实例', () => {
|
||||||
|
it('实例 id 独立于源资产并且只更新目标实例参数', () => {
|
||||||
|
const first = createPlacedMapAsset(
|
||||||
|
{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'stairs' } },
|
||||||
|
'楼梯',
|
||||||
|
'map-a',
|
||||||
|
);
|
||||||
|
const second = createPlacedMapAsset(
|
||||||
|
{ kind: 'project', descriptorPath: 'maps/warehouse/map.json' },
|
||||||
|
'仓库',
|
||||||
|
'map-b',
|
||||||
|
);
|
||||||
|
const next = updatePlacedMapAsset(
|
||||||
|
[first, second],
|
||||||
|
first.id,
|
||||||
|
{
|
||||||
|
kind: 'builtin',
|
||||||
|
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'stairs', stairCount: 12 },
|
||||||
|
},
|
||||||
|
'楼梯',
|
||||||
|
);
|
||||||
|
expect(next[0].selection).toMatchObject({ kind: 'builtin', config: { stairCount: 12 } });
|
||||||
|
expect(next[1]).toBe(second);
|
||||||
|
expect(next[1].selection).toEqual({
|
||||||
|
kind: 'project',
|
||||||
|
descriptorPath: 'maps/warehouse/map.json',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('程序地图和工程地图实例共享同一平面变换约定', () => {
|
||||||
|
expect(
|
||||||
|
mapSelectionTransform({
|
||||||
|
kind: 'builtin',
|
||||||
|
config: {
|
||||||
|
...DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||||
|
preset: 'rough',
|
||||||
|
positionX: 2,
|
||||||
|
positionY: -3,
|
||||||
|
yawDeg: 45,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({ positionX: 2, positionY: -3, yawDeg: 45 });
|
||||||
|
expect(
|
||||||
|
mapSelectionTransform({
|
||||||
|
kind: 'project',
|
||||||
|
descriptorPath: 'maps/warehouse/map.json',
|
||||||
|
positionX: 200,
|
||||||
|
positionY: Number.NaN,
|
||||||
|
yawDeg: -999,
|
||||||
|
}),
|
||||||
|
).toEqual({ positionX: 100, positionY: 0, yawDeg: -180 });
|
||||||
|
|
||||||
|
const selection = {
|
||||||
|
kind: 'project' as const,
|
||||||
|
descriptorPath: 'maps/warehouse/map.json',
|
||||||
|
positionX: 2,
|
||||||
|
positionY: -3,
|
||||||
|
yawDeg: 90,
|
||||||
|
};
|
||||||
|
const world = mapLocalPointToWorld([1, 2, 0.5], selection);
|
||||||
|
expect(world[0]).toBeCloseTo(0);
|
||||||
|
expect(world[1]).toBeCloseTo(-2);
|
||||||
|
expect(mapWorldPointToLocal(world, selection)).toEqual([
|
||||||
|
expect.closeTo(1),
|
||||||
|
expect.closeTo(2),
|
||||||
|
0.5,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@ export type SystemTerrainPreset = (typeof SYSTEM_TERRAIN_PRESETS)[number];
|
|||||||
export type PhysicalMapPreset =
|
export type PhysicalMapPreset =
|
||||||
'none' | 'flat' | 'slope' | 'stairs' | 'obstacles' | SystemTerrainPreset;
|
'none' | 'flat' | 'slope' | 'stairs' | 'obstacles' | SystemTerrainPreset;
|
||||||
|
|
||||||
export function isSystemTerrainPreset(value: PhysicalMapPreset): value is SystemTerrainPreset {
|
export function isSystemTerrainPreset(value: string): value is SystemTerrainPreset {
|
||||||
return (SYSTEM_TERRAIN_PRESETS as readonly string[]).includes(value);
|
return (SYSTEM_TERRAIN_PRESETS as readonly string[]).includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,11 +82,45 @@ export type MapSelection =
|
|||||||
| {
|
| {
|
||||||
kind: 'project';
|
kind: 'project';
|
||||||
descriptorPath: string;
|
descriptorPath: string;
|
||||||
|
/** 工程地图实例位姿;descriptor 内容始终保持源坐标不变。 */
|
||||||
|
positionX?: number;
|
||||||
|
positionY?: number;
|
||||||
|
yawDeg?: number;
|
||||||
spawnPointId?: string;
|
spawnPointId?: string;
|
||||||
robotRootBody?: string;
|
robotRootBody?: string;
|
||||||
frictionOverride?: number;
|
frictionOverride?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlacedMapSelection = Exclude<MapSelection, { kind: 'none' }>;
|
||||||
|
|
||||||
|
/** 场景中的地图实例;源地图与实例状态分离,同一源可重复放置。 */
|
||||||
|
export interface PlacedMapAsset {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
selection: PlacedMapSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlacedMapAsset(
|
||||||
|
selection: PlacedMapSelection,
|
||||||
|
name: string,
|
||||||
|
id = `map_${crypto.randomUUID().slice(0, 8)}`,
|
||||||
|
): PlacedMapAsset {
|
||||||
|
return { id, name, selection: structuredClone(selection) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePlacedMapAsset(
|
||||||
|
assets: readonly PlacedMapAsset[],
|
||||||
|
id: string,
|
||||||
|
selection: PlacedMapSelection,
|
||||||
|
name?: string,
|
||||||
|
): PlacedMapAsset[] {
|
||||||
|
return assets.map((asset) =>
|
||||||
|
asset.id === id
|
||||||
|
? { ...asset, ...(name ? { name } : {}), selection: structuredClone(selection) }
|
||||||
|
: asset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResolvedProjectMap {
|
export interface ResolvedProjectMap {
|
||||||
definition: MapDefinition;
|
definition: MapDefinition;
|
||||||
descriptorPath: string;
|
descriptorPath: string;
|
||||||
@@ -102,6 +136,67 @@ export interface VisualMapAsset {
|
|||||||
data: Uint8Array;
|
data: Uint8Array;
|
||||||
castShadow: boolean;
|
castShadow: boolean;
|
||||||
receiveShadow: boolean;
|
receiveShadow: boolean;
|
||||||
|
positionX: number;
|
||||||
|
positionY: number;
|
||||||
|
yawDeg: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapInstanceTransform {
|
||||||
|
positionX: number;
|
||||||
|
positionY: number;
|
||||||
|
yawDeg: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_MAP_INSTANCE_TRANSFORM: MapInstanceTransform = {
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
yawDeg: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mapSelectionTransform(selection: PlacedMapSelection): MapInstanceTransform {
|
||||||
|
if (selection.kind === 'builtin')
|
||||||
|
return {
|
||||||
|
positionX: selection.config.positionX,
|
||||||
|
positionY: selection.config.positionY,
|
||||||
|
yawDeg: selection.config.yawDeg,
|
||||||
|
};
|
||||||
|
const finite = (value: number | undefined, minimum: number, maximum: number) =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value)
|
||||||
|
? Math.min(maximum, Math.max(minimum, value))
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
positionX: finite(selection.positionX, -100, 100),
|
||||||
|
positionY: finite(selection.positionY, -100, 100),
|
||||||
|
yawDeg: finite(selection.yawDeg, -180, 180),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapLocalPointToWorld(
|
||||||
|
point: readonly [number, number, number],
|
||||||
|
selection: PlacedMapSelection,
|
||||||
|
): [number, number, number] {
|
||||||
|
const transform = mapSelectionTransform(selection);
|
||||||
|
const yaw = (transform.yawDeg * Math.PI) / 180;
|
||||||
|
const cosine = Math.cos(yaw);
|
||||||
|
const sine = Math.sin(yaw);
|
||||||
|
return [
|
||||||
|
transform.positionX + cosine * point[0] - sine * point[1],
|
||||||
|
transform.positionY + sine * point[0] + cosine * point[1],
|
||||||
|
point[2],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWorldPointToLocal(
|
||||||
|
point: readonly [number, number, number],
|
||||||
|
selection: PlacedMapSelection,
|
||||||
|
): [number, number, number] {
|
||||||
|
const transform = mapSelectionTransform(selection);
|
||||||
|
const yaw = (-transform.yawDeg * Math.PI) / 180;
|
||||||
|
const cosine = Math.cos(yaw);
|
||||||
|
const sine = Math.sin(yaw);
|
||||||
|
const dx = point[0] - transform.positionX;
|
||||||
|
const dy = point[1] - transform.positionY;
|
||||||
|
return [cosine * dx - sine * dy, sine * dx + cosine * dy, point[2]];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_PHYSICAL_MAP_CONFIG: PhysicalMapConfig = {
|
export const DEFAULT_PHYSICAL_MAP_CONFIG: PhysicalMapConfig = {
|
||||||
|
|||||||
@@ -36,12 +36,20 @@ function BodyBranch({
|
|||||||
node,
|
node,
|
||||||
depth,
|
depth,
|
||||||
onJointHover,
|
onJointHover,
|
||||||
|
onSelectBody,
|
||||||
|
onSelectJoint,
|
||||||
|
selectedBodyId,
|
||||||
|
selectedJointId,
|
||||||
searching,
|
searching,
|
||||||
query,
|
query,
|
||||||
}: {
|
}: {
|
||||||
node: BodyNode;
|
node: BodyNode;
|
||||||
depth: number;
|
depth: number;
|
||||||
onJointHover: (jointId: number | null) => void;
|
onJointHover: (jointId: number | null) => void;
|
||||||
|
onSelectBody?: (bodyId: number) => void;
|
||||||
|
onSelectJoint?: (joint: JointInfo) => void;
|
||||||
|
selectedBodyId?: number;
|
||||||
|
selectedJointId?: number;
|
||||||
searching: boolean;
|
searching: boolean;
|
||||||
query: string;
|
query: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -60,11 +68,15 @@ function BodyBranch({
|
|||||||
<summary
|
<summary
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
aria-expanded={shownOpen}
|
aria-expanded={shownOpen}
|
||||||
|
aria-selected={selectedBodyId === node.id}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
onSelectBody?.(node.id);
|
||||||
if (searching) event.preventDefault();
|
if (searching) event.preventDefault();
|
||||||
}}
|
}}
|
||||||
className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30"
|
className={`flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30 ${
|
||||||
|
selectedBodyId === node.id ? 'bg-accent-soft text-accent' : 'text-text-secondary'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
|
<Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
@@ -74,10 +86,14 @@ function BodyBranch({
|
|||||||
<ul role="group" className="ml-3 border-l border-border pl-1">
|
<ul role="group" className="ml-3 border-l border-border pl-1">
|
||||||
{node.joints.map((joint) => (
|
{node.joints.map((joint) => (
|
||||||
<li role="none" key={joint.id}>
|
<li role="none" key={joint.id}>
|
||||||
<span
|
<button
|
||||||
|
type="button"
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
tabIndex={0}
|
aria-selected={selectedJointId === joint.id}
|
||||||
className="flex cursor-default items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-warning hover:bg-warning-soft focus:bg-warning-soft focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30"
|
className={`flex w-full items-center gap-1.5 truncate rounded px-1.5 py-1 text-left text-xs text-warning hover:bg-warning-soft focus:bg-warning-soft focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 ${
|
||||||
|
selectedJointId === joint.id ? 'bg-warning-soft' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelectJoint?.(joint)}
|
||||||
onMouseEnter={() => onJointHover(joint.id)}
|
onMouseEnter={() => onJointHover(joint.id)}
|
||||||
onMouseLeave={() => onJointHover(null)}
|
onMouseLeave={() => onJointHover(null)}
|
||||||
onFocus={() => onJointHover(joint.id)}
|
onFocus={() => onJointHover(joint.id)}
|
||||||
@@ -86,7 +102,7 @@ function BodyBranch({
|
|||||||
>
|
>
|
||||||
<Disc3 aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
|
<Disc3 aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
|
||||||
<SearchHighlight text={joint.name} query={query} />
|
<SearchHighlight text={joint.name} query={query} />
|
||||||
</span>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{node.children.map((child) => (
|
{node.children.map((child) => (
|
||||||
@@ -95,6 +111,10 @@ function BodyBranch({
|
|||||||
node={child}
|
node={child}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
onJointHover={onJointHover}
|
onJointHover={onJointHover}
|
||||||
|
onSelectBody={onSelectBody}
|
||||||
|
onSelectJoint={onSelectJoint}
|
||||||
|
selectedBodyId={selectedBodyId}
|
||||||
|
selectedJointId={selectedJointId}
|
||||||
searching={searching}
|
searching={searching}
|
||||||
query={query}
|
query={query}
|
||||||
/>
|
/>
|
||||||
@@ -102,14 +122,20 @@ function BodyBranch({
|
|||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
tabIndex={0}
|
aria-selected={selectedBodyId === node.id}
|
||||||
className="flex items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary focus-visible:ring-2 focus-visible:ring-accent/30"
|
className={`flex w-full items-center gap-1.5 truncate rounded px-1.5 py-1 text-left text-xs focus-visible:ring-2 focus-visible:ring-accent/30 ${
|
||||||
|
selectedBodyId === node.id
|
||||||
|
? 'bg-accent-soft text-accent'
|
||||||
|
: 'text-text-secondary hover:bg-element-hover'
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelectBody?.(node.id)}
|
||||||
>
|
>
|
||||||
<Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
|
<Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||||
<SearchHighlight text={node.name} query={query} />
|
<SearchHighlight text={node.name} query={query} />
|
||||||
</div>
|
</button>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -169,11 +195,19 @@ export function ModelStructureTree({
|
|||||||
bodies,
|
bodies,
|
||||||
joints,
|
joints,
|
||||||
onJointHover,
|
onJointHover,
|
||||||
|
onSelectBody,
|
||||||
|
onSelectJoint,
|
||||||
|
selectedBodyId,
|
||||||
|
selectedJointId,
|
||||||
query = '',
|
query = '',
|
||||||
}: {
|
}: {
|
||||||
bodies: BodyInfo[];
|
bodies: BodyInfo[];
|
||||||
joints: JointInfo[];
|
joints: JointInfo[];
|
||||||
onJointHover: (jointId: number | null) => void;
|
onJointHover: (jointId: number | null) => void;
|
||||||
|
onSelectBody?: (bodyId: number) => void;
|
||||||
|
onSelectJoint?: (joint: JointInfo) => void;
|
||||||
|
selectedBodyId?: number;
|
||||||
|
selectedJointId?: number;
|
||||||
query?: string;
|
query?: string;
|
||||||
}) {
|
}) {
|
||||||
const normalized = query.trim().toLocaleLowerCase(),
|
const normalized = query.trim().toLocaleLowerCase(),
|
||||||
@@ -211,8 +245,12 @@ export function ModelStructureTree({
|
|||||||
renderRow={(item) =>
|
renderRow={(item) =>
|
||||||
item.kind === 'body' ? (
|
item.kind === 'body' ? (
|
||||||
<div
|
<div
|
||||||
|
aria-selected={selectedBodyId === item.body.id}
|
||||||
|
onClick={() => onSelectBody?.(item.body.id)}
|
||||||
onDoubleClick={() => toggle(item)}
|
onDoubleClick={() => toggle(item)}
|
||||||
className="flex h-full items-center gap-1.5 px-1.5 text-xs text-text-secondary"
|
className={`flex h-full items-center gap-1.5 px-1.5 text-xs ${
|
||||||
|
selectedBodyId === item.body.id ? 'text-accent' : 'text-text-secondary'
|
||||||
|
}`}
|
||||||
style={{ paddingLeft: item.depth * 12 + 6 }}
|
style={{ paddingLeft: item.depth * 12 + 6 }}
|
||||||
>
|
>
|
||||||
<Box className="h-3.5 w-3.5 text-accent" />
|
<Box className="h-3.5 w-3.5 text-accent" />
|
||||||
@@ -220,8 +258,12 @@ export function ModelStructureTree({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="flex h-full items-center gap-1.5 px-1.5 text-xs text-warning"
|
aria-selected={selectedJointId === item.joint.id}
|
||||||
|
className={`flex h-full items-center gap-1.5 px-1.5 text-xs text-warning ${
|
||||||
|
selectedJointId === item.joint.id ? 'bg-warning-soft' : ''
|
||||||
|
}`}
|
||||||
style={{ paddingLeft: item.depth * 12 + 6 }}
|
style={{ paddingLeft: item.depth * 12 + 6 }}
|
||||||
|
onClick={() => onSelectJoint?.(item.joint)}
|
||||||
onMouseEnter={() => onJointHover(item.joint.id)}
|
onMouseEnter={() => onJointHover(item.joint.id)}
|
||||||
onMouseLeave={() => onJointHover(null)}
|
onMouseLeave={() => onJointHover(null)}
|
||||||
>
|
>
|
||||||
@@ -244,6 +286,10 @@ export function ModelStructureTree({
|
|||||||
node={root}
|
node={root}
|
||||||
depth={0}
|
depth={0}
|
||||||
onJointHover={onJointHover}
|
onJointHover={onJointHover}
|
||||||
|
onSelectBody={onSelectBody}
|
||||||
|
onSelectJoint={onSelectJoint}
|
||||||
|
selectedBodyId={selectedBodyId}
|
||||||
|
selectedJointId={selectedJointId}
|
||||||
searching={Boolean(normalized)}
|
searching={Boolean(normalized)}
|
||||||
query={query}
|
query={query}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { zipSync } from 'fflate';
|
|||||||
import {
|
import {
|
||||||
choosePreferredEntry,
|
choosePreferredEntry,
|
||||||
discoverEntries,
|
discoverEntries,
|
||||||
|
filesFromDrop,
|
||||||
importBrowserFiles,
|
importBrowserFiles,
|
||||||
normalizeProjectPath,
|
normalizeProjectPath,
|
||||||
prepareProjectForMujoco,
|
prepareProjectForMujoco,
|
||||||
@@ -35,6 +36,30 @@ describe('project importer', () => {
|
|||||||
expect(entries).toHaveLength(3);
|
expect(entries).toHaveLength(3);
|
||||||
expect(choosePreferredEntry(entries)).toBe('model.xml');
|
expect(choosePreferredEntry(entries)).toBe('model.xml');
|
||||||
});
|
});
|
||||||
|
it('通过现代文件系统句柄递归读取拖入的文件夹', async () => {
|
||||||
|
const model = new File(['<mujoco/>'], 'model.xml', { type: 'text/xml' });
|
||||||
|
const fileHandle = {
|
||||||
|
kind: 'file',
|
||||||
|
name: 'model.xml',
|
||||||
|
getFile: async () => model,
|
||||||
|
};
|
||||||
|
const directoryHandle = {
|
||||||
|
kind: 'directory',
|
||||||
|
name: 'robot',
|
||||||
|
async *values() {
|
||||||
|
yield fileHandle;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
kind: 'file',
|
||||||
|
getAsFileSystemHandle: async () => directoryHandle,
|
||||||
|
},
|
||||||
|
] as unknown as DataTransferItemList;
|
||||||
|
const files = await filesFromDrop(items, [] as unknown as FileList);
|
||||||
|
expect(files).toHaveLength(1);
|
||||||
|
expect(files[0].webkitRelativePath).toBe('robot/model.xml');
|
||||||
|
});
|
||||||
it('异步解压 ZIP、保留二进制数据并报告阶段进度', async () => {
|
it('异步解压 ZIP、保留二进制数据并报告阶段进度', async () => {
|
||||||
const zipped = zipSync({
|
const zipped = zipSync({
|
||||||
'robot/model.urdf': encode('<robot name="r"/>'),
|
'robot/model.urdf': encode('<robot name="r"/>'),
|
||||||
|
|||||||
@@ -427,6 +427,29 @@ export async function importBrowserFiles(
|
|||||||
return manifest(files[0].path.split('/')[0] || '工程', files);
|
return manifest(files[0].path.split('/')[0] || '工程', files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DroppedFileSystemHandle {
|
||||||
|
kind: 'file' | 'directory';
|
||||||
|
name: string;
|
||||||
|
getFile?: () => Promise<File>;
|
||||||
|
values?: () => AsyncIterableIterator<DroppedFileSystemHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFileSystemHandle(handle: DroppedFileSystemHandle, prefix = ''): Promise<File[]> {
|
||||||
|
if (handle.kind === 'file' && handle.getFile) {
|
||||||
|
const file = await handle.getFile();
|
||||||
|
Object.defineProperty(file, 'webkitRelativePath', {
|
||||||
|
configurable: true,
|
||||||
|
value: `${prefix}${file.name}`,
|
||||||
|
});
|
||||||
|
return [file];
|
||||||
|
}
|
||||||
|
if (handle.kind !== 'directory' || !handle.values) return [];
|
||||||
|
const files: File[] = [];
|
||||||
|
for await (const child of handle.values())
|
||||||
|
files.push(...(await readFileSystemHandle(child, `${prefix}${handle.name}/`)));
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
interface LegacyEntry {
|
interface LegacyEntry {
|
||||||
isFile: boolean;
|
isFile: boolean;
|
||||||
isDirectory: boolean;
|
isDirectory: boolean;
|
||||||
@@ -464,7 +487,27 @@ export async function filesFromDrop(
|
|||||||
items: DataTransferItemList,
|
items: DataTransferItemList,
|
||||||
fallback: FileList,
|
fallback: FileList,
|
||||||
): Promise<File[]> {
|
): Promise<File[]> {
|
||||||
const entries = Array.from(items)
|
const fileItems = Array.from(items).filter((item) => item.kind === 'file');
|
||||||
|
const handleRequests = fileItems
|
||||||
|
.map((item) => {
|
||||||
|
const getter = (
|
||||||
|
item as unknown as {
|
||||||
|
getAsFileSystemHandle?: () => Promise<DroppedFileSystemHandle | null>;
|
||||||
|
}
|
||||||
|
).getAsFileSystemHandle;
|
||||||
|
return getter?.call(item);
|
||||||
|
})
|
||||||
|
.filter((request): request is Promise<DroppedFileSystemHandle | null> => Boolean(request));
|
||||||
|
if (handleRequests.length > 0 && handleRequests.length === fileItems.length) {
|
||||||
|
const settled = await Promise.allSettled(handleRequests);
|
||||||
|
const handles = settled.flatMap((result) =>
|
||||||
|
result.status === 'fulfilled' && result.value ? [result.value] : [],
|
||||||
|
);
|
||||||
|
if (handles.length === fileItems.length)
|
||||||
|
return (await Promise.all(handles.map((handle) => readFileSystemHandle(handle)))).flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = fileItems
|
||||||
.map(
|
.map(
|
||||||
(item) =>
|
(item) =>
|
||||||
(item as unknown as { webkitGetAsEntry?: () => LegacyEntry | null }).webkitGetAsEntry?.() ??
|
(item as unknown as { webkitGetAsEntry?: () => LegacyEntry | null }).webkitGetAsEntry?.() ??
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ActuatorInfo, ActuatorParameters } from './SimulationSession';
|
import type { ActuatorInfo, ActuatorParameters } from './SimulationSession';
|
||||||
import { Badge } from '../components/ui';
|
import { Badge, ScrubbableNumberInput } from '../components/ui';
|
||||||
|
|
||||||
export function ActuatorControl({
|
export function ActuatorControl({
|
||||||
actuator,
|
actuator,
|
||||||
@@ -187,25 +187,15 @@ function ParameterInput({
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<label className="block text-[10px] text-text-tertiary">
|
<ScrubbableNumberInput
|
||||||
<span className="mb-1 block truncate">{label}</span>
|
label={label}
|
||||||
<input
|
value={Number.isFinite(value) ? value : 0}
|
||||||
key={value}
|
step={Math.max(0.001, Math.abs(value) * 0.01)}
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
defaultValue={Number.isFinite(value) ? value : 0}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40"
|
onValueChange={(next) => {
|
||||||
onBlur={(event) => {
|
if (next !== value) onCommit(next);
|
||||||
const next = Number(event.currentTarget.value);
|
|
||||||
if (Number.isFinite(next) && next !== value) onCommit(next);
|
|
||||||
else event.currentTarget.value = String(value);
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter') event.currentTarget.blur();
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function ParameterToggle({
|
function ParameterToggle({
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { MainThreadPhysicsAdapter } from './PhysicsAdapter';
|
||||||
|
import type { ProjectFile } from '../project/types';
|
||||||
|
|
||||||
|
interface Disposable {
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdapterLifecycleState {
|
||||||
|
session: Disposable | null;
|
||||||
|
workspace: Disposable | null;
|
||||||
|
supportFiles: ProjectFile[];
|
||||||
|
retiredSession: Disposable | null;
|
||||||
|
retiredWorkspace: Disposable | null;
|
||||||
|
retiredSupportFiles: ProjectFile[];
|
||||||
|
releaseRetired(): void;
|
||||||
|
rollbackRetired(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lifecycle = (adapter: MainThreadPhysicsAdapter) =>
|
||||||
|
adapter as unknown as AdapterLifecycleState;
|
||||||
|
|
||||||
|
const file = (path: string): ProjectFile => ({
|
||||||
|
path,
|
||||||
|
data: new Uint8Array(),
|
||||||
|
size: 0,
|
||||||
|
source: 'file',
|
||||||
|
mimeType: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MainThreadPhysicsAdapter 会话事务清理', () => {
|
||||||
|
it('先清空 retired 引用,旧资源释放失败也不会破坏当前会话', () => {
|
||||||
|
const adapter = new MainThreadPhysicsAdapter();
|
||||||
|
const state = lifecycle(adapter);
|
||||||
|
const activeSession = { dispose: vi.fn() };
|
||||||
|
const retiredSession = {
|
||||||
|
dispose: vi.fn(() => {
|
||||||
|
throw new Error('controller cleanup failed');
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const retiredWorkspace = { dispose: vi.fn() };
|
||||||
|
state.session = activeSession;
|
||||||
|
state.retiredSession = retiredSession;
|
||||||
|
state.retiredWorkspace = retiredWorkspace;
|
||||||
|
state.retiredSupportFiles = [file('old.xml')];
|
||||||
|
|
||||||
|
expect(() => state.releaseRetired()).not.toThrow();
|
||||||
|
expect(state.session).toBe(activeSession);
|
||||||
|
expect(state.retiredSession).toBeNull();
|
||||||
|
expect(state.retiredWorkspace).toBeNull();
|
||||||
|
expect(state.retiredSupportFiles).toEqual([]);
|
||||||
|
expect(retiredSession.dispose).toHaveBeenCalledOnce();
|
||||||
|
expect(retiredWorkspace.dispose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('先恢复旧引用,候选资源释放失败也不会阻止回滚', () => {
|
||||||
|
const adapter = new MainThreadPhysicsAdapter();
|
||||||
|
const state = lifecycle(adapter);
|
||||||
|
const failedSession = {
|
||||||
|
dispose: vi.fn(() => {
|
||||||
|
throw new Error('candidate cleanup failed');
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const failedWorkspace = { dispose: vi.fn() };
|
||||||
|
const retiredSession = { dispose: vi.fn() };
|
||||||
|
const retiredWorkspace = { dispose: vi.fn() };
|
||||||
|
const retiredFiles = [file('old.xml')];
|
||||||
|
state.session = failedSession;
|
||||||
|
state.workspace = failedWorkspace;
|
||||||
|
state.supportFiles = [file('new.xml')];
|
||||||
|
state.retiredSession = retiredSession;
|
||||||
|
state.retiredWorkspace = retiredWorkspace;
|
||||||
|
state.retiredSupportFiles = retiredFiles;
|
||||||
|
|
||||||
|
expect(() => state.rollbackRetired()).not.toThrow();
|
||||||
|
expect(state.session).toBe(retiredSession);
|
||||||
|
expect(state.workspace).toBe(retiredWorkspace);
|
||||||
|
expect(state.supportFiles).toBe(retiredFiles);
|
||||||
|
expect(state.retiredSession).toBeNull();
|
||||||
|
expect(state.retiredWorkspace).toBeNull();
|
||||||
|
expect(failedSession.dispose).toHaveBeenCalledOnce();
|
||||||
|
expect(failedWorkspace.dispose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,7 +24,8 @@ import type {
|
|||||||
import { composePhysicalMap } from '../map/physicalMap';
|
import { composePhysicalMap } from '../map/physicalMap';
|
||||||
import { composeProjectMap } from '../map/MapComposer';
|
import { composeProjectMap } from '../map/MapComposer';
|
||||||
import { resolveProjectMap } from '../map/MapLoader';
|
import { resolveProjectMap } from '../map/MapLoader';
|
||||||
import { DEFAULT_MAP_SELECTION, type MapSelection } from '../map/types';
|
import { composePlacedMapAssets } from '../map/MapStackComposer';
|
||||||
|
import { DEFAULT_MAP_SELECTION, type MapSelection, type PlacedMapAsset } from '../map/types';
|
||||||
|
|
||||||
export type UrdfLoadMode = 'mjcf' | 'native';
|
export type UrdfLoadMode = 'mjcf' | 'native';
|
||||||
export type { UrdfBaseMode, UrdfEnhancementOptions };
|
export type { UrdfBaseMode, UrdfEnhancementOptions };
|
||||||
@@ -38,7 +39,9 @@ export interface PhysicsLoadOptions {
|
|||||||
urdfMode?: UrdfLoadMode;
|
urdfMode?: UrdfLoadMode;
|
||||||
baseMode?: UrdfBaseMode;
|
baseMode?: UrdfBaseMode;
|
||||||
enhancements?: UrdfEnhancementOptions;
|
enhancements?: UrdfEnhancementOptions;
|
||||||
|
/** @deprecated 单地图兼容入口;新场景应使用 mapAssets。 */
|
||||||
map?: MapSelection;
|
map?: MapSelection;
|
||||||
|
mapAssets?: readonly PlacedMapAsset[];
|
||||||
onProgress?: (progress: PhysicsLoadProgress) => void;
|
onProgress?: (progress: PhysicsLoadProgress) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +129,8 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
|||||||
sensorType: 'camera',
|
sensorType: 'camera',
|
||||||
};
|
};
|
||||||
const mapSelection = options.map ?? DEFAULT_MAP_SELECTION;
|
const mapSelection = options.map ?? DEFAULT_MAP_SELECTION;
|
||||||
|
const placedMapAssets = options.mapAssets;
|
||||||
|
const hasMaps = placedMapAssets ? placedMapAssets.length > 0 : mapSelection.kind !== 'none';
|
||||||
const report = (value: number, label: string) => {
|
const report = (value: number, label: string) => {
|
||||||
if (!this.disposed && generation === this.loadGeneration)
|
if (!this.disposed && generation === this.loadGeneration)
|
||||||
options.onProgress?.({ value, label });
|
options.onProgress?.({ value, label });
|
||||||
@@ -191,24 +196,31 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
|||||||
intermediate.dispose();
|
intermediate.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (mapSelection.kind !== 'none') {
|
if (hasMaps) {
|
||||||
report(0.72, '组合机器人与物理地图');
|
report(0.72, '组合机器人与物理地图');
|
||||||
if (entry?.format === 'urdf' && urdfMode === 'native')
|
if (entry?.format === 'urdf' && urdfMode === 'native')
|
||||||
throw new Error('原生 URDF 模式暂不支持地图,请切换为转换模式');
|
throw new Error('原生 URDF 模式暂不支持地图,请切换为转换模式');
|
||||||
const slash = modelRelativePath.lastIndexOf('/');
|
const slash = modelRelativePath.lastIndexOf('/');
|
||||||
const directory = slash >= 0 ? modelRelativePath.slice(0, slash + 1) : '';
|
const directory = slash >= 0 ? modelRelativePath.slice(0, slash + 1) : '';
|
||||||
const mapPath = `${directory}.__mujoco_map_scene_${manifest.id.replace(/[^a-zA-Z0-9_-]/g, '_')}.xml`;
|
const mapPath = `${directory}.__mujoco_map_scene_${manifest.id.replace(/[^a-zA-Z0-9_-]/g, '_')}.xml`;
|
||||||
if (mapSelection.kind === 'builtin') {
|
const source = new TextEncoder().encode(workspace.readText(modelRelativePath));
|
||||||
const composed = composePhysicalMap(
|
if (placedMapAssets) {
|
||||||
new TextEncoder().encode(workspace.readText(modelRelativePath)),
|
const composed = composePlacedMapAssets(
|
||||||
mapSelection.config,
|
source,
|
||||||
|
mapPath,
|
||||||
|
prepared.manifest,
|
||||||
|
placedMapAssets,
|
||||||
);
|
);
|
||||||
workspace.writeGenerated(mapPath, composed.data);
|
workspace.writeGenerated(mapPath, composed.data);
|
||||||
|
warnings.push(...composed.warnings);
|
||||||
|
} else if (mapSelection.kind === 'builtin') {
|
||||||
|
const composed = composePhysicalMap(source, mapSelection.config);
|
||||||
|
workspace.writeGenerated(mapPath, composed.data);
|
||||||
if (composed.summary) warnings.push(composed.summary);
|
if (composed.summary) warnings.push(composed.summary);
|
||||||
} else {
|
} else if (mapSelection.kind === 'project') {
|
||||||
const resolvedMap = resolveProjectMap(prepared.manifest, mapSelection.descriptorPath);
|
const resolvedMap = resolveProjectMap(prepared.manifest, mapSelection.descriptorPath);
|
||||||
const composed = composeProjectMap(
|
const composed = composeProjectMap(
|
||||||
new TextEncoder().encode(workspace.readText(modelRelativePath)),
|
source,
|
||||||
mapPath,
|
mapPath,
|
||||||
prepared.manifest,
|
prepared.manifest,
|
||||||
resolvedMap,
|
resolvedMap,
|
||||||
@@ -339,21 +351,41 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
|||||||
return this.supportFiles.map((file) => ({ ...file, data: file.data.slice() }));
|
return this.supportFiles.map((file) => ({ ...file, data: file.data.slice() }));
|
||||||
}
|
}
|
||||||
releaseRetired(): void {
|
releaseRetired(): void {
|
||||||
this.retiredSession?.dispose();
|
const retiredSession = this.retiredSession;
|
||||||
|
const retiredWorkspace = this.retiredWorkspace;
|
||||||
this.retiredSession = null;
|
this.retiredSession = null;
|
||||||
this.retiredWorkspace?.dispose();
|
|
||||||
this.retiredWorkspace = null;
|
this.retiredWorkspace = null;
|
||||||
this.retiredSupportFiles = [];
|
this.retiredSupportFiles = [];
|
||||||
|
try {
|
||||||
|
retiredSession?.dispose();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[MuJoCo] 释放旧仿真会话失败', error);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
retiredWorkspace?.dispose();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[MuJoCo] 释放旧内存工作区失败', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rollbackRetired(): void {
|
rollbackRetired(): void {
|
||||||
this.session?.dispose();
|
const failedSession = this.session;
|
||||||
this.workspace?.dispose();
|
const failedWorkspace = this.workspace;
|
||||||
this.session = this.retiredSession;
|
this.session = this.retiredSession;
|
||||||
this.workspace = this.retiredWorkspace;
|
this.workspace = this.retiredWorkspace;
|
||||||
this.supportFiles = this.retiredSupportFiles;
|
this.supportFiles = this.retiredSupportFiles;
|
||||||
this.retiredSession = null;
|
this.retiredSession = null;
|
||||||
this.retiredWorkspace = null;
|
this.retiredWorkspace = null;
|
||||||
this.retiredSupportFiles = [];
|
this.retiredSupportFiles = [];
|
||||||
|
try {
|
||||||
|
failedSession?.dispose();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[MuJoCo] 释放失败的候选仿真会话失败', error);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
failedWorkspace?.dispose();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[MuJoCo] 释放失败的候选内存工作区失败', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
exportMjcf(): Uint8Array {
|
exportMjcf(): Uint8Array {
|
||||||
if (!this.session || !this.workspace) throw new Error('尚未加载可导出的模型');
|
if (!this.session || !this.workspace) throw new Error('尚未加载可导出的模型');
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Box, Disc3, Info } from 'lucide-react';
|
||||||
|
import type { ModelEntry } from '../project/types';
|
||||||
|
import type { UrdfBaseMode, UrdfLoadMode } from './PhysicsAdapter';
|
||||||
|
import type { ViewerSelection } from '../viewer/MuJoCoViewer';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
CollapsibleSection,
|
||||||
|
CopyButton,
|
||||||
|
PropertyRow,
|
||||||
|
Select,
|
||||||
|
} from '../components/ui';
|
||||||
|
import { ActuatorControl, Check, ControlSlider } from './ActuatorControl';
|
||||||
|
import type {
|
||||||
|
ActuatorParameters,
|
||||||
|
BodyInfo,
|
||||||
|
JointInfo,
|
||||||
|
SimulationSnapshot,
|
||||||
|
} from './SimulationSession';
|
||||||
|
|
||||||
|
const JOINT_TYPE_LABELS: Record<number, string> = {
|
||||||
|
0: 'Free',
|
||||||
|
1: 'Ball',
|
||||||
|
2: 'Slide',
|
||||||
|
3: 'Hinge',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InspectorIdentity({
|
||||||
|
icon,
|
||||||
|
eyebrow,
|
||||||
|
name,
|
||||||
|
meta,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
eyebrow: string;
|
||||||
|
name: string;
|
||||||
|
meta?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header className="flex items-center gap-3 border-b border-border bg-surface/70 px-3 py-3">
|
||||||
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg border border-border-subtle bg-panel-muted text-accent [&>svg]:h-4 [&>svg]:w-4">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[10px] font-medium uppercase tracking-wider text-text-tertiary">
|
||||||
|
{eyebrow}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-sm font-semibold text-text-primary" title={name}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
{meta && <div className="mt-0.5 truncate text-[10px] text-text-tertiary">{meta}</div>}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSummaryInspector({
|
||||||
|
snapshot,
|
||||||
|
selectedFormat,
|
||||||
|
loading,
|
||||||
|
urdfMode,
|
||||||
|
baseMode,
|
||||||
|
showCollision,
|
||||||
|
onUrdfMode,
|
||||||
|
onBaseMode,
|
||||||
|
onShowCollision,
|
||||||
|
}: {
|
||||||
|
snapshot: SimulationSnapshot;
|
||||||
|
selectedFormat?: ModelEntry['format'];
|
||||||
|
loading: boolean;
|
||||||
|
urdfMode: UrdfLoadMode;
|
||||||
|
baseMode: UrdfBaseMode;
|
||||||
|
showCollision: boolean;
|
||||||
|
onUrdfMode: (value: UrdfLoadMode) => void;
|
||||||
|
onBaseMode: (value: UrdfBaseMode) => void;
|
||||||
|
onShowCollision: (value: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InspectorIdentity
|
||||||
|
icon={<Info />}
|
||||||
|
eyebrow="场景"
|
||||||
|
name="未选择对象"
|
||||||
|
meta="从场景大纲或视口选择 Body、Joint 或地图物体"
|
||||||
|
/>
|
||||||
|
<CollapsibleSection
|
||||||
|
title="模型摘要"
|
||||||
|
defaultOpen
|
||||||
|
badge={<Badge>{snapshot.model.nbody} Body</Badge>}
|
||||||
|
>
|
||||||
|
<PropertyRow label="Body" value={snapshot.model.nbody} />
|
||||||
|
<PropertyRow label="Joint" value={snapshot.model.njnt} />
|
||||||
|
<PropertyRow label="Geom" value={snapshot.model.ngeom} />
|
||||||
|
<PropertyRow label="Actuator" value={snapshot.model.nactuator} />
|
||||||
|
<PropertyRow label="qpos / qvel" value={`${snapshot.model.nq} / ${snapshot.model.nv}`} />
|
||||||
|
</CollapsibleSection>
|
||||||
|
{selectedFormat === 'urdf' && (
|
||||||
|
<CollapsibleSection title="URDF 导入设置" defaultOpen>
|
||||||
|
<Select
|
||||||
|
aria-label="URDF 处理方式"
|
||||||
|
className="w-full"
|
||||||
|
value={urdfMode}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={(event) => onUrdfMode(event.target.value as UrdfLoadMode)}
|
||||||
|
>
|
||||||
|
<option value="mjcf">转换为 MJCF(推荐)</option>
|
||||||
|
<option value="native">MuJoCo 原生 URDF</option>
|
||||||
|
</Select>
|
||||||
|
<label className="mt-3 block text-xs text-text-secondary">
|
||||||
|
<span className="mb-1 block">基座类型</span>
|
||||||
|
<Select
|
||||||
|
aria-label="URDF 基座类型"
|
||||||
|
className="w-full"
|
||||||
|
value={baseMode}
|
||||||
|
disabled={loading || urdfMode === 'native'}
|
||||||
|
onChange={(event) => onBaseMode(event.target.value as UrdfBaseMode)}
|
||||||
|
>
|
||||||
|
<option value="floating">浮动基座(Free Joint)</option>
|
||||||
|
<option value="fixed">固定基座(连接世界)</option>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<Check label="显示碰撞几何" checked={showCollision} onChange={onShowCollision} />
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BodyInspector({
|
||||||
|
body,
|
||||||
|
snapshot,
|
||||||
|
viewerSelection,
|
||||||
|
showCollision,
|
||||||
|
onShowCollision,
|
||||||
|
onSelectJoint,
|
||||||
|
}: {
|
||||||
|
body: BodyInfo;
|
||||||
|
snapshot: SimulationSnapshot;
|
||||||
|
viewerSelection: ViewerSelection | null;
|
||||||
|
showCollision: boolean;
|
||||||
|
onShowCollision: (value: boolean) => void;
|
||||||
|
onSelectJoint: (jointId: number, bodyId: number) => void;
|
||||||
|
}) {
|
||||||
|
const parent = snapshot.bodies.find((candidate) => candidate.id === body.parentId);
|
||||||
|
const joints = snapshot.joints.filter((joint) => joint.bodyId === body.id);
|
||||||
|
const children = snapshot.bodies.filter((candidate) => candidate.parentId === body.id);
|
||||||
|
const hit = viewerSelection?.bodyId === body.id ? viewerSelection : null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InspectorIdentity
|
||||||
|
icon={<Box />}
|
||||||
|
eyebrow="Robot / Body"
|
||||||
|
name={body.name}
|
||||||
|
meta={`Body #${body.id}`}
|
||||||
|
/>
|
||||||
|
<CollapsibleSection title="标识与层级" defaultOpen>
|
||||||
|
<PropertyRow
|
||||||
|
label="名称"
|
||||||
|
value={body.name}
|
||||||
|
action={<CopyButton value={body.name} label="复制 Body 名称" />}
|
||||||
|
/>
|
||||||
|
<PropertyRow label="Body ID" value={body.id} />
|
||||||
|
<PropertyRow label="父级" value={parent?.name ?? 'world'} />
|
||||||
|
<PropertyRow label="子 Body" value={children.length} />
|
||||||
|
<PropertyRow label="Joint" value={joints.length} />
|
||||||
|
</CollapsibleSection>
|
||||||
|
{hit && (
|
||||||
|
<CollapsibleSection title="视口命中" defaultOpen>
|
||||||
|
<PropertyRow label="Geom ID" value={hit.geomId} />
|
||||||
|
<PropertyRow label="Geom Type" value={hit.geomType} />
|
||||||
|
<PropertyRow
|
||||||
|
label="世界位置"
|
||||||
|
value={hit.position.map((value) => value.toFixed(3)).join(', ')}
|
||||||
|
action={<CopyButton value={hit.position.join(', ')} label="复制世界位置" />}
|
||||||
|
/>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
<CollapsibleSection title="显示" defaultOpen>
|
||||||
|
<Check label="显示碰撞几何" checked={showCollision} onChange={onShowCollision} />
|
||||||
|
</CollapsibleSection>
|
||||||
|
{joints.length > 0 && (
|
||||||
|
<CollapsibleSection title="所属关节" defaultOpen badge={<Badge>{joints.length}</Badge>}>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{joints.map((joint) => (
|
||||||
|
<button
|
||||||
|
key={joint.id}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 rounded border border-border-subtle px-2 py-1.5 text-left text-xs text-text-secondary hover:border-accent hover:bg-accent-soft"
|
||||||
|
onClick={() => onSelectJoint(joint.id, joint.bodyId)}
|
||||||
|
>
|
||||||
|
<Disc3 className="h-3.5 w-3.5 text-warning" aria-hidden="true" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{joint.name}</span>
|
||||||
|
<span className="text-[9px] text-text-tertiary">
|
||||||
|
{JOINT_TYPE_LABELS[joint.type] ?? joint.type}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
{!hit && (
|
||||||
|
<p className="flex items-center gap-2 p-3 text-[10px] leading-relaxed text-text-tertiary">
|
||||||
|
<Info className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
Body 运行时变换为只读;若要显示命中位置,请在视口中点击该 Body。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jointScale(joint: JointInfo, angleUnit: 'rad' | 'deg'): number {
|
||||||
|
return joint.type === 3 && angleUnit === 'deg' ? 180 / Math.PI : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JointInspector({
|
||||||
|
joint,
|
||||||
|
snapshot,
|
||||||
|
loading,
|
||||||
|
ignoreJointLimits,
|
||||||
|
jointAdvanced,
|
||||||
|
angleUnit,
|
||||||
|
onResetJoints,
|
||||||
|
onToggleJointLimits,
|
||||||
|
onToggleAdvanced,
|
||||||
|
onToggleAngleUnit,
|
||||||
|
onJoint,
|
||||||
|
onActuator,
|
||||||
|
onActuatorParameters,
|
||||||
|
}: {
|
||||||
|
joint: JointInfo;
|
||||||
|
snapshot: SimulationSnapshot;
|
||||||
|
loading: boolean;
|
||||||
|
ignoreJointLimits: boolean;
|
||||||
|
jointAdvanced: boolean;
|
||||||
|
angleUnit: 'rad' | 'deg';
|
||||||
|
onResetJoints: () => void;
|
||||||
|
onToggleJointLimits: () => void;
|
||||||
|
onToggleAdvanced: () => void;
|
||||||
|
onToggleAngleUnit: () => void;
|
||||||
|
onJoint: (id: number, value: number) => void;
|
||||||
|
onActuator: (id: number, value: number) => void;
|
||||||
|
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
|
||||||
|
}) {
|
||||||
|
const body = snapshot.bodies.find((candidate) => candidate.id === joint.bodyId);
|
||||||
|
const actuators = snapshot.actuators.filter((actuator) => actuator.jointId === joint.id);
|
||||||
|
const scale = jointScale(joint, angleUnit);
|
||||||
|
const unit =
|
||||||
|
joint.type === 3 ? (angleUnit === 'deg' ? '°' : ' rad') : joint.type === 2 ? ' m' : '';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InspectorIdentity
|
||||||
|
icon={<Disc3 />}
|
||||||
|
eyebrow="Robot / Joint"
|
||||||
|
name={joint.name}
|
||||||
|
meta={`${JOINT_TYPE_LABELS[joint.type] ?? `Type ${joint.type}`} · ${body?.name ?? `Body ${joint.bodyId}`}`}
|
||||||
|
/>
|
||||||
|
<CollapsibleSection title="关节属性" defaultOpen>
|
||||||
|
<PropertyRow label="Joint ID" value={joint.id} />
|
||||||
|
<PropertyRow label="所属 Body" value={body?.name ?? joint.bodyId} />
|
||||||
|
<PropertyRow label="轴向" value={joint.axis.map((value) => value.toFixed(3)).join(', ')} />
|
||||||
|
<PropertyRow label="限位" value={joint.limited ? '启用' : '未启用'} />
|
||||||
|
<div className="mt-3">
|
||||||
|
<ControlSlider
|
||||||
|
label={`${joint.name}${joint.editable ? '' : '(只读)'}`}
|
||||||
|
value={joint.value * scale}
|
||||||
|
min={joint.min * scale}
|
||||||
|
max={joint.max * scale}
|
||||||
|
unit={unit}
|
||||||
|
advanced={jointAdvanced}
|
||||||
|
limited={joint.limited}
|
||||||
|
limitsIgnored={joint.limitsIgnored}
|
||||||
|
limitMin={joint.limitMin * scale}
|
||||||
|
limitMax={joint.limitMax * scale}
|
||||||
|
disabled={loading || !joint.editable}
|
||||||
|
onChange={(value) => onJoint(joint.id, value / scale)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button disabled={loading} onClick={onResetJoints}>
|
||||||
|
重置关节
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={loading}
|
||||||
|
variant={ignoreJointLimits ? 'primary' : 'secondary'}
|
||||||
|
aria-pressed={ignoreJointLimits}
|
||||||
|
onClick={onToggleJointLimits}
|
||||||
|
>
|
||||||
|
忽略关节限位
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={jointAdvanced ? 'primary' : 'secondary'}
|
||||||
|
aria-pressed={jointAdvanced}
|
||||||
|
onClick={onToggleAdvanced}
|
||||||
|
>
|
||||||
|
高级
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={angleUnit === 'deg' ? 'primary' : 'secondary'}
|
||||||
|
aria-pressed={angleUnit === 'deg'}
|
||||||
|
onClick={onToggleAngleUnit}
|
||||||
|
>
|
||||||
|
{angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection
|
||||||
|
title="关联 Actuator"
|
||||||
|
defaultOpen
|
||||||
|
badge={<Badge>{actuators.length}</Badge>}
|
||||||
|
>
|
||||||
|
{actuators.length ? (
|
||||||
|
actuators.map((actuator) => (
|
||||||
|
<ActuatorControl
|
||||||
|
key={actuator.id}
|
||||||
|
actuator={actuator}
|
||||||
|
onControl={(value) => onActuator(actuator.id, value)}
|
||||||
|
onParameters={(parameters) => onActuatorParameters(actuator.id, parameters)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-text-tertiary">该关节没有关联驱动器</p>
|
||||||
|
)}
|
||||||
|
</CollapsibleSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -928,11 +928,18 @@ export class SimulationSession {
|
|||||||
dispose(): void {
|
dispose(): void {
|
||||||
if (this.disposed) return;
|
if (this.disposed) return;
|
||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
this.removeController();
|
const cleanup = (label: string, action: () => void) => {
|
||||||
this.removeRLPolicy();
|
try {
|
||||||
this.clearExternalForce();
|
action();
|
||||||
this.perturb.delete();
|
} catch (error) {
|
||||||
this.data.delete();
|
console.warn(`[MuJoCo] 释放${label}失败`, error);
|
||||||
this.model.delete();
|
}
|
||||||
|
};
|
||||||
|
cleanup('Python 控制器', () => this.removeController());
|
||||||
|
cleanup('RL 策略', () => this.removeRLPolicy());
|
||||||
|
cleanup('外力状态', () => this.clearExternalForce());
|
||||||
|
cleanup('扰动对象', () => this.perturb.delete());
|
||||||
|
cleanup('仿真数据', () => this.data.delete());
|
||||||
|
cleanup('仿真模型', () => this.model.delete());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-3
@@ -29,6 +29,9 @@
|
|||||||
--ui-success-border: #a7ddca;
|
--ui-success-border: #a7ddca;
|
||||||
--ui-scrollbar: #a9b5c4;
|
--ui-scrollbar: #a9b5c4;
|
||||||
--ui-scrollbar-hover: #7f8ea1;
|
--ui-scrollbar-hover: #7f8ea1;
|
||||||
|
--ui-glass: rgb(255 255 255 / 78%);
|
||||||
|
--ui-glass-highlight: rgb(255 255 255 / 72%);
|
||||||
|
--ui-shadow: rgb(15 23 42 / 18%);
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
.theme-dark {
|
.theme-dark {
|
||||||
@@ -58,6 +61,9 @@
|
|||||||
--ui-success-border: #285f50;
|
--ui-success-border: #285f50;
|
||||||
--ui-scrollbar: #46566c;
|
--ui-scrollbar: #46566c;
|
||||||
--ui-scrollbar-hover: #61728a;
|
--ui-scrollbar-hover: #61728a;
|
||||||
|
--ui-glass: rgb(14 25 39 / 78%);
|
||||||
|
--ui-glass-highlight: rgb(255 255 255 / 7%);
|
||||||
|
--ui-shadow: rgb(0 0 0 / 48%);
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -106,15 +112,91 @@
|
|||||||
.technical-value {
|
.technical-value {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
.workbench-header {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
color-mix(in srgb, var(--ui-panel) 94%, var(--ui-surface)),
|
||||||
|
var(--ui-panel) 42%,
|
||||||
|
color-mix(in srgb, var(--ui-panel) 88%, var(--ui-accent) 12%)
|
||||||
|
);
|
||||||
|
}
|
||||||
.viewport-shell {
|
.viewport-shell {
|
||||||
|
isolation: isolate;
|
||||||
background:
|
background:
|
||||||
radial-gradient(
|
radial-gradient(
|
||||||
circle at 50% 18%,
|
circle at 50% 14%,
|
||||||
color-mix(in srgb, var(--ui-accent) 8%, transparent),
|
color-mix(in srgb, var(--ui-accent) 10%, transparent),
|
||||||
transparent 34%
|
transparent 35%
|
||||||
),
|
),
|
||||||
linear-gradient(145deg, var(--ui-surface), var(--ui-bg));
|
linear-gradient(145deg, var(--ui-surface), var(--ui-bg));
|
||||||
}
|
}
|
||||||
|
.viewport-shell::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(180deg, rgb(255 255 255 / 2%), transparent 22%, rgb(0 0 0 / 7%));
|
||||||
|
}
|
||||||
|
.engineering-glass {
|
||||||
|
border-color: color-mix(in srgb, var(--ui-border-strong) 72%, transparent);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, var(--ui-glass-highlight), transparent 42%), var(--ui-glass);
|
||||||
|
box-shadow:
|
||||||
|
0 16px 44px var(--ui-shadow),
|
||||||
|
inset 0 1px 0 color-mix(in srgb, white 10%, transparent),
|
||||||
|
inset 0 -1px 0 color-mix(in srgb, black 10%, transparent);
|
||||||
|
backdrop-filter: blur(16px) saturate(135%);
|
||||||
|
}
|
||||||
|
.engineering-card {
|
||||||
|
border-color: color-mix(in srgb, var(--ui-border) 88%, transparent);
|
||||||
|
background: linear-gradient(
|
||||||
|
145deg,
|
||||||
|
color-mix(in srgb, var(--ui-surface) 88%, transparent),
|
||||||
|
color-mix(in srgb, var(--ui-panel) 72%, transparent)
|
||||||
|
);
|
||||||
|
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
|
||||||
|
}
|
||||||
|
.tool-active-glow {
|
||||||
|
box-shadow: 0 0 14px color-mix(in srgb, var(--ui-accent) 14%, transparent);
|
||||||
|
}
|
||||||
|
.engineering-badge {
|
||||||
|
background-image: linear-gradient(180deg, rgb(255 255 255 / 5%), transparent);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgb(255 255 255 / 5%),
|
||||||
|
0 3px 10px rgb(0 0 0 / 9%);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
.map-tool-enter {
|
||||||
|
animation: map-tool-enter 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
.draft-dirty-dot {
|
||||||
|
animation: draft-dirty-pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.scrubbable-number {
|
||||||
|
@apply min-w-0 text-[10px] text-text-secondary;
|
||||||
|
}
|
||||||
|
.scrubbable-number-label {
|
||||||
|
@apply mb-1 flex h-4 max-w-full cursor-ew-resize select-none items-center gap-1 border-0 bg-transparent p-0 text-left text-inherit outline-none transition-colors hover:text-accent disabled:cursor-not-allowed disabled:opacity-40;
|
||||||
|
}
|
||||||
|
.scrubbable-number-input {
|
||||||
|
@apply h-7 w-full rounded-md border border-border-strong bg-input px-2 text-xs text-text-primary outline-none transition-[border-color,box-shadow,background-color] hover:border-accent focus:border-accent focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:opacity-40;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.scrubbable-number-active .scrubbable-number-label {
|
||||||
|
@apply text-accent;
|
||||||
|
}
|
||||||
|
.scrubbable-number-active .scrubbable-number-input {
|
||||||
|
border-color: var(--ui-accent);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px color-mix(in srgb, var(--ui-accent) 20%, transparent),
|
||||||
|
0 0 18px color-mix(in srgb, var(--ui-accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
body.is-scrubbing-number,
|
||||||
|
body.is-scrubbing-number * {
|
||||||
|
cursor: ew-resize !important;
|
||||||
|
user-select: none !important;
|
||||||
|
}
|
||||||
.workspace-welcome {
|
.workspace-welcome {
|
||||||
animation: workspace-enter 360ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
animation: workspace-enter 360ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@@ -134,6 +216,25 @@
|
|||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@keyframes map-tool-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -6px) scale(0.985);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes draft-dirty-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--ui-warning) 28%, transparent);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 5px transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
@keyframes workspace-enter {
|
@keyframes workspace-enter {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -1 +1,22 @@
|
|||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => undefined,
|
||||||
|
removeListener: () => undefined,
|
||||||
|
addEventListener: () => undefined,
|
||||||
|
removeEventListener: () => undefined,
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!globalThis.ResizeObserver)
|
||||||
|
globalThis.ResizeObserver = class ResizeObserver {
|
||||||
|
observe(): void {}
|
||||||
|
unobserve(): void {}
|
||||||
|
disconnect(): void {}
|
||||||
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user