feat(web-platform): release V0.8.3 常规界面优化
web-platform-release / Build and publish release (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
web-platform-release / Build and publish release (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
This commit is contained in:
+2
-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/
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mujoco-web-platform",
|
"name": "mujoco-web-platform",
|
||||||
"version": "0.8.2",
|
"version": "0.8.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mujoco-web-platform",
|
"name": "mujoco-web-platform",
|
||||||
"version": "0.8.2",
|
"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",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mujoco-web-platform",
|
"name": "mujoco-web-platform",
|
||||||
"version": "0.8.2",
|
"version": "0.8.3",
|
||||||
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
|
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
# MuJoCo Web 地图导入与编辑设计
|
|
||||||
|
|
||||||
> 状态:V1/V2 与 V3 地图编辑能力已实现。当前阶段只聚焦地图导入、可视化、受约束编辑、事务应用和地图包导出。
|
|
||||||
|
|
||||||
## 1. 目标
|
|
||||||
|
|
||||||
平台在不修改 `@mujoco/mujoco` WASM 内核的前提下,支持:
|
|
||||||
|
|
||||||
1. 内置 MJCF 物理地图:平地、坡道、楼梯和随机障碍物。
|
|
||||||
2. 工程地图包:`map.json`、简化 MJCF 碰撞层、可选自包含 GLB 视觉层和出生点。
|
|
||||||
3. Schema V2 创作层:通过 `map.scene.json` 编辑受支持的静态原语。
|
|
||||||
4. 将完全可识别的只读静态 MJCF 显式转换为可编辑副本。
|
|
||||||
5. 在浏览器中事务式重新编译,并导出独立地图 ZIP。
|
|
||||||
|
|
||||||
当前阶段不扩展机器人运动控制或其他地图上层应用。
|
|
||||||
|
|
||||||
## 2. 坐标与分层
|
|
||||||
|
|
||||||
所有地图统一使用:
|
|
||||||
|
|
||||||
- 米制;
|
|
||||||
- Z-up;
|
|
||||||
- 右手坐标系;
|
|
||||||
- `+X` 前方;
|
|
||||||
- 物理层、视觉层、创作层和出生点共享世界原点。
|
|
||||||
|
|
||||||
地图拆分为三类资产:
|
|
||||||
|
|
||||||
- `physics/world.xml`:MuJoCo 使用的简化静态碰撞层;
|
|
||||||
- `visuals/scene.glb`:Three.js 使用的高精度视觉层;
|
|
||||||
- `authoring/map.scene.json`:编辑器的规范源。
|
|
||||||
|
|
||||||
高面数视觉模型不得直接作为大型地图碰撞网格。
|
|
||||||
|
|
||||||
## 3. 地图包结构
|
|
||||||
|
|
||||||
```text
|
|
||||||
maps/warehouse/
|
|
||||||
├── map.json
|
|
||||||
├── physics/
|
|
||||||
│ ├── world.xml
|
|
||||||
│ └── meshes/*.obj
|
|
||||||
├── visuals/
|
|
||||||
│ └── scene.glb
|
|
||||||
└── authoring/
|
|
||||||
└── map.scene.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Schema V1 支持物理层、视觉层和出生点。Schema V2 增加创作层:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"schemaVersion": 2,
|
|
||||||
"id": "warehouse",
|
|
||||||
"name": "仓库",
|
|
||||||
"coordinateSystem": { "units": "m", "up": "Z", "forward": "+X" },
|
|
||||||
"physics": { "source": "physics/world.xml" },
|
|
||||||
"visual": { "source": "visuals/scene.glb" },
|
|
||||||
"authoring": { "source": "authoring/map.scene.json" },
|
|
||||||
"spawnPoints": [
|
|
||||||
{ "id": "main", "name": "主入口", "position": [0, 0, 0.35], "yawDeg": 0 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
声明 `authoring.source` 时必须同时声明 `physics.source`。
|
|
||||||
|
|
||||||
## 4. 安全导入
|
|
||||||
|
|
||||||
所有地图文件必须进入 `ProjectManifest` 和 `MemfsWorkspace`,禁止运行时直接读取任意主机路径。
|
|
||||||
|
|
||||||
资源引用必须拒绝:
|
|
||||||
|
|
||||||
- HTTP/HTTPS 和其他协议;
|
|
||||||
- 绝对路径、UNC、盘符路径;
|
|
||||||
- NUL、编码绕过和路径穿越;
|
|
||||||
- 不存在的工程资源;
|
|
||||||
- 重复路径及超出导入配额的文件。
|
|
||||||
|
|
||||||
视觉层仅支持自包含 GLB 2.0。暂不支持外部 glTF 依赖、Draco、KTX2 或视觉层隐藏坐标修正。
|
|
||||||
|
|
||||||
## 5. 物理地图约束
|
|
||||||
|
|
||||||
工程物理层只允许静态 `worldbody` 和必要的基础 asset。拒绝:
|
|
||||||
|
|
||||||
- joint 和动态 body;
|
|
||||||
- mocap;
|
|
||||||
- actuator、sensor、tendon、equality;
|
|
||||||
- include;
|
|
||||||
- default class;
|
|
||||||
- 依赖不兼容 compiler 角度语义的姿态;
|
|
||||||
- 无法安全重写的资源路径。
|
|
||||||
|
|
||||||
地图碰撞 geom 使用 `group="2"`,并通过稳定命名空间 `__platform_map_<mapId>_...` 与机器人隔离。
|
|
||||||
|
|
||||||
## 6. 创作文档
|
|
||||||
|
|
||||||
`map.scene.json` 是唯一编辑规范源,支持:
|
|
||||||
|
|
||||||
- box;
|
|
||||||
- cylinder;
|
|
||||||
- capsule;
|
|
||||||
- ramp;
|
|
||||||
- stairs;
|
|
||||||
- 出生点。
|
|
||||||
|
|
||||||
每个对象包含稳定 ID、名称、类型、世界位姿、原语参数、摩擦、颜色和启用状态。对象不超过 2,000 个,出生点不超过 500 个,生成 geom 不超过 10,000 个。
|
|
||||||
|
|
||||||
`MapDocumentCompiler` 根据创作文档确定性生成 `physics/world.xml`。生成结果只使用 quaternion,不依赖 Euler 角。
|
|
||||||
|
|
||||||
## 7. 编辑交互
|
|
||||||
|
|
||||||
地图面板提供统一的“场景 · 资产库”,工程地图源、认证资产和系统程序地形都遵循同一条交互链路:点击或拖放到画布、进入轻量草稿、在场景树选择/删除,最后一次应用。三类卡片共享一种受校验的拖放载荷,画布落点统一按 0.1 m 归一化,任何一种来源的增删都不会隐式触发 MuJoCo 编译。
|
|
||||||
|
|
||||||
地图源与场景实例分离。同一工程地图源可创建多个拥有独立 XY/绕 Z 位姿的引用实例;物理层、视觉层和出生点使用同一个实例变换。编辑 `authoring` 内容属于源编辑,会同步影响引用该 descriptor 的实例,界面必须明确提示这一影响范围,不能伪装成实例级覆盖。
|
|
||||||
|
|
||||||
当前没有可编辑地图时,首个认证资产会创建临时 `map.json`、空物理层和创作层骨架,切换到新场景并作为未应用草稿进入 Three.js 预览;此阶段不调用 MuJoCo。“放弃场景更改”必须同时撤销临时实例、创作草稿和这三份临时工程文件,下一次创建可以复用同一场景 ID。用户点击“应用并重新编译”后才生成并事务提交实际物理层。
|
|
||||||
|
|
||||||
React 中的 `MapEditSession` 是草稿唯一来源。Three.js 预览层只负责:
|
|
||||||
|
|
||||||
- 草稿原语显示;
|
|
||||||
- 射线拾取和高亮;
|
|
||||||
- 世界坐标平移;
|
|
||||||
- 绕世界 Z 轴旋转;
|
|
||||||
- 原语尺寸缩放;
|
|
||||||
- 出生点预览;
|
|
||||||
- 操纵器和临时资源生命周期。
|
|
||||||
|
|
||||||
交互规则:
|
|
||||||
|
|
||||||
- `W`:移动;
|
|
||||||
- `E`:旋转;
|
|
||||||
- `S`:缩放;
|
|
||||||
- `Delete`/`Backspace`:删除;
|
|
||||||
- `Escape`:取消选择;
|
|
||||||
- `Ctrl+Z`:撤销;
|
|
||||||
- `Ctrl+Y` 或 `Ctrl+Shift+Z`:重做。
|
|
||||||
|
|
||||||
拖动 TransformControls 操纵轴期间禁用 OrbitControls;在画布空白区域按住鼠标左键仍可旋转相机。连续拖动只在 `mouseUp` 时提交一次历史记录。缩放必须写回原语参数,不能把 Three.js 节点 scale 作为持久数据。`locked` 的准确语义是“锁定位姿”:禁止位置、旋转和视口变换,但仍允许编辑名称、尺寸、摩擦和颜色。
|
|
||||||
|
|
||||||
“自动落位(重力)”通过统一地图表面查询器沿世界 `-Z` 求最高承载面。查询器与物理合成共用确定性程序几何,支持有限平地、坡道、楼梯、障碍物、坑/沟壑和高度场,并应用地图实例变换;因此认证资产可正确落到系统程序地形上,而不是始终假设 `z=0`。
|
|
||||||
|
|
||||||
## 8. 只读地图转换
|
|
||||||
|
|
||||||
外部 MJCF 默认只读。只有完全可逆的静态原语地图可显式创建可编辑副本。
|
|
||||||
|
|
||||||
允许转换:
|
|
||||||
|
|
||||||
- box;
|
|
||||||
- cylinder;
|
|
||||||
- capsule;
|
|
||||||
- 由嵌套静态 body 组成的世界位姿;
|
|
||||||
- pos、quat、size、friction、rgba 和出生点。
|
|
||||||
|
|
||||||
遇到下列内容必须整体拒绝,禁止静默丢弃:
|
|
||||||
|
|
||||||
- plane、mesh、heightfield;
|
|
||||||
- asset、材质和碰撞过滤扩展;
|
|
||||||
- joint、site、light 或其他未知结构;
|
|
||||||
- euler、axisangle、xyaxes、zaxis、fromto;
|
|
||||||
- 不能确认无损的 compiler 配置。
|
|
||||||
|
|
||||||
## 9. 事务边界
|
|
||||||
|
|
||||||
地图实例增删、工程地图实例变换、程序地图参数和认证资产源编辑共享同一个应用基线。普通模型入口切换或 URDF 模式重载只能使用上一次成功应用的地图基线,不能静默提交当前场景草稿。应用编辑草稿或转换地图时,顺序固定为:
|
|
||||||
|
|
||||||
1. 生成候选创作层;
|
|
||||||
2. 确定性生成候选物理层;
|
|
||||||
3. 创建候选 manifest;
|
|
||||||
4. 在新的 MEMFS 工作区编译 MuJoCo;
|
|
||||||
5. Viewer 成功 attach 新会话;
|
|
||||||
6. 最后提交 manifest、地图选择和编辑状态;
|
|
||||||
7. 推进地图实例与创作文档的统一应用基线;
|
|
||||||
8. 释放旧会话和旧工作区。
|
|
||||||
|
|
||||||
任一步失败都必须恢复旧仿真和旧工程状态,同时保留完整用户草稿。编译期间禁用所有草稿写入口;提交完成时只清除本次提交的文档版本,不能丢掉晚到修改。系统程序地形会替换无限地面 plane,避免其填平沟壑、深坑和高度场负高度。
|
|
||||||
|
|
||||||
## 10. 导出
|
|
||||||
|
|
||||||
浏览器导入文件不保证可写,因此不直接覆盖源目录。地图通过 ZIP 导出,包含:
|
|
||||||
|
|
||||||
- `map.json`;
|
|
||||||
- 物理层及其显式 asset;
|
|
||||||
- 可选视觉层;
|
|
||||||
- 可选创作层。
|
|
||||||
|
|
||||||
ZIP 内路径保持工程相对结构,并继续执行路径安全校验。
|
|
||||||
|
|
||||||
## 11. 验证重点
|
|
||||||
|
|
||||||
- Schema V1/V2 兼容性;
|
|
||||||
- 路径穿越和协议绕过拒绝;
|
|
||||||
- 地图物理合成和命名空间隔离;
|
|
||||||
- GLB 自包含校验及资源释放;
|
|
||||||
- 编辑文档严格校验;
|
|
||||||
- 确定性 MJCF 输出;
|
|
||||||
- Undo/Redo 和视口变换写回;
|
|
||||||
- 只读转换的白名单与整体拒绝;
|
|
||||||
- 编译或 Viewer attach 失败后的事务回滚;
|
|
||||||
- 地图 ZIP 资产完整性;
|
|
||||||
- 三类地图来源的统一拖放、场景树和单次提交边界;
|
|
||||||
- 普通模型重载不提交场景草稿,应用失败后草稿仍可放弃或重试;
|
|
||||||
- 工程地图实例物理/视觉/出生点变换一致;
|
|
||||||
- 首个认证资产临时场景的完整放弃;
|
|
||||||
- 认证资产在程序地形上的重力落位,以及坑洞不被无限平面填平。
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
# 奖励函数自调参 Agent 实施计划
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
当前项目已有完整的本地训练链路:React/Vite 前端通过 `LocalTrainingClient` 调用仅监听 loopback 的 Python `training_server`,服务再启动内置 `training_server/rl` 中的 Go2 + mjlab/RSL-RL 训练器,并产出 `policy.onnx`。现有请求只包含环境数、迭代数、随机种子、设备和 W&B 模式;奖励项与权重固定在任务配置中,服务主要从标准输出解析迭代进度,尚未向前端提供结构化奖励曲线或自动调参循环。
|
|
||||||
|
|
||||||
第一版范围已确定为 `Unitree-Go2-Flat`,优化优先级依次为:速度跟踪、动作平滑、姿态稳定、减少跌倒、足端滑移、能耗。Agent 可以调整现有奖励权重、启停白名单奖励项以及修改获准的阈值/核宽等参数,但不能生成或执行任意 Python 奖励代码。系统同时提供全自动与逐轮审批模式,在本地 RTX 5080 上串行训练,通过云端 API 调用 Agent;独立打开一个参考 Isaac/TensorBoard 交互方式的监控网页,展示曲线、trial 对比、参数 diff 与最佳策略。
|
|
||||||
|
|
||||||
目标是在现有安全边界内增加一个可审计、可暂停、可恢复、可回退的调参闭环。云端只接收裁剪后的数值配置、曲线摘要和评估指标,不接收机器人资产、checkpoint、源代码、训练服务访问令牌或本地路径。已确认使用 DeepSeek 官方 OpenAI-compatible API(Base URL `https://api.deepseek.com`)和精确模型标识 `deepseek-v4-flash`;API key 仅从训练服务环境变量读取。目标占比采用 `35/20/15/15/10/5`,逐轮审批时基线自动运行、之后每个建议等待批准。最佳结果保存为命名 preset 并可用于后续训练,不覆盖仓库内 Python 默认值。
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
采用“确定性试验编排 + 独立评估 + 云端 Agent 建议”的分层方案,而不是让 LLM 直接改源码或决定 trial 是否有效:
|
|
||||||
|
|
||||||
1. **白名单参数空间**:为每个 reward term 定义固定符号、默认值、上下界、是否允许置零及单轮最大变化;开放权重,以及 `std`、`command_threshold`、`target_height`、步态 `period/threshold`、姿态分段容差等少量参数。保留当前 15 个奖励项,并新增默认关闭(权重为 0)的 `electrical_power`,以覆盖能耗目标。`track_linear_velocity`、`track_angular_velocity`、`body_orientation_l2`、`is_terminated`、`joint_pos_limits`、`action_rate_l2` 不允许关闭;其余白名单项可置零。每个 proposal 最多改 4 个标量,非零权重幅值单轮限制在前值的 `0.5×–2×`(同时受绝对上下界约束),符号不可翻转,Go2 trot 的足序 offset 不开放。Agent 只能返回结构化 patch,服务端合并并二次校验。
|
|
||||||
2. **权重无关的质量指标**:现有 `Episode_Reward/*` 已由 mjlab `RewardManager` 自动记录,但它随权重变化,不能直接作为优化目标。新增固定定义的速度误差、动作加速度、姿态误差、跌倒率、接触足滑移速度和正向机械功率指标;训练曲线用于诊断,最终排名采用固定命令集与固定评估种子得到的这些指标,避免通过放大奖励权重“刷高总奖励”。
|
|
||||||
3. **指标与产物管线**:调参 trial 强制使用本地 TensorBoard writer;训练脚本接受服务端生成的明确输出目录与奖励配置文件,保存 `env.yaml`、Agent patch、checkpoint、ONNX 和评估结果。服务通过 TensorBoard EventAccumulator 增量读取 scalar 并写入 SQLite,曲线 API 按 LTTB/桶聚合降采样;不依赖脆弱的控制台正则解析指标。
|
|
||||||
4. **独立评估与评分**:增加无探索噪声的评估入口,使用同一组站立、前进/侧移、转向和组合速度命令,对每个 rung 的 checkpoint 运行相同场景。速度目标内部按线速度/角速度误差 `80/20` 合并;六个顶层目标按已确认的 `35% / 20% / 15% / 15% / 10% / 5%` 聚合。对所有“越低越好”的原始指标使用创建 session 时冻结的基线尺度 `scale=max(abs(baseline), physical_floor)`,计算并裁剪相对改善 `(baseline-current)/scale`;跌倒率不得高于基线 `+2%`,速度误差不得恶化超过 `5%`,否则该 trial 不可晋级。输出原始指标、各目标改善、总分和 3-seed 均值/离散度,确保后续新增 trial 不会改变旧 trial 的分数。
|
|
||||||
5. **DeepSeek Agent 与数值搜索协作**:采用 PydanticAI 的 OpenAI-compatible provider 连接 `deepseek-v4-flash`,以严格类型的 `RewardProposal` 返回最多 4 项参数 patch、依据、预期影响和置信度;优先使用模型 JSON/structured-output 能力,能力探测失败时退回 PydanticAI 的 prompted JSON + 本地 Pydantic 校验,不授予 Agent 任何 shell、文件或网络工具。Optuna study 记录完整参数/分数并执行 successive-halving;Agent proposal 作为 enqueue/fixed trial 进入 study,只有显式启用 fallback 时才由 Optuna sampler 代提候选。模型使用低温度、60 秒超时和最多 2 次结构化修复;每次仅发送最多 12 个 trial 摘要、每条曲线最多 64 个降采样点,并记录脱敏 prompt hash、模型名、token usage、批准操作与最终 patch。服务对输出执行有限数值、符号、边界、最大步长、重复配置和高风险组合校验,校验失败要求 Agent 修正,不能静默执行。
|
|
||||||
6. **session 状态机**:基线自动运行 → 短预算 trial → 固定评估 → Agent 建议 →(自动批准或进入 `awaiting_approval`)→ 下一 trial → successive-halving 晋级 → 最佳配置复核。逐轮审批支持接受、拒绝并附反馈、手动修改后接受;暂停不杀死已完成数据,停止会终止当前进程组。完成后把最佳 reward patch 保存为不可变命名 preset,并提供“从 preset 新建普通训练/新 tuning session”、导出 JSON、下载/导入 ONNX;不写回 `velocity_env_cfg.py`。
|
|
||||||
7. **默认 RTX 5080 预算**:12 个唯一配置(含基线)、4096 个并行环境、GPU `0`;所有配置先训练 300 iterations,前 4 名从自身 checkpoint 续训到 900,前 2 名续训到 2000,总量约等于 4.1 次完整 2000-iteration 训练。连续 4 个建议无显著提升时提前停止;trial 数、环境数和各 rung 可在 4–20 / 合法服务范围内调整。搜索期固定训练 seed 控制方差,晋级候选使用 3 个固定评估 seed 复核。
|
|
||||||
8. **独立监控网页**:新增 Vite 多页面入口 `tuning.html`,从现有训练面板用新标签页打开。页面采用 TensorBoard 风格的 run 选择、平滑、缩放、悬浮值、标签过滤和多 trial 叠加图,并增加 Agent 决策时间线、API 连通性测试、审批卡片、参数 diff、排行榜、暂停/恢复/停止、preset 导出及最佳 ONNX 下载/导入。新标签页 URL 只携带 session ID;训练服务 token 通过同源、校验 origin 的一次性 `postMessage` 交接并仅存于新标签页 `sessionStorage`,失败时回退到手工输入,绝不放入 query/hash。“导入最佳策略”由 dashboard 向仍打开的 workbench 发送同源消息,workbench 使用自身 client 下载并调用现有 `onPolicyReady`;无 opener 时回退为文件下载。图表使用轻量 `uPlot`,不启动或 iframe 嵌入第二个 TensorBoard 服务。
|
|
||||||
9. **API 与落盘边界**:新增 capabilities/Agent 测试、session 创建与详情、trial/metrics 查询、proposal 批准/拒绝、pause/resume/cancel、preset 列表/导出和最佳 artifact 下载接口;继续沿用现有 Bearer Token、Host/Origin 校验与 32 KiB 请求限制。SQLite 使用 WAL 和每线程连接,默认位于已忽略的 `training_server/rl/logs/auto_tuning/tuning.sqlite3`,trial 产物位于同目录的 session 子目录;API 永远只接受 ID,不接受客户端文件路径。启动时把遗留 `training/evaluating` 状态标记为 `interrupted`,从最后完整 checkpoint 显式恢复,不尝试盲目重连旧 PID。
|
|
||||||
10. **DeepSeek 配置**:使用 `DEEPSEEK_API_KEY`、`MUJOCO_TUNING_AGENT_BASE_URL`(默认 `https://api.deepseek.com`)和 `MUJOCO_TUNING_AGENT_MODEL`(默认 `deepseek-v4-flash`);可配置 timeout,但 API key 不提供 CLI 参数,避免进入 shell history。健康接口仅返回 `configured/model/baseUrl`,连接测试返回能力与脱敏错误,不返回 key 或完整供应商响应。未配置云端 key 时普通训练保持可用,tuning capability 明确显示不可用;自动模式不静默退回 Optuna,只有用户在 session 中显式勾选 fallback 才允许。
|
|
||||||
|
|
||||||
初始权重白名单如下;负项只能保持负号,正项只能保持正号。绝对边界与单轮 `0.5×–2×` 限制同时生效,实际启用前用基线 smoke test 校验量纲:
|
|
||||||
|
|
||||||
| Reward term | 当前值 | 允许范围 | 可关闭 |
|
|
||||||
| --- | ---: | ---: | :---: |
|
|
||||||
| `track_linear_velocity` | 1.0 | 0.5–3.0 | 否 |
|
|
||||||
| `track_angular_velocity` | 1.0 | 0.25–2.0 | 否 |
|
|
||||||
| `body_orientation_l2` | -1.0 | -3.0–-0.1 | 否 |
|
|
||||||
| `pose` | 1.0 | 0–2.5 | 是 |
|
|
||||||
| `body_ang_vel` | -0.05 | -0.2–0 | 是 |
|
|
||||||
| `angular_momentum` | -0.025 | -0.1–0 | 是 |
|
|
||||||
| `is_terminated` | -200 | -400–-50 | 否 |
|
|
||||||
| `joint_acc_l2` | -2.5e-7 | -2e-6–0 | 是 |
|
|
||||||
| `joint_pos_limits` | -10 | -30–-2 | 否 |
|
|
||||||
| `action_rate_l2` | -0.05 | -0.2–-0.005 | 否 |
|
|
||||||
| `foot_gait` | 0.5 | 0–1.5 | 是 |
|
|
||||||
| `foot_clearance` | -1.0 | -3.0–0 | 是 |
|
|
||||||
| `foot_slip` | -0.25 | -1.0–0 | 是 |
|
|
||||||
| `soft_landing` | -1e-3 | -5e-3–0 | 是 |
|
|
||||||
| `stand_still` | -1.0 | -3.0–0 | 是 |
|
|
||||||
| `electrical_power`(新增) | 0 | -5e-3–0 | 是 |
|
|
||||||
|
|
||||||
参数白名单限制为:线速度 `std=0.25–1.0`、角速度 `std=0.35–1.2`;`pose` 三档 std 使用当前 Go2 数组的 `0.5×–2×` 缩放因子,walking/running threshold 分别为 `0.05–0.5` / `1.0–2.5` 且保持有序;`foot_gait.period=0.4–0.8`、`threshold=0.45–0.65`;`foot_clearance.target_height=0.06–0.16`;各运动相关 `command_threshold=0.02–0.30`。结构对象、函数名、传感器名、asset selector、步态 offset 和终止角度不开放。
|
|
||||||
|
|
||||||
## Files to modify
|
|
||||||
|
|
||||||
关键修改与新增路径:
|
|
||||||
|
|
||||||
- `training_server/rl/src/tasks/velocity/velocity_env_cfg.py`:挂载固定质量指标与可选能耗奖励项。
|
|
||||||
- `training_server/rl/src/tasks/velocity/config/go2/env_cfgs.py`:Go2 参数默认值及白名单配置应用入口。
|
|
||||||
- `training_server/rl/src/tasks/velocity/mdp/metrics.py`(新增)与 `mdp/__init__.py`:权重无关的六类质量指标。
|
|
||||||
- `training_server/rl/src/tasks/velocity/mdp/rewards.py`:仅在现有函数无法覆盖白名单参数时补充实现;优先复用 mjlab 内置项。
|
|
||||||
- `training_server/rl/scripts/train.py`:显式 run 目录、奖励 patch、checkpoint 续训和 TensorBoard 配置。
|
|
||||||
- `training_server/rl/scripts/evaluate.py`(新增):固定命令/seed 的 checkpoint 评估与 JSON 结果。
|
|
||||||
- `training_server/rl/src/tasks/velocity/rl/runner.py`:保持 checkpoint/ONNX 对应关系,必要时暴露最终 checkpoint 元数据。
|
|
||||||
- `training_server/tuning/`(新增):schema/目标函数、SQLite storage、TensorBoard ingest、PydanticAI advisor、Optuna sampler 与 session orchestrator,避免继续膨胀单文件服务。
|
|
||||||
- `training_server/server.py`:组合现有训练 manager 与 tuning manager,增加 `/api/tuning/*` 路由和启动配置。
|
|
||||||
- `training_server/tests/test_server.py` 及新增 `training_server/tests/test_tuning_*.py`:API、状态机、存储、Agent 校验与 fake trainer 集成测试。
|
|
||||||
- `training_server/requirements.txt`(新增)与 `requirements-dev.txt`:加入并固定经 Python 3.12 实测的 `pydantic-ai-slim[openai]`、Optuna 和 TensorBoard;保留 `training_server/rl/requirements.txt` 只承载 mjlab 训练栈,避免职责混杂。
|
|
||||||
- `web_platform/tuning.html`、`web_platform/src/tuning/`(新增):独立监控入口、dashboard、uPlot 曲线、审批/参数 diff/排行榜。
|
|
||||||
- `web_platform/src/training/types.ts` 与 `LocalTrainingClient.ts`:调参 API 类型与方法;必要时按职责拆出 `TuningClient.ts`。
|
|
||||||
- `web_platform/src/training/LocalTrainingPanel.tsx`:创建 session 的基础入口及“在新网页打开”按钮。
|
|
||||||
- `web_platform/vite.config.ts`:多页面构建入口;`package.json` / lockfile 增加 `uplot`。
|
|
||||||
- `.gitignore`、`README.md`、`training_server/README.md`、`web_platform/README.md`:忽略本地状态/产物并记录安装、安全和使用流程。
|
|
||||||
|
|
||||||
## Reuse
|
|
||||||
|
|
||||||
- 复用 `training_server/server.py` 的 Bearer Token、loopback/CORS 限制、无 shell 参数数组、训练任务互斥、进程组取消和 ONNX 下载机制;tuning 与普通训练共享同一个 GPU 活动锁。
|
|
||||||
- 复用 `training_server/rl/scripts/train.py` 的 tyro/dataclass 配置和现有 checkpoint resume 流程,新增 patch 应用层而不是改写源文件。
|
|
||||||
- 复用 mjlab `RewardManager` 自动产生的 `Episode_Reward/<term>`、`MetricsManager` 的 `Episode_Metrics/<term>`、`TerminationManager` 的 `Episode_Termination/<term>`,以及 RSL-RL TensorBoard scalar writer。
|
|
||||||
- 复用当前 `mean_action_acc`、`feet_slip`、`body_orientation_l2` 等计算;能耗优先复用 mjlab 的 `electrical_power_cost`,不重复实现扭矩功率算法。
|
|
||||||
- 复用 `VelocityOnPolicyRunner.save()` 的 checkpoint + `policy.onnx` 同步导出和元数据附加;晋级 trial 从自己的 checkpoint 恢复。
|
|
||||||
- 参考已安装 mjlab 的 `scripts/play.py` / tracking evaluate 结构实现仓库内最小速度任务评估入口。
|
|
||||||
- 复用 `LocalTrainingClient` 的鉴权/错误处理、`LocalTrainingPanel` 的连接信息与取消/策略下载交互,以及共享 `Button`、`Badge`、`Tabs`、`ProgressBar` 等 UI。
|
|
||||||
- 当前前端没有路由器或图表库,故使用 Vite MPA 而非引入整套路由;图表仅新增面向大量 scalar 的 `uPlot`。
|
|
||||||
|
|
||||||
## Steps
|
|
||||||
|
|
||||||
- [x] 固化 `Unitree-Go2-Flat` 的 15 个现有 reward term schema:当前值、符号、上下界、启停规则、可调参数、每次最多 4 项、`0.5×–2×` 变化率和跨参数约束,并为能耗加入默认关闭项。
|
|
||||||
- [x] 增加权重无关的六类训练/评估指标,定义归一化方向、目标阈值、默认 `35/20/15/15/10/5` 聚合权重、失败/NaN/过早跌倒惩罚。
|
|
||||||
- [x] 扩展训练入口以接收服务生成的 patch 文件与明确 run 目录,支持同配置 checkpoint 晋级续训,并强制保存完整配置快照。
|
|
||||||
- [x] 实现固定命令集、固定 seed、无探索噪声的独立评估入口,产生可验证 JSON 与 TensorBoard scalars。
|
|
||||||
- [x] 实现 TensorBoard scalar 增量采集、SQLite schema/migration、曲线降采样及 session/trial/proposal/audit 持久化。
|
|
||||||
- [x] 实现 tuning orchestrator、successive-halving、共享 GPU 互斥、提前停止、崩溃恢复、暂停/取消和最佳产物选择。
|
|
||||||
- [x] 通过 PydanticAI OpenAI-compatible provider 接入官方 `deepseek-v4-flash` 和 Optuna study,实现环境变量密钥、连接/能力测试、脱敏上下文、结构化输出、重试/修复、超时、usage 审计与显式 Agent 不可用状态。
|
|
||||||
- [x] 实现自动批准与 `awaiting_approval` 两条状态路径,包括接受、拒绝反馈、手动修订后接受及完整审计记录。
|
|
||||||
- [x] 扩展 `/api/tuning/*`、健康信息、指标查询和最佳 ONNX 下载,并补齐 TypeScript 类型/客户端。
|
|
||||||
- [x] 新增 `tuning.html` TensorBoard 风格 dashboard 与现有面板的新标签页入口,完成曲线、筛选/平滑/缩放、trial 对比、Agent 时间线、审批、diff、排行榜和控制操作。
|
|
||||||
- [x] 添加 Python/TypeScript/组件/E2E 测试,更新依赖锁、忽略规则、安全说明和安装/使用文档。
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- 单元测试:参数 schema 与边界、目标分数、建议约束、状态转换、失败/取消/恢复、最佳 trial 选择、API 鉴权。
|
|
||||||
- 集成测试:使用轻量 fake trainer 产生确定性 TensorBoard events/评估 JSON,并用 fake PydanticAI model 返回合法、越界、重复和畸形 proposal,完整跑通多 trial 调参而不依赖 GPU 或真实云端 API。
|
|
||||||
- 前端测试:表单校验、轮询、曲线渲染、参数 diff、自动/审批分支、跨标签页无 URL 密钥交接、停止、preset 与最佳结果操作。
|
|
||||||
- 真实训练 smoke test:在本地 RTX 5080 / GPU 0 上先用 256–512 environments、10–20 iterations 跑基线与 2 个 trial,验证 TensorBoard 指标、评估 JSON、checkpoint、ONNX、参数快照和数据库记录一一对应;再单独确认 4096 environments 不 OOM 后启用默认预算。
|
|
||||||
- 确定性检查:相同 checkpoint + 相同命令/seed 的评估分数在容差内一致;改变 reward 权重不会直接改变固定质量指标的定义或归一化。
|
|
||||||
- 恢复检查:分别在训练、评估、等待审批时重启服务;确认 session 从 SQLite 恢复且不会重复启动 trial,活动子进程能被停止。
|
|
||||||
- 回归:`npm run typecheck`、`npm run lint`、`npm run test`、`npm run test:training-server`、`npm run build`、`npm run test:e2e`;具备依赖时执行 `npm run lint:python`。
|
|
||||||
- 安全检查:非法奖励名、符号翻转、越界/NaN/Inf、超大 patch、路径注入、重复 proposal、并发普通训练/session、伪造 artifact 路径和 Agent 超时均被拒绝或进入明确状态;API key 不出现在响应、日志、SQLite 或浏览器存储中。
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
# 参考 URDF-Studio 补全前端组件计划
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
目标是在不改变现有 MuJoCo 导入、编译、仿真、渲染和资源生命周期逻辑的前提下,参考本机 `/home/cen/Embodied_Workspace/Mujoco_Projects/URDF-Studio` 的前端信息架构与视觉样式,逐项补全 `wasm/web_platform` 缺少的前端组件。
|
|
||||||
|
|
||||||
关键约束:
|
|
||||||
|
|
||||||
- 只借鉴前端布局、视觉语言与交互组织,不直接迁移 URDF-Studio 的机器人解析、编辑或 Three.js 运行时。
|
|
||||||
- 新组件接入前必须先向用户展示候选清单、用途、影响范围并逐项获得确认。
|
|
||||||
- 第一批 A–H 已按批准计划实施;后续新增组件仍遵循先确认再接入。
|
|
||||||
|
|
||||||
初步现状:
|
|
||||||
|
|
||||||
- 本项目 UI 高度集中在 `wasm/web_platform/src/app/App.tsx`,已有顶部工具栏、工程/模型结构左栏、三维视口、模型控制右栏、底部性能状态栏、入口选择、加载态和诊断卡片。
|
|
||||||
- MuJoCo 与 Three.js 对象由 `MainThreadPhysicsAdapter`、`MuJoCoViewer` 和 React refs 持有;Zustand 仅保存 UI 可消费快照。后续 UI 重构应保持这一边界。
|
|
||||||
- URDF-Studio 已将 Header、WorkspaceSidebars、Viewer overlays、通用 UI 控件、拖动窗口、设置弹窗等拆成独立组件,可作为组件边界和样式参考。
|
|
||||||
- 当前资源树和模型结构树已具备纯数据构建函数及单测,不应重写;只需替换字符图标、颜色和行样式,并保留原有树语义。
|
|
||||||
- 当前没有 `App.tsx` 级组件测试;后续应通过抽出无状态壳层组件来增加 UI 测试,避免在测试中初始化 WebGL/WASM。
|
|
||||||
|
|
||||||
已确认产品方向:
|
|
||||||
|
|
||||||
- 第一批聚焦工作台外壳:顶部工具栏、可折叠侧栏、视口工具、底部状态栏、通知与弹窗。
|
|
||||||
- 只采用 URDF-Studio 的专业工作台风格,保留 MuJoCo 平台品牌、中文信息架构和现有布局特色,不做像素级复刻。
|
|
||||||
- 允许新增 `lucide-react`,用于替代当前字符/Emoji 图标并统一视觉语言。
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
1. 对照两个项目的页面壳层、工具栏、左右侧栏、视口叠层、弹窗/通知和基础控件,形成“已有 / 缺少 / 不适用”的组件差异表。
|
|
||||||
2. 将候选组件按纯展示、现有动作封装、需要新增产品能力三类分级;第一目标只推荐前两类,避免触碰底层 MuJoCo 逻辑。
|
|
||||||
3. 第一批 A–H 已全部获得用户确认;后续若发现需要新增候选组件,必须再次确认,不能顺带加入。
|
|
||||||
4. 先建立精简语义样式令牌和无状态基础组件,再拆分 `App.tsx` 的布局组件;业务回调继续由 `App` 注入,adapter/viewer 生命周期不下沉到展示组件。
|
|
||||||
5. 第一批布局采用 40px 紧凑 Header、现有左右固定侧栏和 28px 状态栏;交互模式工具组停靠在 Header 中央。右栏默认展开“模型信息、当前选择、关节”,折叠其他低频分区。
|
|
||||||
6. 不照搬 URDF-Studio 的移动端底部工具条、复杂响应式菜单或 Floating UI。保持当前 `min-w-[1024px]` 桌面产品边界。
|
|
||||||
7. 每批只接入少量组件并执行回归验证,确保模型导入、仿真和三维交互行为不变。
|
|
||||||
|
|
||||||
## Files to modify
|
|
||||||
|
|
||||||
- `wasm/web_platform/src/app/App.tsx`:仅保留 viewer/adapter 生命周期、业务状态和事件编排,以 props 连接新 UI 组件。
|
|
||||||
- `wasm/web_platform/src/app/components/WorkbenchHeader.tsx`:品牌区、文件导入、仿真传输控制、速度、面板/主题动作与中央工具栏插槽。
|
|
||||||
- `wasm/web_platform/src/app/components/ViewerToolDock.tsx`:select/joint/force 模式与相机复位的图标工具组。
|
|
||||||
- `wasm/web_platform/src/app/components/SidebarPanel.tsx`:左右侧栏壳层、标题、折叠分区和滚动区域。
|
|
||||||
- `wasm/web_platform/src/app/components/StatusBar.tsx`:性能、WASM 和快捷键状态。
|
|
||||||
- `wasm/web_platform/src/app/components/EntrySelectionDialog.tsx`:多入口选择的领域弹窗。
|
|
||||||
- `wasm/web_platform/src/app/components/DiagnosticNotice.tsx`:诊断摘要、路径和可展开详情。
|
|
||||||
- `wasm/web_platform/src/app/components/WorkspaceOverlays.tsx`:加载 HUD、空工作区和中心叠层组织。
|
|
||||||
- `wasm/web_platform/src/components/ui/Button.tsx`、`IconButton.tsx`、`Tooltip.tsx`、`Select.tsx`、`Dialog.tsx`、`CollapsibleSection.tsx`、`ToolbarToggleGroup.tsx`:第一批基础控件。
|
|
||||||
- `wasm/web_platform/src/components/ui/index.ts`:稳定导出边界。
|
|
||||||
- `wasm/web_platform/src/project/ProjectTree.tsx`、`ModelStructureTree.tsx`:仅做令牌化视觉和 Lucide 图标替换,保留构树逻辑及公开 props。
|
|
||||||
- `wasm/web_platform/src/app/ErrorBoundary.tsx`:使用新基础控件和语义样式,不改错误边界行为。
|
|
||||||
- `wasm/web_platform/src/styles.css`、`wasm/web_platform/tailwind.config.cjs`:颜色、表面、文字、强调色、滚动条、焦点环和组件令牌;移除依赖深色工具类覆盖的主题实现。
|
|
||||||
- `wasm/web_platform/src/app/components/*.test.tsx`、`wasm/web_platform/src/components/ui/*.test.tsx`:新增组件测试。
|
|
||||||
- `wasm/web_platform/e2e/app.spec.ts`:保持业务断言并更新因图标化/折叠带来的交互定位。
|
|
||||||
- `wasm/package.json` / `wasm/package-lock.json`:加入已确认的 `lucide-react`;不引入 `@floating-ui/react`。
|
|
||||||
|
|
||||||
## Reuse
|
|
||||||
|
|
||||||
本项目继续复用:
|
|
||||||
|
|
||||||
- `wasm/web_platform/src/app/App.tsx` 中的导入、加载、播放/暂停、单步、重置、模式切换及面板状态回调。
|
|
||||||
- `wasm/web_platform/src/stores/useAppStore.ts` 的工程、快照、选择、性能和诊断状态。
|
|
||||||
- `wasm/web_platform/src/project/ProjectTree.tsx` 与 `ModelStructureTree.tsx`。
|
|
||||||
- `wasm/web_platform/src/simulation/PhysicsAdapter.ts` 和 `SimulationSession.ts`(保持不改或仅由现有接口调用)。
|
|
||||||
- `wasm/web_platform/src/viewer/MuJoCoViewer.ts`(保持渲染和交互接口不变)。
|
|
||||||
|
|
||||||
样式/结构参考(只读来源):
|
|
||||||
|
|
||||||
- `/home/cen/Embodied_Workspace/Mujoco_Projects/URDF-Studio/src/app/components/Header.tsx`
|
|
||||||
- `/home/cen/Embodied_Workspace/Mujoco_Projects/URDF-Studio/src/app/components/workspace/WorkspaceSidebars.tsx`
|
|
||||||
- `/home/cen/Embodied_Workspace/Mujoco_Projects/URDF-Studio/src/app/components/AppLayoutView.tsx`
|
|
||||||
- `/home/cen/Embodied_Workspace/Mujoco_Projects/URDF-Studio/src/shared/components/ui/`
|
|
||||||
- `/home/cen/Embodied_Workspace/Mujoco_Projects/URDF-Studio/src/shared/components/Panel/OptionsPanel.tsx`:参考可折叠分区及紧凑面板标题,不移植拖动/悬浮窗逻辑。
|
|
||||||
- `/home/cen/Embodied_Workspace/Mujoco_Projects/URDF-Studio/src/features/urdf-viewer/components/ViewerToolbar.tsx`:参考图标化工具模式组和活动态,不移植 Portal/移动端工具条。
|
|
||||||
- `/home/cen/Embodied_Workspace/Mujoco_Projects/URDF-Studio/src/styles/index.css`:仅提炼语义表面、边框、文字、强调色、滚动条和 focus ring;不复制其 AI、编辑器、字体缩放等无关样式。
|
|
||||||
|
|
||||||
### 第一批组件(已确认)
|
|
||||||
|
|
||||||
| 编号 | 候选组件 | 复用现有能力 | 预期变化 | 底层风险 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| A ✅ | `WorkbenchHeader` | 导入、播放、单步、重置、速度、主题、面板开关回调 | 40px 紧凑品牌栏,动作分组,Lucide 图标 + 文案/提示 | 低,仅回调透传 |
|
|
||||||
| B ✅ | `ViewerToolDock` + `ToolbarToggleGroup` | select/joint/force 模式、相机复位 | Header 中央停靠工具组,活动态更清晰 | 低,仅调用现有 mode/viewer API |
|
|
||||||
| C ✅ | `SidebarPanel` + `CollapsibleSection` | 左右侧栏现有内容和开关 | 统一面板标题、滚动条、分区折叠;保留 resize-x;右栏低频区默认折叠 | 低,内容插槽化 |
|
|
||||||
| D ✅ | `StatusBar` | 时间、FPS、step、内存、WASM、预算警告 | 图标化状态、语义色和更紧凑层级 | 低,纯展示 |
|
|
||||||
| E ✅ | `Dialog` + `EntrySelectionDialog` | 多入口 `loadEntry` | Portal、Esc、焦点圈定、焦点恢复和统一弹窗外观 | 低,不改入口判断/加载 |
|
|
||||||
| F ✅ | `DiagnosticNotice` | `AppDiagnostic` | 摘要以通知样式展示,可展开技术详情;保留手动关闭 | 低,不改错误生成 |
|
|
||||||
| G ✅ | `LoadingOverlay` + `EmptyWorkspace` | `loading`、空状态、拖放导入 | 统一 HUD、图标、引导层次,不伪造加载百分比 | 低,纯展示 |
|
|
||||||
| H ✅ | 基础 UI:`Button`、`IconButton`、`Tooltip`、`Select` | 替换 `.btn/.icon-btn/.field` | 统一尺寸、变体、禁用态、focus ring;Tooltip 不增加 Floating UI 依赖 | 低,HTML 原生语义 |
|
|
||||||
|
|
||||||
## Steps
|
|
||||||
|
|
||||||
- [x] 审计 URDF-Studio 页面布局、样式令牌和第一批组件职责。
|
|
||||||
- [x] 审计本项目已有组件及其与 MuJoCo/viewer 的耦合边界。
|
|
||||||
- [x] 形成第一批组件差异矩阵;排除源码编辑、属性编辑、导出、快照、AI、测量、绘制、撤销/重做、移动端工具条等新增业务能力。
|
|
||||||
- [x] 获得用户对第一批 A–H、Header 中央工具组和右栏默认折叠策略的确认。
|
|
||||||
- [x] **基础层**:安装 `lucide-react`;定义 light/dark 语义 CSS 变量并映射到 Tailwind;实现 Button、IconButton、Tooltip、Select、Dialog、CollapsibleSection、ToolbarToggleGroup。Tooltip 使用 hover/focus CSS 展示,Dialog 使用 portal、Esc、焦点圈定和焦点恢复。
|
|
||||||
- [x] **工作台壳层**:实现 40px `WorkbenchHeader`,左侧保留 MuJoCo 品牌和文件动作,中间放 select/joint/force 工具组,右侧放相机、侧栏和主题动作;保留按钮可访问名称及原有回调。
|
|
||||||
- [x] **侧栏拆分**:实现 `SidebarPanel`,将左栏工程资源/模型结构和右栏模型信息、URDF、警告、选择、Actuator、关节、外力内容从 `App.tsx` 搬入展示组件;业务回调和 `adapter.current` 调用仍在 `App` 中生成后传入。右栏“模型信息、当前选择、关节”默认展开,其余默认折叠,警告出现时自动展开。
|
|
||||||
- [x] **叠层与反馈**:用 `WorkspaceOverlays` 组织空态/加载态,用 `EntrySelectionDialog` 替换内联入口对话框,用 `DiagnosticNotice` 替换诊断卡;不改变 loading/diagnostic/entries 的状态来源和加载时序。
|
|
||||||
- [x] **状态栏与树视觉**:实现 `StatusBar`;为两棵现有树替换 Emoji/字符图标并应用语义令牌,不改 `buildProjectTree`、`buildBodyTree` 或选择/hover 行为。
|
|
||||||
- [x] **收敛 App**:删除已迁移的内联 `PanelTitle`、`EmptyImport`、`EntryDialog`、`DiagnosticCard` 等展示函数;将无状态滑杆展示移入侧栏组件,不改数值换算、范围或 onChange 逻辑。
|
|
||||||
- [x] **测试与验收**:为基础控件、折叠区、Header 回调、Dialog 焦点/Esc、诊断详情和状态栏增加 Testing Library 测试;更新 E2E 图标/折叠定位并跑完整回归。
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- 运行 `npm run typecheck:platform --prefix wasm`、`npm run lint:platform --prefix wasm`、`npm run test:platform --prefix wasm`、`npm run build:platform --prefix wasm` 和 `npm run test:e2e:platform --prefix wasm`,全部通过。
|
|
||||||
- 新组件测试覆盖:Header 各动作只触发一次;模式按钮 `aria-pressed`;折叠区默认状态;Dialog Esc/Tab/焦点恢复;诊断详情展开与关闭;Loading/Empty 的互斥显示。
|
|
||||||
- 用现有 MJCF、URDF、文件夹和 ZIP 路径回归导入及入口切换。
|
|
||||||
- 回归播放/暂停、单步、重置、速度、关节、actuator、外力、碰撞显示、选择和相机复位。
|
|
||||||
- 检查 adapter/viewer 初始化与销毁次数,确认 UI 拆分未造成重复会话、重复渲染循环或 WASM 资源泄漏。
|
|
||||||
- 对照 URDF-Studio 检查布局密度、层级、悬停/选中/禁用态及深浅主题;桌面窄宽度下检查折叠与溢出。
|
|
||||||
- 对新增弹窗、菜单和提示执行键盘操作、焦点管理和 ARIA 冒烟检查。
|
|
||||||
|
|
||||||
## Implementation Result
|
|
||||||
|
|
||||||
- 已完成 A–H 第一批组件和 `lucide-react` 接入;MuJoCo adapter、viewer、manifest 和业务命令仍由 `App.tsx` 持有。
|
|
||||||
- `App.tsx` 已收敛为生命周期/事件编排层;工作台 Header、工具组、侧栏、状态栏、弹窗、诊断和加载/空态均拆为展示组件。
|
|
||||||
- 深浅主题完成语义令牌化,并在 1440×900 下完成暗色、亮色人工截图检查。
|
|
||||||
- 审查发现并修复:必选入口弹窗重渲染抢焦点、原生输入焦点可见性、模型树 ARIA 层级、侧栏重开折叠状态丢失和侧栏按钮展开状态缺失。
|
|
||||||
- 验证结果:TypeScript、ESLint、12 个测试文件共 28 个单元/组件测试、5 个 Playwright E2E 和生产构建全部通过。
|
|
||||||
- 已知非阻塞项:生产构建仍有约 972 KiB 主 JS chunk 警告,与原 MVP 记录的代码分割遗留项一致。
|
|
||||||
|
|
||||||
## Second Batch Result
|
|
||||||
|
|
||||||
- [x] `ResizablePanel` / `PanelResizeHandle`:支持鼠标拖动、键盘调整、ARIA 数值和本地宽度持久化。
|
|
||||||
- [x] `TreeSearchField`:工程树和模型结构树支持本地过滤,并保留匹配节点的祖先路径。
|
|
||||||
- [x] `ViewportHUD`:复用现有仿真、交互模式和选择状态,在视口内提供轻量状态反馈。
|
|
||||||
- [x] `ShortcutHelpDialog`:Header 帮助入口集中说明现有键盘与鼠标操作。
|
|
||||||
- [x] `Badge`、`Tabs`、`Separator`、`Skeleton`:补齐基础层,并升级模型加载反馈。
|
|
||||||
- [x] 侧栏标签页:左栏拆分“工程 / 模型结构”,右栏拆分“属性 / 控制”;非活动面板保持挂载,保留树和折叠状态。
|
|
||||||
- [x] 第二批验证:TypeScript、ESLint、14 个测试文件共 36 个测试、生产构建及 5 个 Playwright E2E 全部通过;完成加载模型和快捷键弹窗的人工截图检查。
|
|
||||||
- [x] 审查修复:Tabs roving tabindex/方向键导航、侧栏宽度边界与指针取消清理、Dialog 打开时快捷键隔离、搜索结果目录锁定展开,以及重编译失败快照清理和新 Session 速度恢复。
|
|
||||||
|
|
||||||
## Third Batch Result
|
|
||||||
|
|
||||||
- [x] `ConfirmDialog`:统一替换移除工程的原生确认框。
|
|
||||||
- [x] `CommandPalette`:支持 Header 入口、`Ctrl+K`、搜索、方向键和 Enter,复用现有仿真/视口/布局动作。
|
|
||||||
- [x] `PerformancePopover`:从状态栏查看 FPS、物理步进、内存与预算状态。
|
|
||||||
- [x] `Kbd`、`PropertyRow`、`CopyButton`:统一快捷键和属性展示,并支持复制已有选择信息。
|
|
||||||
- [x] `TreeSearchSummary`、`SearchHighlight`、`EmptySearchState`:显示匹配数量、高亮命中并统一无结果反馈。
|
|
||||||
- [x] `ViewportFullscreenToggle`:支持浏览器全屏进入、退出及状态同步。
|
|
||||||
- [x] 第三批验证:TypeScript、ESLint、16 个测试文件共 42 个测试、生产构建及 5 个 Playwright E2E 全部通过;完成命令面板和完整工作台截图检查。
|
|
||||||
- [x] 审查修复:全屏元素内 Dialog Portal、Popover 与命令面板互斥关闭、树搜索计数同源、Clipboard API 降级,以及命令面板 combobox/listbox 活动项关联。
|
|
||||||
|
|
||||||
## Fourth Batch Result
|
|
||||||
|
|
||||||
- [x] `ToastViewport` / `NotificationCenter`:模型加载成功、兼容提示和编译失败进入会话通知队列,并提供即时 Toast。
|
|
||||||
- [x] `LayoutSettingsDialog`:统一左右栏显示、宽度重置和默认/宽视口/工程浏览/控制调试预设。
|
|
||||||
- [x] `ProjectBreadcrumb` / `EntrySwitcher`:展示工程入口路径,并可在多入口工程中直接切换。
|
|
||||||
- [x] `VirtualTreeViewport`:大型工程文件树及 Body/Joint 树超过阈值时启用窗口化渲染。
|
|
||||||
- [x] `SettingsDialog`:集中管理主题、角度单位、碰撞几何、关节高级信息和外力强度。
|
|
||||||
- [x] Header 接入通知、布局和设置入口;1024px 产品边界下隐藏品牌长标题以避免工具区重叠。
|
|
||||||
- [x] 第四批验证:TypeScript、ESLint、18 个测试文件共 49 个测试、生产构建及 5 个 Playwright E2E 全部通过。
|
|
||||||
|
|
||||||
- [x] 审查修复:入口切换加载锁与禁用态、1024px Header 轨道约束及 E2E 边界检查、虚拟树 roving active descendant/方向键导航和层级展开折叠。
|
|
||||||
|
|
||||||
## Fifth Batch Result
|
|
||||||
|
|
||||||
- [x] `ToolbarOverflowMenu`:1024px 工具区通过“更多”菜单承载命令、布局、设置、全屏、帮助与主题动作。
|
|
||||||
- [x] 通用 `Popover` / `DropdownMenu`:统一外部点击、Escape、焦点恢复及菜单方向键行为,并重构性能和通知弹层。
|
|
||||||
- [x] `ImportProgressPanel` / `ProgressBar`:在不改变转换逻辑的前提下显示读取、资源处理、WASM/编译和视口创建阶段。
|
|
||||||
- [x] `DiagnosticsDrawer` / EventLog:按全部、警告和错误分类查看、复制及清空会话事件。
|
|
||||||
- [x] `SearchableCombobox`:多入口工程支持路径搜索和键盘选择。
|
|
||||||
- [x] `ErrorRecoveryPanel`:诊断反馈提供重试入口、复制详情和返回工程树动作。
|
|
||||||
- [x] `LiveRegion`:统一 Toast、加载阶段和诊断变化的辅助技术播报。
|
|
||||||
- [x] 第五批验证:TypeScript、ESLint、20 个测试文件共 56 个测试和生产构建通过;Playwright E2E 完整回归覆盖更多菜单、事件日志与错误恢复。
|
|
||||||
|
|
||||||
- [x] 审查修复:拖放文件收集与导入共享互斥锁、仅模型编译错误允许重试、菜单首项聚焦及触发器恢复、Combobox 完整状态与 Tab 关闭、事件日志按活动标签惰性挂载、Toast 单一 live region。
|
|
||||||
- [x] 信息归并:从“模型与控制”侧栏移除 `URDF 兼容处理` 分区,完整兼容处理明细仅保留在通知中心与事件日志。
|
|
||||||
|
|
||||||
## Confirmed Decisions
|
|
||||||
|
|
||||||
1. 第一批 A–H 全部纳入。
|
|
||||||
2. 只采用 URDF-Studio 的专业工作台风格,保留 MuJoCo 品牌、中文界面和当前左右栏布局。
|
|
||||||
3. 允许引入 `lucide-react`,不引入 `@floating-ui/react`。
|
|
||||||
4. select/joint/force 工具组停靠在 Header 中央。
|
|
||||||
5. 右侧默认展开模型信息、当前选择和关节;其他低频分区折叠,警告出现时自动展开。
|
|
||||||
6. 第一批不新增编辑、导出、快照、AI、测量、绘制、撤销/重做等产品能力。
|
|
||||||
7. 后续任何超出 A–H 的新组件都需再次向用户确认。
|
|
||||||
@@ -145,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();
|
||||||
@@ -162,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();
|
||||||
@@ -195,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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -296,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();
|
||||||
@@ -344,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();
|
||||||
@@ -413,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
|
||||||
@@ -429,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"]')
|
||||||
@@ -437,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 }) => {
|
||||||
@@ -479,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('认证资产与场景对象属性')).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'));
|
||||||
@@ -496,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();
|
||||||
@@ -526,20 +542,19 @@ test('认证资产自动打开地图属性并与参数地形一次编译', async
|
|||||||
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 page.getByRole('tab', { name: '属性' }).click();
|
|
||||||
await library.getByRole('button', { name: '添加基础方盒' }).click();
|
await library.getByRole('button', { name: '添加基础方盒' }).click();
|
||||||
await expect(page.getByRole('tab', { name: '地图' })).toHaveAttribute('aria-selected', 'true');
|
await expect(page.getByText('Map / Object')).toBeVisible();
|
||||||
await expect(page.getByText('认证资产与场景对象属性')).toBeVisible();
|
await expect(page.getByLabel('地图物体检查器')).toBeVisible();
|
||||||
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
|
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
|
||||||
|
|
||||||
const sceneTree = page.getByLabel('场景资产树');
|
const sceneTree = page.getByLabel('场景资产树');
|
||||||
await expect(sceneTree.getByText('基础方盒')).toBeVisible();
|
await expect(sceneTree.getByText('基础方盒')).toBeVisible();
|
||||||
await page.getByRole('tab', { name: '属性' }).click();
|
await sceneTree.getByRole('treeitem', { name: 'box', exact: true }).click();
|
||||||
await sceneTree.getByRole('button', { name: /基础方盒/ }).click();
|
await expect(page.getByText('Robot / Body')).toBeVisible();
|
||||||
await expect(page.getByRole('tab', { name: '地图' })).toHaveAttribute('aria-selected', 'true');
|
await sceneTree.getByRole('treeitem', { name: /基础方盒/ }).click();
|
||||||
|
await expect(page.getByText('Map / Object')).toBeVisible();
|
||||||
|
|
||||||
await library.getByRole('button', { name: '添加随机粗糙地形' }).click();
|
await library.getByRole('button', { name: '添加随机粗糙地形' }).click();
|
||||||
await page.getByLabel('位置 X(m)').fill('4');
|
await page.getByLabel('位置 X(m)').fill('4');
|
||||||
@@ -564,10 +579,10 @@ test('认证资产自动打开地图属性并与参数地形一次编译', async
|
|||||||
}
|
}
|
||||||
expect(selectedDraftAsset).toBe(true);
|
expect(selectedDraftAsset).toBe(true);
|
||||||
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json');
|
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json');
|
||||||
await sceneTree.getByRole('button', { name: '选择地图实例 随机粗糙地形' }).click();
|
await sceneTree.getByRole('treeitem', { name: /随机粗糙地形/ }).click();
|
||||||
await expect(page.getByLabel('地图来源')).toHaveValue('builtin');
|
await expect(page.getByLabel('地图来源')).toHaveValue('builtin');
|
||||||
|
|
||||||
await page.getByRole('button', { name: '一次编译应用' }).click();
|
await page.getByRole('button', { name: '应用场景' }).click();
|
||||||
|
|
||||||
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
|
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
|
||||||
await expect(page.getByText(/已加载工程地图“场景 1”(1 个物理几何/)).toBeVisible();
|
await expect(page.getByText(/已加载工程地图“场景 1”(1 个物理几何/)).toBeVisible();
|
||||||
@@ -610,15 +625,14 @@ test('放弃首次认证资产会完整回滚临时场景和工程文件', async
|
|||||||
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 library.getByRole('button', { name: '添加基础方盒' }).click();
|
await library.getByRole('button', { name: '添加基础方盒' }).click();
|
||||||
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
|
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
|
||||||
await expect(page.getByLabel('场景资产树')).toContainText('场景 1');
|
await expect(page.getByLabel('场景资产树')).toContainText('场景 1');
|
||||||
await page.getByRole('button', { name: '放弃场景更改' }).click();
|
await page.getByRole('button', { name: '放弃更改' }).click();
|
||||||
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
|
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
|
||||||
await expect(page.getByLabel('场景资产树')).toContainText('0 个实例');
|
await expect(page.getByLabel('场景资产树')).not.toContainText('场景 1');
|
||||||
await expect(library.getByText('场景 1')).toHaveCount(0);
|
await expect(library.getByText('场景 1')).toHaveCount(0);
|
||||||
|
|
||||||
// 文件和 map id 也必须回滚;再次创建应复用 scene_1,而不是泄漏出 scene_2。
|
// 文件和 map id 也必须回滚;再次创建应复用 scene_1,而不是泄漏出 scene_2。
|
||||||
@@ -654,7 +668,6 @@ test('工程地图与参数地形共享放置草稿、实例变换和回滚入
|
|||||||
buffer: Buffer.from(project),
|
buffer: Buffer.from(project),
|
||||||
});
|
});
|
||||||
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 library.getByRole('button', { name: '放置工程地图 草稿仓库' }).click();
|
await library.getByRole('button', { name: '放置工程地图 草稿仓库' }).click();
|
||||||
@@ -667,7 +680,7 @@ test('工程地图与参数地形共享放置草稿、实例变换和回滚入
|
|||||||
|
|
||||||
await library.getByRole('button', { name: '添加波浪地形' }).click();
|
await library.getByRole('button', { name: '添加波浪地形' }).click();
|
||||||
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
|
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
|
||||||
await page.getByRole('button', { name: '一次编译应用' }).click();
|
await page.getByRole('button', { name: '应用场景' }).click();
|
||||||
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
|
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
|
||||||
await expect(page.getByText(/已加载工程地图“草稿仓库”/)).toBeVisible();
|
await expect(page.getByText(/已加载工程地图“草稿仓库”/)).toBeVisible();
|
||||||
await expect(page.getByText(/已加载波浪地形物理地图/)).toBeVisible();
|
await expect(page.getByText(/已加载波浪地形物理地图/)).toBeVisible();
|
||||||
@@ -675,7 +688,7 @@ test('工程地图与参数地形共享放置草稿、实例变换和回滚入
|
|||||||
await page.getByRole('button', { name: '删除地图实例 草稿仓库' }).click();
|
await page.getByRole('button', { name: '删除地图实例 草稿仓库' }).click();
|
||||||
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
|
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
|
||||||
await expect(page.getByLabel('场景资产树')).not.toContainText('草稿仓库');
|
await expect(page.getByLabel('场景资产树')).not.toContainText('草稿仓库');
|
||||||
await page.getByRole('button', { name: '放弃场景更改' }).click();
|
await page.getByRole('button', { name: '放弃更改' }).click();
|
||||||
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
|
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
|
||||||
await expect(page.getByLabel('场景资产树')).toContainText('草稿仓库');
|
await expect(page.getByLabel('场景资产树')).toContainText('草稿仓库');
|
||||||
});
|
});
|
||||||
@@ -692,7 +705,6 @@ test('认证资产重力放置使用参数地形的真实承载高度', async ({
|
|||||||
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 library.getByRole('button', { name: '添加深坑地形' }).click();
|
await library.getByRole('button', { name: '添加深坑地形' }).click();
|
||||||
@@ -700,7 +712,7 @@ test('认证资产重力放置使用参数地形的真实承载高度', async ({
|
|||||||
await library.getByLabel('新增资产放置方式').selectOption('gravity');
|
await library.getByLabel('新增资产放置方式').selectOption('gravity');
|
||||||
await library.getByRole('button', { name: '添加基础方盒' }).click();
|
await library.getByRole('button', { name: '添加基础方盒' }).click();
|
||||||
|
|
||||||
await expect(page.getByLabel('对象放置方式')).toHaveValue('gravity');
|
await expect(page.getByLabel('贴地检测模式')).toHaveValue('gravity');
|
||||||
await expect(page.getByLabel('对象位置Z')).toHaveValue('-0.3');
|
await expect(page.getByLabel('对象位置Z')).toHaveValue('-0.3');
|
||||||
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
|
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -717,7 +729,6 @@ 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();
|
|
||||||
|
|
||||||
await page.locator('[data-system-terrain="rough"]').dragTo(page.locator('main canvas'));
|
await page.locator('[data-system-terrain="rough"]').dragTo(page.locator('main canvas'));
|
||||||
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
|
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
|
||||||
@@ -727,18 +738,19 @@ test('参数化地形可连续拖到画布并一次性编译', async ({ page })
|
|||||||
await expect(page.getByLabel('场景资产树')).toContainText('随机粗糙地形');
|
await expect(page.getByLabel('场景资产树')).toContainText('随机粗糙地形');
|
||||||
await expect(page.getByLabel('场景资产树')).toContainText('波浪地形');
|
await expect(page.getByLabel('场景资产树')).toContainText('波浪地形');
|
||||||
|
|
||||||
await page.getByRole('tab', { name: '属性' }).click();
|
await page.getByLabel('场景资产树').getByRole('treeitem', { name: 'box' }).click();
|
||||||
|
await expect(page.getByText('Robot / Body')).toBeVisible();
|
||||||
const canvasBox = await page.locator('main canvas').first().boundingBox();
|
const canvasBox = await page.locator('main canvas').first().boundingBox();
|
||||||
expect(canvasBox).not.toBeNull();
|
expect(canvasBox).not.toBeNull();
|
||||||
await page.mouse.click(
|
await page.mouse.click(
|
||||||
canvasBox!.x + canvasBox!.width * 0.5,
|
canvasBox!.x + canvasBox!.width * 0.5,
|
||||||
canvasBox!.y + canvasBox!.height * 0.78,
|
canvasBox!.y + canvasBox!.height * 0.78,
|
||||||
);
|
);
|
||||||
await expect(page.getByRole('tab', { name: '地图' })).toHaveAttribute('aria-selected', 'true');
|
await expect(page.getByText('Map / Instance')).toBeVisible();
|
||||||
await expect(page.getByLabel('视口选择与变换')).toBeVisible();
|
await expect(page.getByLabel('地图视口工具')).toBeVisible();
|
||||||
await expect(page.getByText('地图与对象属性')).toBeVisible();
|
await expect(page.getByText('地图与对象属性')).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: '一次编译应用' }).click();
|
await page.getByRole('button', { name: '应用场景' }).click();
|
||||||
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
|
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
|
||||||
await expect(page.getByText(/已加载随机粗糙地形物理地图/)).toBeVisible({
|
await expect(page.getByText(/已加载随机粗糙地形物理地图/)).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
@@ -758,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');
|
||||||
@@ -790,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);
|
||||||
@@ -834,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();
|
||||||
@@ -869,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('认证资产与场景对象属性').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();
|
||||||
});
|
});
|
||||||
@@ -915,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('认证资产与场景对象属性')).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText('认证资产与场景对象属性')).toBeVisible({ timeout: 30_000 });
|
||||||
const editor = page.getByText('认证资产与场景对象属性').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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -974,7 +1010,6 @@ test('工程地图编译失败时保留上一仿真会话', async ({ page }) =>
|
|||||||
const runningTime = Number(
|
const runningTime = Number(
|
||||||
(await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0,
|
(await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0,
|
||||||
);
|
);
|
||||||
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.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 });
|
await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 });
|
||||||
@@ -989,7 +1024,7 @@ test('工程地图编译失败时保留上一仿真会话', async ({ page }) =>
|
|||||||
Number((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0),
|
Number((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0),
|
||||||
)
|
)
|
||||||
.toBeGreaterThan(runningTime);
|
.toBeGreaterThan(runningTime);
|
||||||
await page.getByRole('button', { name: '放弃场景更改' }).click();
|
await page.getByRole('button', { name: '放弃更改' }).click();
|
||||||
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
|
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
|
||||||
await expect(page.getByLabel('地图来源')).toHaveValue('none');
|
await expect(page.getByLabel('地图来源')).toHaveValue('none');
|
||||||
});
|
});
|
||||||
|
|||||||
+358
-83
@@ -19,6 +19,7 @@ import {
|
|||||||
CircleHelp,
|
CircleHelp,
|
||||||
Code2,
|
Code2,
|
||||||
Crosshair,
|
Crosshair,
|
||||||
|
Database,
|
||||||
Download,
|
Download,
|
||||||
Hand,
|
Hand,
|
||||||
Maximize,
|
Maximize,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
|
SlidersHorizontal,
|
||||||
SunMoon,
|
SunMoon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DEFAULT_IMPORT_LIMITS, type MapEntry, type ProjectManifest } from '../project/types';
|
import { DEFAULT_IMPORT_LIMITS, type MapEntry, type ProjectManifest } from '../project/types';
|
||||||
@@ -55,8 +57,17 @@ import {
|
|||||||
import { useAppStore, type AppDiagnostic } from '../stores/useAppStore';
|
import { useAppStore, type AppDiagnostic } from '../stores/useAppStore';
|
||||||
import { WorkbenchHeader } from './components/WorkbenchHeader';
|
import { WorkbenchHeader } from './components/WorkbenchHeader';
|
||||||
import { ViewerToolDock } from './components/ViewerToolDock';
|
import { ViewerToolDock } from './components/ViewerToolDock';
|
||||||
|
import {
|
||||||
|
MapAssetDropIndicator,
|
||||||
|
MapDraftStatusOverlay,
|
||||||
|
MapViewportToolbar,
|
||||||
|
type MapAssetDropTarget,
|
||||||
|
} from './components/MapViewportTools';
|
||||||
import { ModelControlsSidebar } from './components/ModelControlsSidebar';
|
import { ModelControlsSidebar } from './components/ModelControlsSidebar';
|
||||||
import { ProjectSidebar } from './components/ProjectSidebar';
|
import { ProjectSidebar, type ProjectResourceTab } from './components/ProjectSidebar';
|
||||||
|
import type { WorkspaceTool } from './components/WorkspaceToolsPanel';
|
||||||
|
import type { EditorSelection } from './editorSelection';
|
||||||
|
import { useMapEditorShortcuts } from './hooks/useMapEditorShortcuts';
|
||||||
import { WorkspaceOverlays, type ImportProgress } from './components/WorkspaceOverlays';
|
import { WorkspaceOverlays, type ImportProgress } from './components/WorkspaceOverlays';
|
||||||
import { EntrySelectionDialog } from './components/EntrySelectionDialog';
|
import { EntrySelectionDialog } from './components/EntrySelectionDialog';
|
||||||
import { ErrorRecoveryPanel } from './components/ErrorRecoveryPanel';
|
import { ErrorRecoveryPanel } from './components/ErrorRecoveryPanel';
|
||||||
@@ -107,6 +118,7 @@ import type {
|
|||||||
EditableMapDocument,
|
EditableMapDocument,
|
||||||
EditableMapObjectType,
|
EditableMapObjectType,
|
||||||
MapEditorInteractionCallbacks,
|
MapEditorInteractionCallbacks,
|
||||||
|
MapEditorSessionState,
|
||||||
MapEditorTransformMode,
|
MapEditorTransformMode,
|
||||||
MapObjectPlacementMode,
|
MapObjectPlacementMode,
|
||||||
} from '../map/editor/types';
|
} from '../map/editor/types';
|
||||||
@@ -131,6 +143,11 @@ const SourceEditorDialog = lazy(() =>
|
|||||||
default: module.SourceEditorDialog,
|
default: module.SourceEditorDialog,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
const WorkspaceToolsPanel = lazy(() =>
|
||||||
|
import('./components/WorkspaceToolsPanel').then((module) => ({
|
||||||
|
default: module.WorkspaceToolsPanel,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
function hasTransferType(dataTransfer: DataTransfer, type: string): boolean {
|
function hasTransferType(dataTransfer: DataTransfer, type: string): boolean {
|
||||||
return Array.from(dataTransfer.types).includes(type);
|
return Array.from(dataTransfer.types).includes(type);
|
||||||
@@ -253,6 +270,7 @@ export function App() {
|
|||||||
importInFlight = useRef(false),
|
importInFlight = useRef(false),
|
||||||
adapter = useRef(new MainThreadPhysicsAdapter()),
|
adapter = useRef(new MainThreadPhysicsAdapter()),
|
||||||
root = useRef<HTMLDivElement>(null),
|
root = useRef<HTMLDivElement>(null),
|
||||||
|
viewportShell = useRef<HTMLElement>(null),
|
||||||
viewerHost = useRef<HTMLDivElement>(null),
|
viewerHost = useRef<HTMLDivElement>(null),
|
||||||
viewer = useRef<MuJoCoViewer | null>(null),
|
viewer = useRef<MuJoCoViewer | null>(null),
|
||||||
viewerReady = useRef<Promise<MuJoCoViewer | null> | null>(null),
|
viewerReady = useRef<Promise<MuJoCoViewer | null> | null>(null),
|
||||||
@@ -307,12 +325,16 @@ export function App() {
|
|||||||
[committedEditorDocuments, setCommittedEditorDocuments] = useState<
|
[committedEditorDocuments, setCommittedEditorDocuments] = useState<
|
||||||
Map<string, EditableMapDocument>
|
Map<string, EditableMapDocument>
|
||||||
>(() => new Map()),
|
>(() => new Map()),
|
||||||
[projectSidebarTab, setProjectSidebarTab] = useState<'project' | 'structure' | 'assets'>(
|
[projectSidebarTab, setProjectSidebarTab] = useState<ProjectResourceTab>('assets'),
|
||||||
'project',
|
[editorSelection, setEditorSelection] = useState<EditorSelection | null>(null),
|
||||||
|
[workspaceTool, setWorkspaceTool] = useState<WorkspaceTool | null>(null),
|
||||||
|
[mapTransformMode, setMapTransformMode] = useState<MapEditorTransformMode>('translate'),
|
||||||
|
[mapSnapping, setMapSnapping] = useState(true),
|
||||||
|
[assetPlacementMode, setAssetPlacementMode] = useState<MapObjectPlacementMode>('auto_ground'),
|
||||||
|
[editorSessionStates, setEditorSessionStates] = useState<Map<string, MapEditorSessionState>>(
|
||||||
|
() => new Map(),
|
||||||
),
|
),
|
||||||
[modelControlsTab, setModelControlsTab] = useState<'properties' | 'controls' | 'data' | 'map'>(
|
[mapAssetDropTarget, setMapAssetDropTarget] = useState<MapAssetDropTarget>();
|
||||||
'properties',
|
|
||||||
);
|
|
||||||
const [urdfMode, setUrdfMode] = useState<UrdfLoadMode>('mjcf'),
|
const [urdfMode, setUrdfMode] = useState<UrdfLoadMode>('mjcf'),
|
||||||
urdfModeRef = useRef<UrdfLoadMode>('mjcf');
|
urdfModeRef = useRef<UrdfLoadMode>('mjcf');
|
||||||
const [baseMode, setBaseMode] = useState<UrdfBaseMode>('floating'),
|
const [baseMode, setBaseMode] = useState<UrdfBaseMode>('floating'),
|
||||||
@@ -356,10 +378,27 @@ export function App() {
|
|||||||
: [],
|
: [],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return [...editorDrafts.keys()].filter((path) => !coveredDescriptors.has(path)).length;
|
return [...editorDrafts.keys()]
|
||||||
}, [mapSceneDraft.changedIds, placedMapAssets, editorDrafts]);
|
.filter((path) => !coveredDescriptors.has(path))
|
||||||
|
.reduce(
|
||||||
|
(count, path) => count + Math.max(1, editorSessionStates.get(path)?.changeCount ?? 1),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}, [mapSceneDraft.changedIds, placedMapAssets, editorDrafts, editorSessionStates]);
|
||||||
const sceneDraftChangeCount = mapSceneDraft.changeCount + editorOnlyDraftCount,
|
const sceneDraftChangeCount = mapSceneDraft.changeCount + editorOnlyDraftCount,
|
||||||
mapSceneDirty = sceneDraftChangeCount > 0;
|
mapSceneDirty = sceneDraftChangeCount > 0;
|
||||||
|
const activeEditorView =
|
||||||
|
mapSelection.kind === 'project'
|
||||||
|
? (editorDrafts.get(mapSelection.descriptorPath) ?? editorDocument)
|
||||||
|
: null;
|
||||||
|
const selectedMapObject =
|
||||||
|
editorSelection?.kind === 'map-object' && editorSelection.mapAssetId === activeMapAssetId
|
||||||
|
? activeEditorView?.objects.find((object) => object.id === editorSelection.objectId)
|
||||||
|
: undefined;
|
||||||
|
const mapEditingActive =
|
||||||
|
Boolean(activeMapAssetId) &&
|
||||||
|
(editorSelection?.kind === 'map' || editorSelection?.kind === 'map-object');
|
||||||
|
const activePlacementMode = selectedMapObject?.placementMode ?? assetPlacementMode;
|
||||||
const viewerSettings = useRef({
|
const viewerSettings = useRef({
|
||||||
mode: state.mode,
|
mode: state.mode,
|
||||||
forceScale,
|
forceScale,
|
||||||
@@ -402,10 +441,8 @@ export function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.setSelection(selection);
|
state.setSelection(selection);
|
||||||
if (selection) {
|
setEditorSelection(selection ? { kind: 'body', bodyId: selection.bodyId } : null);
|
||||||
setRightOpen(true);
|
if (selection) setRightOpen(true);
|
||||||
setModelControlsTab('properties');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onFrame: (frame, fps, snapshot) => {
|
onFrame: (frame, fps, snapshot) => {
|
||||||
const memory = (performance as Performance & { memory?: { usedJSHeapSize: number } })
|
const memory = (performance as Performance & { memory?: { usedJSHeapSize: number } })
|
||||||
@@ -434,10 +471,15 @@ export function App() {
|
|||||||
},
|
},
|
||||||
onMapEditorSelect: (id) => {
|
onMapEditorSelect: (id) => {
|
||||||
editorInteraction.current?.onSelect(id);
|
editorInteraction.current?.onSelect(id);
|
||||||
if (id) {
|
const mapAssetId = activeMapAssetIdRef.current;
|
||||||
setRightOpen(true);
|
setEditorSelection(
|
||||||
setModelControlsTab('map');
|
mapAssetId
|
||||||
}
|
? id
|
||||||
|
? { kind: 'map-object', mapAssetId, objectId: id }
|
||||||
|
: { kind: 'map', mapAssetId }
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
if (mapAssetId) setRightOpen(true);
|
||||||
},
|
},
|
||||||
onMapEditorPreviewSelect: (mapAssetId, objectId) =>
|
onMapEditorPreviewSelect: (mapAssetId, objectId) =>
|
||||||
mapEditorPreviewInteraction.current(mapAssetId, objectId),
|
mapEditorPreviewInteraction.current(mapAssetId, objectId),
|
||||||
@@ -487,6 +529,12 @@ export function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
viewer.current?.setMode(state.mode);
|
viewer.current?.setMode(state.mode);
|
||||||
}, [state.mode]);
|
}, [state.mode]);
|
||||||
|
useEffect(() => {
|
||||||
|
viewer.current?.setMapEditorTransformMode(mapTransformMode);
|
||||||
|
}, [mapTransformMode]);
|
||||||
|
useEffect(() => {
|
||||||
|
viewer.current?.setMapEditorSnapping(mapSnapping ? 0.1 : null, mapSnapping ? 5 : null);
|
||||||
|
}, [mapSnapping]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewer.current) viewer.current.forceScale = forceScale;
|
if (viewer.current) viewer.current.forceScale = forceScale;
|
||||||
}, [forceScale]);
|
}, [forceScale]);
|
||||||
@@ -611,6 +659,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
state.setSnapshot(snapshot);
|
state.setSnapshot(snapshot);
|
||||||
state.setSelection(null);
|
state.setSelection(null);
|
||||||
|
setEditorSelection(null);
|
||||||
state.setPaused(true);
|
state.setPaused(true);
|
||||||
setImportProgress({
|
setImportProgress({
|
||||||
title: '正在准备仿真',
|
title: '正在准备仿真',
|
||||||
@@ -759,7 +808,8 @@ export function App() {
|
|||||||
manifest.current = next;
|
manifest.current = next;
|
||||||
setProjectMaps(next.maps);
|
setProjectMaps(next.maps);
|
||||||
setCommittedEditorDocuments(manifestEditorDocuments(next));
|
setCommittedEditorDocuments(manifestEditorDocuments(next));
|
||||||
setProjectSidebarTab('project');
|
setProjectSidebarTab('assets');
|
||||||
|
setEditorSelection(null);
|
||||||
setEditorDocument(null);
|
setEditorDocument(null);
|
||||||
editorDraftsRef.current = new Map();
|
editorDraftsRef.current = new Map();
|
||||||
setEditorDrafts(new Map());
|
setEditorDrafts(new Map());
|
||||||
@@ -827,10 +877,12 @@ export function App() {
|
|||||||
setPolicyStatus(undefined);
|
setPolicyStatus(undefined);
|
||||||
setProjectMaps([]);
|
setProjectMaps([]);
|
||||||
setCommittedEditorDocuments(new Map());
|
setCommittedEditorDocuments(new Map());
|
||||||
setProjectSidebarTab('project');
|
setProjectSidebarTab('assets');
|
||||||
|
setEditorSelection(null);
|
||||||
setEditorDocument(null);
|
setEditorDocument(null);
|
||||||
editorDraftsRef.current = new Map();
|
editorDraftsRef.current = new Map();
|
||||||
setEditorDrafts(new Map());
|
setEditorDrafts(new Map());
|
||||||
|
setEditorSessionStates(new Map());
|
||||||
provisionalMapFilesRef.current.clear();
|
provisionalMapFilesRef.current.clear();
|
||||||
viewer.current?.setMapEditorDocument(null);
|
viewer.current?.setMapEditorDocument(null);
|
||||||
placedMapAssetsRef.current = [];
|
placedMapAssetsRef.current = [];
|
||||||
@@ -902,6 +954,7 @@ export function App() {
|
|||||||
const clearEditorDrafts = useCallback(() => {
|
const clearEditorDrafts = useCallback(() => {
|
||||||
editorDraftsRef.current = new Map();
|
editorDraftsRef.current = new Map();
|
||||||
setEditorDrafts(new Map());
|
setEditorDrafts(new Map());
|
||||||
|
setEditorSessionStates(new Map());
|
||||||
}, []);
|
}, []);
|
||||||
const bindEditorInteraction = useCallback((callbacks: MapEditorInteractionCallbacks | null) => {
|
const bindEditorInteraction = useCallback((callbacks: MapEditorInteractionCallbacks | null) => {
|
||||||
editorInteraction.current = callbacks;
|
editorInteraction.current = callbacks;
|
||||||
@@ -923,16 +976,62 @@ export function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
const selectEditorObject = useCallback((id: string | null) => {
|
const selectEditorObject = useCallback((id: string | null) => {
|
||||||
viewer.current?.selectMapEditorObject(id);
|
viewer.current?.selectMapEditorObject(id);
|
||||||
|
const mapAssetId = activeMapAssetIdRef.current;
|
||||||
|
setEditorSelection(
|
||||||
|
mapAssetId
|
||||||
|
? id
|
||||||
|
? { kind: 'map-object', mapAssetId, objectId: id }
|
||||||
|
: { kind: 'map', mapAssetId }
|
||||||
|
: null,
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
const setEditorTransformMode = useCallback((mode: MapEditorTransformMode) => {
|
const updateEditorSessionState = useCallback(
|
||||||
viewer.current?.setMapEditorTransformMode(mode);
|
(descriptorPath: string, sessionState: MapEditorSessionState | null) => {
|
||||||
}, []);
|
setEditorSessionStates((current) => {
|
||||||
const setEditorSnapping = useCallback(
|
const next = new Map(current);
|
||||||
(translation: number | null, rotationDegrees: number | null) => {
|
if (sessionState?.dirty) next.set(descriptorPath, sessionState);
|
||||||
viewer.current?.setMapEditorSnapping(translation, rotationDegrees);
|
else next.delete(descriptorPath);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
const activateMapEditing = useCallback(() => {
|
||||||
|
state.setMode('select');
|
||||||
|
}, []);
|
||||||
|
const changeMapTransformMode = useCallback((nextMode: MapEditorTransformMode) => {
|
||||||
|
setMapTransformMode(nextMode);
|
||||||
|
state.setMode('select');
|
||||||
|
}, []);
|
||||||
|
const changeMapPlacementMode = useCallback(
|
||||||
|
(placementMode: MapObjectPlacementMode) => {
|
||||||
|
setAssetPlacementMode(placementMode);
|
||||||
|
if (placementMode === 'locked')
|
||||||
|
setMapTransformMode((currentMode) => (currentMode === 'scale' ? 'translate' : currentMode));
|
||||||
|
if (editorSelection?.kind === 'map-object') {
|
||||||
|
editorInteraction.current?.onSetPlacementMode(editorSelection.objectId, placementMode);
|
||||||
|
if (placementMode !== 'locked')
|
||||||
|
viewer.current?.flashMapEditorSurfaceAlignment(editorSelection.objectId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editorSelection],
|
||||||
|
);
|
||||||
|
const alignSelectedMapObject = useCallback(() => {
|
||||||
|
if (editorSelection?.kind !== 'map-object') return;
|
||||||
|
editorInteraction.current?.onAlignToSurface(editorSelection.objectId);
|
||||||
|
viewer.current?.flashMapEditorSurfaceAlignment(editorSelection.objectId);
|
||||||
|
}, [editorSelection]);
|
||||||
|
const focusSelectedObject = useCallback(() => {
|
||||||
|
const bodyId =
|
||||||
|
editorSelection?.kind === 'body' || editorSelection?.kind === 'joint'
|
||||||
|
? editorSelection.bodyId
|
||||||
|
: undefined;
|
||||||
|
viewer.current?.focusSelection(bodyId);
|
||||||
|
}, [editorSelection]);
|
||||||
|
const deleteSelectedMapObject = useCallback(() => {
|
||||||
|
if (editorSelection?.kind === 'map-object')
|
||||||
|
editorInteraction.current?.onDelete(editorSelection.objectId);
|
||||||
|
}, [editorSelection]);
|
||||||
const readEditorDocument = useCallback((selection: MapSelection): EditableMapDocument | null => {
|
const readEditorDocument = useCallback((selection: MapSelection): EditableMapDocument | null => {
|
||||||
if (selection.kind !== 'project' || !manifest.current) return null;
|
if (selection.kind !== 'project' || !manifest.current) return null;
|
||||||
const resolved = resolveProjectMap(manifest.current, selection.descriptorPath);
|
const resolved = resolveProjectMap(manifest.current, selection.descriptorPath);
|
||||||
@@ -1001,12 +1100,13 @@ export function App() {
|
|||||||
setActiveMapAssetId(active?.id);
|
setActiveMapAssetId(active?.id);
|
||||||
mapSelectionRef.current = nextSelection;
|
mapSelectionRef.current = nextSelection;
|
||||||
setMapSelection(nextSelection);
|
setMapSelection(nextSelection);
|
||||||
|
if (nextSelection.kind === 'builtin')
|
||||||
|
setMapTransformMode((currentMode) => (currentMode === 'scale' ? 'translate' : currentMode));
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const focusMapProperties = useCallback(() => {
|
const focusMapProperties = useCallback(() => {
|
||||||
setRightOpen(true);
|
setRightOpen(true);
|
||||||
setModelControlsTab('map');
|
|
||||||
}, []);
|
}, []);
|
||||||
const previewVisualMapScene = useCallback(
|
const previewVisualMapScene = useCallback(
|
||||||
(assets: readonly PlacedMapAsset[], reload: boolean) => {
|
(assets: readonly PlacedMapAsset[], reload: boolean) => {
|
||||||
@@ -1070,7 +1170,19 @@ export function App() {
|
|||||||
: null;
|
: null;
|
||||||
setEditorDocument(committedEditorDocument);
|
setEditorDocument(committedEditorDocument);
|
||||||
previewEditorDocument(previewDocument);
|
previewEditorDocument(previewDocument);
|
||||||
if (objectId) viewer.current?.selectMapEditorObject(objectId);
|
if (objectId) {
|
||||||
|
viewer.current?.selectMapEditorObject(objectId);
|
||||||
|
if (editorInteraction.current) {
|
||||||
|
editorInteraction.current.onSelect(objectId);
|
||||||
|
pendingEditorObjectId.current = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setEditorSelection(
|
||||||
|
objectId
|
||||||
|
? { kind: 'map-object', mapAssetId: id, objectId }
|
||||||
|
: { kind: 'map', mapAssetId: id },
|
||||||
|
);
|
||||||
|
useAppStore.getState().setSelection(null);
|
||||||
focusMapProperties();
|
focusMapProperties();
|
||||||
},
|
},
|
||||||
[focusMapProperties, previewEditorDocument, readEditorDocument, setMapScene],
|
[focusMapProperties, previewEditorDocument, readEditorDocument, setMapScene],
|
||||||
@@ -1116,8 +1228,8 @@ export function App() {
|
|||||||
adapter.current.setPaused(true);
|
adapter.current.setPaused(true);
|
||||||
useAppStore.getState().setPaused(true);
|
useAppStore.getState().setPaused(true);
|
||||||
useAppStore.getState().setSelection(null);
|
useAppStore.getState().setSelection(null);
|
||||||
|
setEditorSelection({ kind: 'map', mapAssetId: id });
|
||||||
setRightOpen(true);
|
setRightOpen(true);
|
||||||
setModelControlsTab('map');
|
|
||||||
},
|
},
|
||||||
[activateMapAsset],
|
[activateMapAsset],
|
||||||
);
|
);
|
||||||
@@ -1133,8 +1245,8 @@ export function App() {
|
|||||||
setEditorDocument(null);
|
setEditorDocument(null);
|
||||||
adapter.current.setPaused(true);
|
adapter.current.setPaused(true);
|
||||||
useAppStore.getState().setPaused(true);
|
useAppStore.getState().setPaused(true);
|
||||||
|
setEditorSelection({ kind: 'map', mapAssetId: id });
|
||||||
setRightOpen(true);
|
setRightOpen(true);
|
||||||
setModelControlsTab('map');
|
|
||||||
},
|
},
|
||||||
[setMapScene],
|
[setMapScene],
|
||||||
);
|
);
|
||||||
@@ -1252,6 +1364,8 @@ export function App() {
|
|||||||
setProjectMaps(maps);
|
setProjectMaps(maps);
|
||||||
setCommittedEditorDocuments(manifestEditorDocuments(candidate));
|
setCommittedEditorDocuments(manifestEditorDocuments(candidate));
|
||||||
setMapScene([...placedMapAssetsRef.current, placed], placed.id, selection);
|
setMapScene([...placedMapAssetsRef.current, placed], placed.id, selection);
|
||||||
|
setEditorSelection({ kind: 'map', mapAssetId: placed.id });
|
||||||
|
setRightOpen(true);
|
||||||
setEditorDocument(document);
|
setEditorDocument(document);
|
||||||
previewEditorDocument(document);
|
previewEditorDocument(document);
|
||||||
state.setProject(
|
state.setProject(
|
||||||
@@ -1304,6 +1418,8 @@ export function App() {
|
|||||||
const placed = createPlacedMapAsset(selection, name);
|
const placed = createPlacedMapAsset(selection, name);
|
||||||
const assets = [...placedMapAssetsRef.current, placed];
|
const assets = [...placedMapAssetsRef.current, placed];
|
||||||
setMapScene(assets, placed.id, selection);
|
setMapScene(assets, placed.id, selection);
|
||||||
|
setEditorSelection({ kind: 'map', mapAssetId: placed.id });
|
||||||
|
setRightOpen(true);
|
||||||
if (selection.kind === 'project') previewVisualMapScene(assets, true);
|
if (selection.kind === 'project') previewVisualMapScene(assets, true);
|
||||||
setEditorDocument(readEditorDocument(selection));
|
setEditorDocument(readEditorDocument(selection));
|
||||||
viewer.current?.setMapEditorDocument(null);
|
viewer.current?.setMapEditorDocument(null);
|
||||||
@@ -1321,6 +1437,7 @@ export function App() {
|
|||||||
? (nextAssets.at(-1) ?? undefined)
|
? (nextAssets.at(-1) ?? undefined)
|
||||||
: nextAssets.find((asset) => asset.id === previousActiveId);
|
: nextAssets.find((asset) => asset.id === previousActiveId);
|
||||||
setMapScene(nextAssets, nextActive?.id, nextActive?.selection);
|
setMapScene(nextAssets, nextActive?.id, nextActive?.selection);
|
||||||
|
setEditorSelection(nextActive ? { kind: 'map', mapAssetId: nextActive.id } : null);
|
||||||
setEditorDocument(nextActive ? readEditorDocument(nextActive.selection) : null);
|
setEditorDocument(nextActive ? readEditorDocument(nextActive.selection) : null);
|
||||||
viewer.current?.setMapEditorDocument(null);
|
viewer.current?.setMapEditorDocument(null);
|
||||||
return true;
|
return true;
|
||||||
@@ -1390,6 +1507,12 @@ export function App() {
|
|||||||
}
|
}
|
||||||
editorDraftsRef.current = remainingDrafts;
|
editorDraftsRef.current = remainingDrafts;
|
||||||
setEditorDrafts(remainingDrafts);
|
setEditorDrafts(remainingDrafts);
|
||||||
|
setEditorSessionStates((currentStates) => {
|
||||||
|
const nextStates = new Map(currentStates);
|
||||||
|
for (const path of submittedDrafts.keys())
|
||||||
|
if (!remainingDrafts.has(path)) nextStates.delete(path);
|
||||||
|
return nextStates;
|
||||||
|
});
|
||||||
setProjectMaps(maps);
|
setProjectMaps(maps);
|
||||||
setCommittedEditorDocuments(manifestEditorDocuments(committed));
|
setCommittedEditorDocuments(manifestEditorDocuments(committed));
|
||||||
const committedEditorDocument = readEditorDocument(mapSelectionRef.current);
|
const committedEditorDocument = readEditorDocument(mapSelectionRef.current);
|
||||||
@@ -1406,10 +1529,13 @@ export function App() {
|
|||||||
entry.path,
|
entry.path,
|
||||||
);
|
);
|
||||||
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
||||||
|
const selectedMapAssetId = activeMapAssetIdRef.current;
|
||||||
|
setEditorSelection(selectedMapAssetId ? { kind: 'map', mapAssetId: selectedMapAssetId } : null);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const discardMapSceneDraft = () => {
|
const discardMapSceneDraft = () => {
|
||||||
if (state.loading || loadInFlight.current) return;
|
if (state.loading || loadInFlight.current) return;
|
||||||
|
editorInteraction.current?.onDiscard();
|
||||||
clearEditorDrafts();
|
clearEditorDrafts();
|
||||||
const omittedPaths = new Set<string>();
|
const omittedPaths = new Set<string>();
|
||||||
for (const paths of provisionalMapFilesRef.current.values())
|
for (const paths of provisionalMapFilesRef.current.values())
|
||||||
@@ -1426,11 +1552,13 @@ export function App() {
|
|||||||
restoredManifest.entries,
|
restoredManifest.entries,
|
||||||
state.selectedEntry,
|
state.selectedEntry,
|
||||||
);
|
);
|
||||||
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
||||||
}
|
}
|
||||||
const assets = restoreAppliedMapScene(appliedMapAssetsRef.current);
|
const assets = restoreAppliedMapScene(appliedMapAssetsRef.current);
|
||||||
const active =
|
const active =
|
||||||
assets.find((asset) => asset.id === activeMapAssetIdRef.current) ?? assets.at(-1);
|
assets.find((asset) => asset.id === activeMapAssetIdRef.current) ?? assets.at(-1);
|
||||||
setMapScene(assets, active?.id, active?.selection);
|
setMapScene(assets, active?.id, active?.selection);
|
||||||
|
setEditorSelection(active ? { kind: 'map', mapAssetId: active.id } : null);
|
||||||
previewVisualMapScene(assets, true);
|
previewVisualMapScene(assets, true);
|
||||||
setEditorDocument(active ? readEditorDocument(active.selection) : null);
|
setEditorDocument(active ? readEditorDocument(active.selection) : null);
|
||||||
viewer.current?.setMapEditorDocument(null);
|
viewer.current?.setMapEditorDocument(null);
|
||||||
@@ -1465,6 +1593,7 @@ export function App() {
|
|||||||
);
|
);
|
||||||
const nextAssets = updatePlacedMapAsset(placedMapAssetsRef.current, active.id, value, nextName);
|
const nextAssets = updatePlacedMapAsset(placedMapAssetsRef.current, active.id, value, nextName);
|
||||||
setMapScene(nextAssets, active.id, value);
|
setMapScene(nextAssets, active.id, value);
|
||||||
|
setEditorSelection({ kind: 'map', mapAssetId: active.id });
|
||||||
setEditorDocument(readEditorDocument(value));
|
setEditorDocument(readEditorDocument(value));
|
||||||
viewer.current?.setMapEditorDocument(null);
|
viewer.current?.setMapEditorDocument(null);
|
||||||
void commitMapScene();
|
void commitMapScene();
|
||||||
@@ -1578,6 +1707,10 @@ export function App() {
|
|||||||
entryPath,
|
entryPath,
|
||||||
);
|
);
|
||||||
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
||||||
|
const selectedMapAssetId = activeMapAssetIdRef.current;
|
||||||
|
setEditorSelection(
|
||||||
|
selectedMapAssetId ? { kind: 'map', mapAssetId: selectedMapAssetId } : null,
|
||||||
|
);
|
||||||
notify('已创建可编辑地图副本', authoringPath);
|
notify('已创建可编辑地图副本', authoringPath);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1608,17 +1741,30 @@ export function App() {
|
|||||||
const resetDragState = () => {
|
const resetDragState = () => {
|
||||||
dragDepth.current = 0;
|
dragDepth.current = 0;
|
||||||
setDragActive(false);
|
setDragActive(false);
|
||||||
|
setMapAssetDropTarget(undefined);
|
||||||
};
|
};
|
||||||
const dragEnter = (event: DragEvent) => {
|
const dragEnter = (event: DragEvent) => {
|
||||||
if (
|
if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) {
|
||||||
hasTransferType(event.dataTransfer, 'Files') &&
|
const bounds = viewportShell.current?.getBoundingClientRect();
|
||||||
!hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)
|
if (bounds)
|
||||||
) {
|
setMapAssetDropTarget({
|
||||||
|
left: event.clientX - bounds.left,
|
||||||
|
top: event.clientY - bounds.top,
|
||||||
|
position: viewer.current?.mapPlanePoint(event.clientX, event.clientY) ?? null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasTransferType(event.dataTransfer, 'Files')) {
|
||||||
dragDepth.current += 1;
|
dragDepth.current += 1;
|
||||||
if (!state.loading) setDragActive(true);
|
if (!state.loading) setDragActive(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const dragLeave = (event: DragEvent) => {
|
const dragLeave = (event: DragEvent) => {
|
||||||
|
if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
|
||||||
|
setMapAssetDropTarget(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!hasTransferType(event.dataTransfer, 'Files')) return;
|
if (!hasTransferType(event.dataTransfer, 'Files')) return;
|
||||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||||
if (dragDepth.current === 0) setDragActive(false);
|
if (dragDepth.current === 0) setDragActive(false);
|
||||||
@@ -1626,9 +1772,15 @@ export function App() {
|
|||||||
const dragOver = (event: DragEvent) => {
|
const dragOver = (event: DragEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) {
|
if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) {
|
||||||
event.dataTransfer.dropEffect = viewer.current?.mapPlanePoint(event.clientX, event.clientY)
|
const position = viewer.current?.mapPlanePoint(event.clientX, event.clientY) ?? null;
|
||||||
? 'copy'
|
const bounds = viewportShell.current?.getBoundingClientRect();
|
||||||
: 'none';
|
if (bounds)
|
||||||
|
setMapAssetDropTarget({
|
||||||
|
left: event.clientX - bounds.left,
|
||||||
|
top: event.clientY - bounds.top,
|
||||||
|
position,
|
||||||
|
});
|
||||||
|
event.dataTransfer.dropEffect = position ? 'copy' : 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasTransferType(event.dataTransfer, 'Files'))
|
if (hasTransferType(event.dataTransfer, 'Files'))
|
||||||
@@ -1981,6 +2133,10 @@ export function App() {
|
|||||||
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
|
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
|
||||||
else if (root.current) void root.current.requestFullscreen().catch(() => {});
|
else if (root.current) void root.current.requestFullscreen().catch(() => {});
|
||||||
};
|
};
|
||||||
|
const showWorkspaceTool = (tool: WorkspaceTool) => {
|
||||||
|
setWorkspaceTool(tool);
|
||||||
|
setRightOpen(true);
|
||||||
|
};
|
||||||
const applyLayoutPreset = (preset: LayoutPreset) => {
|
const applyLayoutPreset = (preset: LayoutPreset) => {
|
||||||
if (preset === 'viewport') {
|
if (preset === 'viewport') {
|
||||||
setLeftOpen(false);
|
setLeftOpen(false);
|
||||||
@@ -1993,6 +2149,7 @@ export function App() {
|
|||||||
} else if (preset === 'control') {
|
} else if (preset === 'control') {
|
||||||
setLeftOpen(false);
|
setLeftOpen(false);
|
||||||
setRightOpen(true);
|
setRightOpen(true);
|
||||||
|
setWorkspaceTool('controls');
|
||||||
dispatchLayoutWidths(288, 384);
|
dispatchLayoutWidths(288, 384);
|
||||||
} else {
|
} else {
|
||||||
setLeftOpen(true);
|
setLeftOpen(true);
|
||||||
@@ -2000,19 +2157,36 @@ export function App() {
|
|||||||
dispatchLayoutWidths(288, 288);
|
dispatchLayoutWidths(288, 288);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
useMapEditorShortcuts({
|
||||||
|
enabled: Boolean(state.snapshot),
|
||||||
|
mapEditing: mapEditingActive,
|
||||||
|
dirty: mapSceneDirty,
|
||||||
|
loading: state.loading,
|
||||||
|
hasSelection: Boolean(editorSelection ?? state.selection),
|
||||||
|
canDelete: editorSelection?.kind === 'map-object',
|
||||||
|
canScale: mapSelection.kind === 'project' && selectedMapObject?.placementMode !== 'locked',
|
||||||
|
onTransformMode: changeMapTransformMode,
|
||||||
|
onFocusSelection: focusSelectedObject,
|
||||||
|
onDeleteSelection: deleteSelectedMapObject,
|
||||||
|
onToggleSnapping: () => setMapSnapping((value) => !value),
|
||||||
|
onSave: () => void commitMapScene(),
|
||||||
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const key = (event: KeyboardEvent) => {
|
const key = (event: KeyboardEvent) => {
|
||||||
|
if (event.defaultPrevented) return;
|
||||||
if (
|
if (
|
||||||
document.activeElement instanceof HTMLElement &&
|
document.activeElement instanceof HTMLElement &&
|
||||||
document.activeElement.closest('[role="dialog"]')
|
document.activeElement.closest('[role="dialog"]')
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
const target = event.target instanceof HTMLElement ? event.target : null;
|
||||||
|
if (target?.closest('input,select,textarea,[contenteditable="true"]')) return;
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'k') {
|
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'k') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setCommandOpen(true);
|
setCommandOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ((event.target as HTMLElement).matches('input,select,button')) return;
|
if (target?.closest('button')) return;
|
||||||
if (event.code === 'Space') {
|
if (event.code === 'Space') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
togglePause();
|
togglePause();
|
||||||
@@ -2100,6 +2274,22 @@ export function App() {
|
|||||||
disabled: !state.snapshot,
|
disabled: !state.snapshot,
|
||||||
run: exportMjcf,
|
run: exportMjcf,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'workspace-controls',
|
||||||
|
label: '在右侧打开控制台',
|
||||||
|
group: '工具',
|
||||||
|
icon: <SlidersHorizontal className="h-4 w-4" />,
|
||||||
|
disabled: !state.snapshot,
|
||||||
|
run: () => showWorkspaceTool('controls'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'workspace-data',
|
||||||
|
label: '在右侧打开数据录制',
|
||||||
|
group: '工具',
|
||||||
|
icon: <Database className="h-4 w-4" />,
|
||||||
|
disabled: !state.snapshot,
|
||||||
|
run: () => showWorkspaceTool('data'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'left',
|
id: 'left',
|
||||||
label: leftOpen ? '隐藏工程面板' : '显示工程面板',
|
label: leftOpen ? '隐藏工程面板' : '显示工程面板',
|
||||||
@@ -2109,7 +2299,7 @@ export function App() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'right',
|
id: 'right',
|
||||||
label: rightOpen ? '隐藏属性面板' : '显示属性面板',
|
label: rightOpen ? '隐藏右侧面板' : '显示右侧面板',
|
||||||
group: '布局',
|
group: '布局',
|
||||||
icon: rightOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />,
|
icon: rightOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />,
|
||||||
run: () => setRightOpen((value) => !value),
|
run: () => setRightOpen((value) => !value),
|
||||||
@@ -2136,6 +2326,63 @@ export function App() {
|
|||||||
run: () => setHelpOpen(true),
|
run: () => setHelpOpen(true),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const workspaceTools = workspaceTool ? (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="grid min-h-0 flex-1 place-items-center p-4 text-sm text-text-tertiary"
|
||||||
|
>
|
||||||
|
正在加载工作区工具…
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<WorkspaceToolsPanel
|
||||||
|
active={workspaceTool}
|
||||||
|
snapshot={state.snapshot}
|
||||||
|
loading={state.loading}
|
||||||
|
ignoreJointLimits={ignoreJointLimits}
|
||||||
|
jointAdvanced={jointAdvanced}
|
||||||
|
angleUnit={angleUnit}
|
||||||
|
forceScale={forceScale}
|
||||||
|
controllerPaths={state.files
|
||||||
|
.filter((file) => /\.py$/i.test(file.path))
|
||||||
|
.map((file) => file.path)}
|
||||||
|
selectedControllerPath={selectedControllerPath}
|
||||||
|
controllerStatus={controllerStatus}
|
||||||
|
policyPaths={state.files
|
||||||
|
.filter((file) => /\.onnx$/i.test(file.path))
|
||||||
|
.map((file) => file.path)}
|
||||||
|
selectedPolicyPath={selectedPolicyPath}
|
||||||
|
policyStatus={policyStatus}
|
||||||
|
onResetJoints={resetJoints}
|
||||||
|
onToggleJointLimits={toggleJointLimits}
|
||||||
|
onToggleAdvanced={() => setJointAdvanced((value) => !value)}
|
||||||
|
onToggleAngleUnit={() => setAngleUnit((value) => (value === 'rad' ? 'deg' : 'rad'))}
|
||||||
|
onActuator={setActuator}
|
||||||
|
onActuatorParameters={setActuatorParameters}
|
||||||
|
onJoint={setJoint}
|
||||||
|
onForceScale={setForceScale}
|
||||||
|
onSelectControllerPath={setSelectedControllerPath}
|
||||||
|
onLoadControllerPath={loadControllerPath}
|
||||||
|
onImportController={importController}
|
||||||
|
onToggleController={toggleController}
|
||||||
|
onControllerCommand={sendControllerCommand}
|
||||||
|
onRemoveController={removeController}
|
||||||
|
onSelectPolicyPath={setSelectedPolicyPath}
|
||||||
|
onLoadPolicyPath={loadPolicyPath}
|
||||||
|
onImportPolicy={importPolicy}
|
||||||
|
onTogglePolicy={togglePolicy}
|
||||||
|
onPolicyCommand={setPolicyCommand}
|
||||||
|
onRemovePolicy={removePolicy}
|
||||||
|
onDataRecorderConfigure={configureDataRecorder}
|
||||||
|
onDataRecordingStart={startDataRecording}
|
||||||
|
onDataRecordingStop={stopDataRecording}
|
||||||
|
onDataRecordingClear={clearDataRecording}
|
||||||
|
onDataRecordingExport={exportDataRecording}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : undefined;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={root}
|
ref={root}
|
||||||
@@ -2212,6 +2459,15 @@ export function App() {
|
|||||||
<ViewerToolDock
|
<ViewerToolDock
|
||||||
mode={state.mode}
|
mode={state.mode}
|
||||||
display={displayOptions}
|
display={displayOptions}
|
||||||
|
mapEditContext={
|
||||||
|
mapEditingActive
|
||||||
|
? {
|
||||||
|
active: state.mode === 'select',
|
||||||
|
label: `地图 · ${{ translate: '移动', rotate: '旋转', scale: '缩放' }[mapTransformMode]}`,
|
||||||
|
onActivate: activateMapEditing,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onModeChange={mode}
|
onModeChange={mode}
|
||||||
onDisplayChange={setDisplayOptions}
|
onDisplayChange={setDisplayOptions}
|
||||||
onResetCamera={() => viewer.current?.resetCamera()}
|
onResetCamera={() => viewer.current?.resetCamera()}
|
||||||
@@ -2226,44 +2482,87 @@ export function App() {
|
|||||||
entries={state.entries}
|
entries={state.entries}
|
||||||
selectedEntry={state.selectedEntry}
|
selectedEntry={state.selectedEntry}
|
||||||
snapshot={state.snapshot}
|
snapshot={state.snapshot}
|
||||||
|
selection={editorSelection}
|
||||||
loading={state.loading}
|
loading={state.loading}
|
||||||
nativeUrdf={selectedFormat === 'urdf' && urdfMode === 'native'}
|
nativeUrdf={selectedFormat === 'urdf' && urdfMode === 'native'}
|
||||||
mapSelection={mapSelection}
|
mapSelection={mapSelection}
|
||||||
maps={projectMaps}
|
maps={projectMaps}
|
||||||
placedMaps={placedMapAssets}
|
placedMaps={placedMapAssets}
|
||||||
activeMapId={activeMapAssetId}
|
|
||||||
pendingSceneChangeCount={sceneDraftChangeCount}
|
pendingSceneChangeCount={sceneDraftChangeCount}
|
||||||
pendingSceneIds={pendingSceneIds}
|
pendingSceneIds={pendingSceneIds}
|
||||||
editorDocument={
|
|
||||||
mapSelection.kind === 'project'
|
|
||||||
? (editorDrafts.get(mapSelection.descriptorPath) ?? editorDocument)
|
|
||||||
: editorDocument
|
|
||||||
}
|
|
||||||
editorDocuments={sceneEditorDocuments}
|
editorDocuments={sceneEditorDocuments}
|
||||||
|
assetPlacementMode={assetPlacementMode}
|
||||||
activeTab={projectSidebarTab}
|
activeTab={projectSidebarTab}
|
||||||
onActiveTabChange={setProjectSidebarTab}
|
onActiveTabChange={setProjectSidebarTab}
|
||||||
onRemove={removeProject}
|
onRemove={removeProject}
|
||||||
onSelectEntry={requestLoadEntry}
|
onSelectEntry={requestLoadEntry}
|
||||||
onJointHover={(jointId) => viewer.current?.highlightJoint(jointId)}
|
onSelectBody={(bodyId) => {
|
||||||
|
state.setSelection(null);
|
||||||
|
setEditorSelection({ kind: 'body', bodyId });
|
||||||
|
viewer.current?.selectMapEditorObject(null);
|
||||||
|
viewer.current?.selectParametricMapAsset(null);
|
||||||
|
viewer.current?.highlightJoint(null);
|
||||||
|
setRightOpen(true);
|
||||||
|
}}
|
||||||
|
onSelectJoint={(jointId, bodyId) => {
|
||||||
|
state.setSelection(null);
|
||||||
|
setEditorSelection({ kind: 'joint', jointId, bodyId });
|
||||||
|
viewer.current?.selectMapEditorObject(null);
|
||||||
|
viewer.current?.selectParametricMapAsset(null);
|
||||||
|
viewer.current?.highlightJoint(jointId);
|
||||||
|
setRightOpen(true);
|
||||||
|
}}
|
||||||
|
onJointHover={(jointId) =>
|
||||||
|
viewer.current?.highlightJoint(
|
||||||
|
jointId ?? (editorSelection?.kind === 'joint' ? editorSelection.jointId : null),
|
||||||
|
)
|
||||||
|
}
|
||||||
onAddMapAsset={(type, placementMode) =>
|
onAddMapAsset={(type, placementMode) =>
|
||||||
addCertifiedMapAsset(type, undefined, placementMode)
|
addCertifiedMapAsset(type, undefined, placementMode)
|
||||||
}
|
}
|
||||||
onAddProjectMap={addProjectMapAsset}
|
onAddProjectMap={addProjectMapAsset}
|
||||||
onSelectTerrain={selectTerrainAsset}
|
onSelectTerrain={selectTerrainAsset}
|
||||||
|
onAssetPlacementModeChange={setAssetPlacementMode}
|
||||||
onApplyScene={() => void commitMapScene()}
|
onApplyScene={() => void commitMapScene()}
|
||||||
onDiscardScene={discardMapSceneDraft}
|
onDiscardScene={discardMapSceneDraft}
|
||||||
onSelectMap={activateMapAsset}
|
onSelectMap={activateMapAsset}
|
||||||
onRemoveMap={removePlacedMapAsset}
|
onRemoveMap={removePlacedMapAsset}
|
||||||
onSelectMapObject={(mapId, objectId) => activateMapAsset(mapId, objectId)}
|
onSelectMapObject={(mapId, objectId) => activateMapAsset(mapId, objectId)}
|
||||||
/>
|
/>
|
||||||
<main className="viewport-shell relative min-w-0 flex-1">
|
<main ref={viewportShell} className="viewport-shell relative min-w-0 flex-1">
|
||||||
<div ref={viewerHost} className="absolute inset-0" />
|
<div ref={viewerHost} className="absolute inset-0" />
|
||||||
<ViewportHUD
|
<ViewportHUD
|
||||||
paused={state.paused}
|
paused={state.paused}
|
||||||
mode={state.mode}
|
mode={state.mode}
|
||||||
selection={state.selection}
|
selection={state.selection}
|
||||||
ready={Boolean(state.snapshot)}
|
ready={Boolean(state.snapshot)}
|
||||||
|
mapEditing={mapEditingActive}
|
||||||
/>
|
/>
|
||||||
|
<MapViewportToolbar
|
||||||
|
visible={mapEditingActive && Boolean(state.snapshot)}
|
||||||
|
interactionActive={state.mode === 'select'}
|
||||||
|
mode={mapTransformMode}
|
||||||
|
snapping={mapSnapping}
|
||||||
|
placementMode={activePlacementMode}
|
||||||
|
hasSelectedObject={Boolean(selectedMapObject)}
|
||||||
|
allowScale={
|
||||||
|
mapSelection.kind === 'project' && selectedMapObject?.placementMode !== 'locked'
|
||||||
|
}
|
||||||
|
loading={state.loading}
|
||||||
|
onActivate={activateMapEditing}
|
||||||
|
onModeChange={changeMapTransformMode}
|
||||||
|
onSnappingChange={setMapSnapping}
|
||||||
|
onPlacementModeChange={changeMapPlacementMode}
|
||||||
|
onAlignToSurface={alignSelectedMapObject}
|
||||||
|
/>
|
||||||
|
<MapDraftStatusOverlay
|
||||||
|
visible={Boolean(state.snapshot)}
|
||||||
|
changeCount={sceneDraftChangeCount}
|
||||||
|
loading={state.loading}
|
||||||
|
onCommit={() => void commitMapScene()}
|
||||||
|
onDiscard={discardMapSceneDraft}
|
||||||
|
/>
|
||||||
|
<MapAssetDropIndicator target={mapAssetDropTarget} />
|
||||||
<WorkspaceOverlays
|
<WorkspaceOverlays
|
||||||
loading={state.loading}
|
loading={state.loading}
|
||||||
hasSnapshot={Boolean(state.snapshot)}
|
hasSnapshot={Boolean(state.snapshot)}
|
||||||
@@ -2322,10 +2621,9 @@ export function App() {
|
|||||||
</main>
|
</main>
|
||||||
<ModelControlsSidebar
|
<ModelControlsSidebar
|
||||||
visible={rightOpen}
|
visible={rightOpen}
|
||||||
activeTab={modelControlsTab}
|
|
||||||
onActiveTabChange={setModelControlsTab}
|
|
||||||
snapshot={state.snapshot}
|
snapshot={state.snapshot}
|
||||||
selection={state.selection}
|
selection={editorSelection}
|
||||||
|
viewerSelection={state.selection}
|
||||||
selectedFormat={selectedFormat}
|
selectedFormat={selectedFormat}
|
||||||
loading={state.loading}
|
loading={state.loading}
|
||||||
urdfMode={urdfMode}
|
urdfMode={urdfMode}
|
||||||
@@ -2334,19 +2632,9 @@ export function App() {
|
|||||||
ignoreJointLimits={ignoreJointLimits}
|
ignoreJointLimits={ignoreJointLimits}
|
||||||
jointAdvanced={jointAdvanced}
|
jointAdvanced={jointAdvanced}
|
||||||
angleUnit={angleUnit}
|
angleUnit={angleUnit}
|
||||||
forceScale={forceScale}
|
|
||||||
controllerPaths={state.files
|
|
||||||
.filter((file) => /\.py$/i.test(file.path))
|
|
||||||
.map((file) => file.path)}
|
|
||||||
selectedControllerPath={selectedControllerPath}
|
|
||||||
controllerStatus={controllerStatus}
|
|
||||||
policyPaths={state.files
|
|
||||||
.filter((file) => /\.onnx$/i.test(file.path))
|
|
||||||
.map((file) => file.path)}
|
|
||||||
selectedPolicyPath={selectedPolicyPath}
|
|
||||||
policyStatus={policyStatus}
|
|
||||||
mapSelection={mapSelection}
|
mapSelection={mapSelection}
|
||||||
activeMapAssetId={activeMapAssetId}
|
activeMapAssetId={activeMapAssetId}
|
||||||
|
activeMapAssetName={placedMapAssets.find((asset) => asset.id === activeMapAssetId)?.name}
|
||||||
mapSceneDirty={mapSceneDirty}
|
mapSceneDirty={mapSceneDirty}
|
||||||
maps={projectMaps}
|
maps={projectMaps}
|
||||||
showVisualMap={showVisualMap}
|
showVisualMap={showVisualMap}
|
||||||
@@ -2357,6 +2645,16 @@ export function App() {
|
|||||||
? editorDrafts.get(mapSelection.descriptorPath)
|
? editorDrafts.get(mapSelection.descriptorPath)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
workspaceTool={workspaceTool}
|
||||||
|
workspaceTools={workspaceTools}
|
||||||
|
onWorkspaceToolChange={(tool) => {
|
||||||
|
setWorkspaceTool(tool);
|
||||||
|
if (tool) setRightOpen(true);
|
||||||
|
}}
|
||||||
|
onSelectJoint={(jointId, bodyId) => {
|
||||||
|
setEditorSelection({ kind: 'joint', jointId, bodyId });
|
||||||
|
viewer.current?.highlightJoint(jointId);
|
||||||
|
}}
|
||||||
onUrdfMode={changeUrdfMode}
|
onUrdfMode={changeUrdfMode}
|
||||||
onBaseMode={changeBaseMode}
|
onBaseMode={changeBaseMode}
|
||||||
onShowCollision={setShowCollision}
|
onShowCollision={setShowCollision}
|
||||||
@@ -2367,19 +2665,6 @@ export function App() {
|
|||||||
onActuator={setActuator}
|
onActuator={setActuator}
|
||||||
onActuatorParameters={setActuatorParameters}
|
onActuatorParameters={setActuatorParameters}
|
||||||
onJoint={setJoint}
|
onJoint={setJoint}
|
||||||
onForceScale={setForceScale}
|
|
||||||
onSelectControllerPath={setSelectedControllerPath}
|
|
||||||
onLoadControllerPath={loadControllerPath}
|
|
||||||
onImportController={importController}
|
|
||||||
onToggleController={toggleController}
|
|
||||||
onControllerCommand={sendControllerCommand}
|
|
||||||
onRemoveController={removeController}
|
|
||||||
onSelectPolicyPath={setSelectedPolicyPath}
|
|
||||||
onLoadPolicyPath={loadPolicyPath}
|
|
||||||
onImportPolicy={importPolicy}
|
|
||||||
onTogglePolicy={togglePolicy}
|
|
||||||
onPolicyCommand={setPolicyCommand}
|
|
||||||
onRemovePolicy={removePolicy}
|
|
||||||
onApplyMap={applyMapSelection}
|
onApplyMap={applyMapSelection}
|
||||||
onMapDraft={stageMapSelectionDraft}
|
onMapDraft={stageMapSelectionDraft}
|
||||||
onEditorPreview={previewEditorDocument}
|
onEditorPreview={previewEditorDocument}
|
||||||
@@ -2389,22 +2674,12 @@ export function App() {
|
|||||||
onEditorConvert={convertSelectedMap}
|
onEditorConvert={convertSelectedMap}
|
||||||
onEditorBindInteraction={bindEditorInteraction}
|
onEditorBindInteraction={bindEditorInteraction}
|
||||||
onEditorSelect={selectEditorObject}
|
onEditorSelect={selectEditorObject}
|
||||||
onEditorTransformMode={setEditorTransformMode}
|
onEditorSessionStateChange={updateEditorSessionState}
|
||||||
onEditorSnapping={setEditorSnapping}
|
|
||||||
onEditorSurfaceHeight={editorSurfaceHeight}
|
onEditorSurfaceHeight={editorSurfaceHeight}
|
||||||
onMapDisplay={(visual, collision) => {
|
onMapDisplay={(visual, collision) => {
|
||||||
setShowVisualMap(visual);
|
setShowVisualMap(visual);
|
||||||
setShowMapCollision(collision);
|
setShowMapCollision(collision);
|
||||||
}}
|
}}
|
||||||
onMapTabOpen={() => {
|
|
||||||
setLeftOpen(true);
|
|
||||||
setProjectSidebarTab('assets');
|
|
||||||
}}
|
|
||||||
onDataRecorderConfigure={configureDataRecorder}
|
|
||||||
onDataRecordingStart={startDataRecording}
|
|
||||||
onDataRecordingStop={stopDataRecording}
|
|
||||||
onDataRecordingClear={clearDataRecording}
|
|
||||||
onDataRecordingExport={exportDataRecording}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{pendingUrdfPath && (
|
{pendingUrdfPath && (
|
||||||
|
|||||||
@@ -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 { RLCommand, RLPolicyStatus } from '../../rl/types';
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
CollapsibleSection,
|
|
||||||
CopyButton,
|
|
||||||
PropertyRow,
|
|
||||||
Select,
|
|
||||||
Tabs,
|
|
||||||
} from '../../components/ui';
|
|
||||||
import type { MapSelection, PlacedMapSelection } from '../../map/types';
|
import type { MapSelection, PlacedMapSelection } 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,23 +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;
|
activeMapAssetId?: string;
|
||||||
|
activeMapAssetName?: string;
|
||||||
mapSceneDirty: boolean;
|
mapSceneDirty: boolean;
|
||||||
maps: MapEntry[];
|
maps: MapEntry[];
|
||||||
showVisualMap: boolean;
|
showVisualMap: boolean;
|
||||||
showMapCollision: boolean;
|
showMapCollision: boolean;
|
||||||
editorDocument: EditableMapDocument | null;
|
editorDocument: EditableMapDocument | null;
|
||||||
editorDraftDocument?: EditableMapDocument;
|
editorDraftDocument?: EditableMapDocument;
|
||||||
activeTab?: 'properties' | 'controls' | 'data' | 'map';
|
workspaceTool: WorkspaceTool | null;
|
||||||
onActiveTabChange?: (value: 'properties' | 'controls' | 'data' | 'map') => void;
|
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;
|
||||||
@@ -69,19 +58,6 @@ 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;
|
onMapDraft: (value: PlacedMapSelection) => void;
|
||||||
onEditorPreview: (document: EditableMapDocument | null) => void;
|
onEditorPreview: (document: EditableMapDocument | null) => void;
|
||||||
@@ -95,320 +71,183 @@ interface ModelControlsProps {
|
|||||||
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;
|
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 [internalTab, setInternalTab] = useState<'properties' | 'controls' | 'data' | 'map'>(
|
const snapshot = props.snapshot;
|
||||||
'properties',
|
const activeView = props.workspaceTool ?? 'inspector';
|
||||||
),
|
const title =
|
||||||
tab = props.activeTab ?? internalTab,
|
activeView === 'controls' ? '控制台' : activeView === 'data' ? '数据录制' : '检查器';
|
||||||
s = props.snapshot;
|
const icon =
|
||||||
if (!s)
|
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>
|
<PhysicalMapPanel
|
||||||
<option value="native">MuJoCo 原生 URDF</option>
|
key={`${props.activeMapAssetId ?? 'none'}:${
|
||||||
</Select>
|
props.mapSelection.kind === 'project'
|
||||||
<label className="mt-3 block text-xs text-text-secondary">
|
? props.mapSelection.descriptorPath
|
||||||
<span className="mb-1 block">基座类型</span>
|
: props.mapSelection.kind
|
||||||
<Select
|
}`}
|
||||||
aria-label="URDF 基座类型"
|
value={props.mapSelection}
|
||||||
className="w-full"
|
maps={props.maps}
|
||||||
value={props.baseMode}
|
rootBodies={snapshot.bodies
|
||||||
disabled={props.loading || props.urdfMode === 'native'}
|
.filter(
|
||||||
onChange={(event) => props.onBaseMode(event.target.value as UrdfBaseMode)}
|
(body) =>
|
||||||
>
|
body.id !== 0 && body.parentId === 0 && !body.name.startsWith('__platform_map_'),
|
||||||
<option value="floating">浮动基座(Free Joint)</option>
|
)
|
||||||
<option value="fixed">固定基座(连接世界)</option>
|
.map((body) => body.name)}
|
||||||
</Select>
|
loading={props.loading}
|
||||||
</label>
|
nativeUrdf={props.selectedFormat === 'urdf' && props.urdfMode === 'native'}
|
||||||
<p className="mt-2 text-xs text-text-tertiary">
|
showVisualMap={props.showVisualMap}
|
||||||
MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。
|
showMapCollision={props.showMapCollision}
|
||||||
</p>
|
editorDocument={props.editorDocument}
|
||||||
<Check
|
editorDraftDocument={props.editorDraftDocument}
|
||||||
label="显示碰撞几何"
|
sceneDirty={props.mapSceneDirty}
|
||||||
checked={props.showCollision}
|
onEditorPreview={props.onEditorPreview}
|
||||||
onChange={props.onShowCollision}
|
onEditorDraftChange={props.onEditorDraftChange}
|
||||||
/>
|
onEditorApply={props.onEditorApply}
|
||||||
</CollapsibleSection>
|
onEditorExport={props.onEditorExport}
|
||||||
)}
|
onEditorConvert={props.onEditorConvert}
|
||||||
<CollapsibleSection title="当前选择" defaultOpen>
|
selectedEditorObjectId={
|
||||||
{props.selection ? (
|
selection?.kind === 'map-object' && mapSelected ? selection.objectId : undefined
|
||||||
<div className="text-xs">
|
}
|
||||||
<PropertyRow
|
onEditorBindInteraction={props.onEditorBindInteraction}
|
||||||
label="Body"
|
onEditorSelect={props.onEditorSelect}
|
||||||
value={props.selection.bodyName}
|
onEditorSessionStateChange={props.onEditorSessionStateChange}
|
||||||
action={<CopyButton value={props.selection.bodyName} label="复制 Body 名称" />}
|
onEditorSurfaceHeight={props.onEditorSurfaceHeight}
|
||||||
/>
|
onMapDisplay={props.onMapDisplay}
|
||||||
<PropertyRow
|
onDraft={props.onMapDraft}
|
||||||
label="标识"
|
onApply={props.onApplyMap}
|
||||||
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 = (
|
|
||||||
<>
|
let inspector: ReactNode;
|
||||||
<CollapsibleSection
|
if (selectedBody) {
|
||||||
title="ONNX 强化学习策略"
|
inspector = (
|
||||||
defaultOpen
|
<BodyInspector
|
||||||
badge={s.rlPolicy ? <Badge>{s.rlPolicy.enabled ? '推理' : '停止'}</Badge> : undefined}
|
body={selectedBody}
|
||||||
>
|
snapshot={snapshot}
|
||||||
<RLPolicyPanel
|
viewerSelection={props.viewerSelection}
|
||||||
paths={props.policyPaths}
|
showCollision={props.showCollision}
|
||||||
selectedPath={props.selectedPolicyPath}
|
onShowCollision={props.onShowCollision}
|
||||||
status={props.policyStatus ?? s.rlPolicy}
|
onSelectJoint={props.onSelectJoint}
|
||||||
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) => {
|
|
||||||
setInternalTab(value);
|
|
||||||
props.onActiveTabChange?.(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
|
|
||||||
key={`${props.activeMapAssetId ?? 'none'}:${
|
|
||||||
props.mapSelection.kind === 'project'
|
|
||||||
? props.mapSelection.descriptorPath
|
|
||||||
: props.mapSelection.kind
|
|
||||||
}`}
|
|
||||||
value={props.mapSelection}
|
|
||||||
maps={props.maps}
|
|
||||||
rootBodies={s.bodies
|
|
||||||
.filter(
|
|
||||||
(body) =>
|
|
||||||
body.id !== 0 &&
|
|
||||||
body.parentId === 0 &&
|
|
||||||
!body.name.startsWith('__platform_map_'),
|
|
||||||
)
|
|
||||||
.map((body) => body.name)}
|
|
||||||
loading={props.loading}
|
|
||||||
nativeUrdf={props.selectedFormat === 'urdf' && props.urdfMode === 'native'}
|
|
||||||
showVisualMap={props.showVisualMap}
|
|
||||||
showMapCollision={props.showMapCollision}
|
|
||||||
editorDocument={props.editorDocument}
|
|
||||||
editorDraftDocument={props.editorDraftDocument}
|
|
||||||
sceneDirty={props.mapSceneDirty}
|
|
||||||
onEditorPreview={props.onEditorPreview}
|
|
||||||
onEditorDraftChange={props.onEditorDraftChange}
|
|
||||||
onEditorApply={props.onEditorApply}
|
|
||||||
onEditorExport={props.onEditorExport}
|
|
||||||
onEditorConvert={props.onEditorConvert}
|
|
||||||
onEditorBindInteraction={props.onEditorBindInteraction}
|
|
||||||
onEditorSelect={props.onEditorSelect}
|
|
||||||
onEditorTransformMode={props.onEditorTransformMode}
|
|
||||||
onEditorSnapping={props.onEditorSnapping}
|
|
||||||
onEditorSurfaceHeight={props.onEditorSurfaceHeight}
|
|
||||||
onMapDisplay={props.onMapDisplay}
|
|
||||||
onDraft={props.onMapDraft}
|
|
||||||
onApply={props.onApplyMap}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
} 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,17 +1,13 @@
|
|||||||
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 { MapEntry, 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,
|
||||||
@@ -24,35 +20,42 @@ 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,
|
||||||
maps,
|
maps,
|
||||||
placedMaps,
|
placedMaps,
|
||||||
activeMapId,
|
|
||||||
pendingSceneChangeCount,
|
pendingSceneChangeCount,
|
||||||
pendingSceneIds,
|
pendingSceneIds,
|
||||||
editorDocument,
|
|
||||||
editorDocuments,
|
editorDocuments,
|
||||||
|
assetPlacementMode,
|
||||||
activeTab,
|
activeTab,
|
||||||
onActiveTabChange,
|
onActiveTabChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
onSelectEntry,
|
onSelectEntry,
|
||||||
|
onSelectBody,
|
||||||
|
onSelectJoint,
|
||||||
onJointHover,
|
onJointHover,
|
||||||
onAddMapAsset,
|
onAddMapAsset,
|
||||||
onAddProjectMap,
|
onAddProjectMap,
|
||||||
onSelectTerrain,
|
onSelectTerrain,
|
||||||
|
onAssetPlacementModeChange,
|
||||||
onApplyScene,
|
onApplyScene,
|
||||||
onDiscardScene,
|
onDiscardScene,
|
||||||
onSelectMap,
|
onSelectMap,
|
||||||
@@ -64,21 +67,23 @@ 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;
|
||||||
maps: MapEntry[];
|
maps: MapEntry[];
|
||||||
placedMaps: PlacedMapAsset[];
|
placedMaps: PlacedMapAsset[];
|
||||||
activeMapId?: string;
|
|
||||||
pendingSceneChangeCount: number;
|
pendingSceneChangeCount: number;
|
||||||
pendingSceneIds: string[];
|
pendingSceneIds: string[];
|
||||||
editorDocument: EditableMapDocument | null;
|
editorDocuments: ReadonlyMap<string, EditableMapDocument>;
|
||||||
editorDocuments?: ReadonlyMap<string, EditableMapDocument>;
|
assetPlacementMode?: MapObjectPlacementMode;
|
||||||
activeTab?: 'project' | 'structure' | 'assets';
|
activeTab?: ProjectResourceTab;
|
||||||
onActiveTabChange?: (value: 'project' | 'structure' | 'assets') => void;
|
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,
|
||||||
@@ -86,22 +91,22 @@ export function ProjectSidebar({
|
|||||||
) => void | Promise<void>;
|
) => void | Promise<void>;
|
||||||
onAddProjectMap: (descriptorPath: string) => void;
|
onAddProjectMap: (descriptorPath: string) => void;
|
||||||
onSelectTerrain: (preset: SystemTerrainPreset) => void;
|
onSelectTerrain: (preset: SystemTerrainPreset) => void;
|
||||||
|
onAssetPlacementModeChange?: (mode: MapObjectPlacementMode) => void;
|
||||||
onApplyScene: () => void;
|
onApplyScene: () => void;
|
||||||
onDiscardScene: () => void;
|
onDiscardScene: () => void;
|
||||||
onSelectMap: (id: string) => void;
|
onSelectMap: (id: string) => void;
|
||||||
onRemoveMap: (id: string) => void;
|
onRemoveMap: (id: string) => void;
|
||||||
onSelectMapObject: (mapId: string, objectId: 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">
|
||||||
@@ -109,115 +114,129 @@ 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}
|
||||||
<Tabs
|
firstClassName="flex flex-col"
|
||||||
label="工程侧栏"
|
secondClassName="flex flex-col"
|
||||||
value={tab}
|
first={
|
||||||
onValueChange={(value) => {
|
<>
|
||||||
setInternalTab(value);
|
<div className="flex h-8 shrink-0 items-center gap-2 px-3 text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">
|
||||||
onActiveTabChange?.(value);
|
<Search className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
}}
|
场景大纲
|
||||||
items={[
|
</div>
|
||||||
{
|
<TreeSearchField
|
||||||
value: 'project',
|
value={sceneQuery}
|
||||||
label: '工程',
|
onChange={setSceneQuery}
|
||||||
icon: <FolderTree className="h-3.5 w-3.5" />,
|
resultCount={sceneMatches}
|
||||||
content: (
|
label="搜索场景对象"
|
||||||
<>
|
placeholder="搜索 Body、关节或地图物体…"
|
||||||
<TreeSearchField
|
/>
|
||||||
value={fileQuery}
|
<SceneOutliner
|
||||||
onChange={setFileQuery}
|
snapshot={snapshot}
|
||||||
resultCount={fileMatches}
|
maps={placedMaps}
|
||||||
label="搜索工程文件"
|
documents={editorDocuments}
|
||||||
placeholder="搜索文件或目录…"
|
selection={selection}
|
||||||
/>
|
query={sceneQuery}
|
||||||
<div className="px-2 pb-3">
|
loading={loading}
|
||||||
<ProjectTree
|
pendingSceneChangeCount={pendingSceneChangeCount}
|
||||||
key={projectName}
|
pendingSceneIds={pendingSceneIds}
|
||||||
files={files}
|
onSelectBody={onSelectBody}
|
||||||
entries={entries}
|
onSelectJoint={onSelectJoint}
|
||||||
selectedEntry={selectedEntry}
|
onJointHover={onJointHover}
|
||||||
query={fileQuery}
|
onSelectMap={onSelectMap}
|
||||||
|
onSelectMapObject={onSelectMapObject}
|
||||||
|
onRemoveMap={onRemoveMap}
|
||||||
|
onApplyScene={onApplyScene}
|
||||||
|
onDiscardScene={onDiscardScene}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
second={
|
||||||
|
<Tabs
|
||||||
|
label="资产与工程文件"
|
||||||
|
value={tab}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setInternalTab(value);
|
||||||
|
onActiveTabChange?.(value);
|
||||||
|
}}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
value: 'assets',
|
||||||
|
label: '资产库',
|
||||||
|
icon: <Library className="h-3.5 w-3.5" />,
|
||||||
|
disabled: !snapshot,
|
||||||
|
content: (
|
||||||
|
<MapAssetLibrary
|
||||||
|
disabled={loading || nativeUrdf}
|
||||||
|
terrainSize={
|
||||||
|
mapSelection.kind === 'builtin'
|
||||||
|
? mapSelection.config.size
|
||||||
|
: DEFAULT_PHYSICAL_MAP_CONFIG.size
|
||||||
|
}
|
||||||
|
maps={maps}
|
||||||
|
placementMode={assetPlacementMode}
|
||||||
|
onPlacementModeChange={onAssetPlacementModeChange}
|
||||||
|
onAdd={onAddMapAsset}
|
||||||
|
onAddProjectMap={onAddProjectMap}
|
||||||
|
onSelectTerrain={onSelectTerrain}
|
||||||
/>
|
/>
|
||||||
</div>
|
),
|
||||||
</>
|
},
|
||||||
),
|
{
|
||||||
},
|
value: 'files',
|
||||||
{
|
label: '工程文件',
|
||||||
value: 'structure',
|
icon: <FolderTree className="h-3.5 w-3.5" />,
|
||||||
label: '模型结构',
|
content: (
|
||||||
icon: <Box className="h-3.5 w-3.5" />,
|
<>
|
||||||
disabled: !snapshot,
|
<ProjectBreadcrumb
|
||||||
content: snapshot ? (
|
projectName={projectName}
|
||||||
<>
|
entries={entries}
|
||||||
<TreeSearchField
|
selectedEntry={selectedEntry}
|
||||||
value={structureQuery}
|
loading={loading}
|
||||||
onChange={setStructureQuery}
|
onSelect={onSelectEntry}
|
||||||
resultCount={structureMatches}
|
/>
|
||||||
label="搜索模型结构"
|
<TreeSearchField
|
||||||
placeholder="搜索 Body 或关节…"
|
value={fileQuery}
|
||||||
/>
|
onChange={setFileQuery}
|
||||||
<div className="px-2 pb-3">
|
resultCount={fileMatches}
|
||||||
<ModelStructureTree
|
label="搜索工程文件"
|
||||||
bodies={snapshot.bodies}
|
placeholder="搜索文件或目录…"
|
||||||
joints={snapshot.joints}
|
/>
|
||||||
onJointHover={onJointHover}
|
<div className="px-2 pb-3">
|
||||||
query={structureQuery}
|
<ProjectTree
|
||||||
/>
|
key={projectName}
|
||||||
</div>
|
files={files}
|
||||||
</>
|
entries={entries}
|
||||||
) : (
|
selectedEntry={selectedEntry}
|
||||||
<p className="p-4 text-center text-xs text-text-tertiary">加载模型后显示结构</p>
|
query={fileQuery}
|
||||||
),
|
/>
|
||||||
},
|
</div>
|
||||||
{
|
</>
|
||||||
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}
|
|
||||||
documents={editorDocuments}
|
|
||||||
maps={maps}
|
|
||||||
placedMaps={placedMaps}
|
|
||||||
activeMapId={activeMapId}
|
|
||||||
pendingSceneChangeCount={pendingSceneChangeCount}
|
|
||||||
pendingSceneIds={pendingSceneIds}
|
|
||||||
onAdd={onAddMapAsset}
|
|
||||||
onAddProjectMap={onAddProjectMap}
|
|
||||||
onSelectTerrain={onSelectTerrain}
|
|
||||||
onApplyScene={onApplyScene}
|
|
||||||
onDiscardScene={onDiscardScene}
|
|
||||||
onSelectMap={onSelectMap}
|
|
||||||
onRemoveMap={onRemoveMap}
|
|
||||||
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,14 +59,28 @@ export function ViewportHUD({
|
|||||||
·
|
·
|
||||||
</span>
|
</span>
|
||||||
<span>滚轮缩放</span>
|
<span>滚轮缩放</span>
|
||||||
{mode !== 'select' && (
|
{mapEditing && mode === 'select' ? (
|
||||||
<>
|
<>
|
||||||
<span aria-hidden="true" className="text-border-strong">
|
<span aria-hidden="true" className="text-border-strong">
|
||||||
·
|
·
|
||||||
</span>
|
</span>
|
||||||
<Kbd>1</Kbd>
|
<Kbd>W</Kbd>
|
||||||
<span>旋转视角</span>
|
<Kbd>E</Kbd>
|
||||||
|
<Kbd>R</Kbd>
|
||||||
|
<span>变换</span>
|
||||||
|
<Kbd>F</Kbd>
|
||||||
|
<span>聚焦</span>
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
mode !== 'select' && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden="true" className="text-border-strong">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<Kbd>1</Kbd>
|
||||||
|
<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';
|
||||||
|
|||||||
@@ -16,131 +16,20 @@ const renderLibrary = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
describe('MapAssetLibrary', () => {
|
describe('MapAssetLibrary', () => {
|
||||||
it('把全部来源呈现在同一个放置与应用工作流中', () => {
|
it('只负责地图包、物理原语和预设地形的放置入口', () => {
|
||||||
expect(decodeMapLibraryDragPayload('not-json')).toBeNull();
|
expect(decodeMapLibraryDragPayload('not-json')).toBeNull();
|
||||||
expect(decodeMapLibraryDragPayload('{"kind":"terrain","preset":"unknown"}')).toBeNull();
|
expect(decodeMapLibraryDragPayload('{"kind":"terrain","preset":"unknown"}')).toBeNull();
|
||||||
renderLibrary();
|
renderLibrary();
|
||||||
expect(screen.getByLabelText('地图资产库')).toHaveTextContent('统一地图资产库');
|
const library = screen.getByLabelText('地图资产库');
|
||||||
expect(screen.getByLabelText('地图资产库')).toHaveTextContent('放置 → 预览 → 一次应用');
|
expect(library).toHaveTextContent('场景资产库');
|
||||||
|
expect(library).toHaveTextContent('物理几何原语');
|
||||||
|
expect(library).toHaveTextContent('预设地形');
|
||||||
|
expect(library).not.toHaveTextContent('已放置地图');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('显示已放置地图树并支持选择、删除和重复添加工程地图源', () => {
|
it('地图包使用与资产卡片一致的添加和拖放入口', () => {
|
||||||
const onSelectMap = vi.fn();
|
|
||||||
const onRemoveMap = vi.fn();
|
|
||||||
const onAddProjectMap = vi.fn();
|
|
||||||
render(
|
|
||||||
<MapAssetLibrary
|
|
||||||
disabled={false}
|
|
||||||
terrainSize={8}
|
|
||||||
maps={[
|
|
||||||
{
|
|
||||||
descriptorPath: 'maps/warehouse/map.json',
|
|
||||||
schemaVersion: 1,
|
|
||||||
id: 'warehouse',
|
|
||||||
name: '仓库',
|
|
||||||
spawnPoints: [],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
placedMaps={[
|
|
||||||
{
|
|
||||||
id: 'map-a',
|
|
||||||
name: '仓库',
|
|
||||||
selection: { kind: 'project', descriptorPath: 'maps/warehouse/map.json' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'map-b',
|
|
||||||
name: '粗糙地形',
|
|
||||||
selection: {
|
|
||||||
kind: 'builtin',
|
|
||||||
config: {
|
|
||||||
preset: 'rough',
|
|
||||||
size: 8,
|
|
||||||
friction: 1,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
yawDeg: 0,
|
|
||||||
slopeAngle: 12,
|
|
||||||
stairCount: 8,
|
|
||||||
obstacleCount: 10,
|
|
||||||
seed: 1,
|
|
||||||
terrainDifficulty: 0.5,
|
|
||||||
terrainHorizontalScale: 0.12,
|
|
||||||
terrainVerticalScale: 0.01,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
activeMapId="map-a"
|
|
||||||
onAdd={() => {}}
|
|
||||||
onAddProjectMap={onAddProjectMap}
|
|
||||||
onSelectTerrain={() => {}}
|
|
||||||
onSelectMap={onSelectMap}
|
|
||||||
onRemoveMap={onRemoveMap}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByLabelText('场景资产树')).toHaveTextContent('2 个实例');
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '选择地图实例 粗糙地形' }));
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '删除地图实例 仓库' }));
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '放置工程地图 仓库' }));
|
|
||||||
expect(onSelectMap).toHaveBeenCalledWith('map-b');
|
|
||||||
expect(onRemoveMap).toHaveBeenCalledWith('map-a');
|
|
||||||
expect(onAddProjectMap).toHaveBeenCalledWith('maps/warehouse/map.json');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('场景树为所有地图实例展示认证对象并可跨实例直接选择', () => {
|
|
||||||
const onSelectObject = vi.fn();
|
|
||||||
const editable = {
|
|
||||||
schemaVersion: 1 as const,
|
|
||||||
mapId: 'warehouse',
|
|
||||||
revision: 0,
|
|
||||||
objects: [
|
|
||||||
{
|
|
||||||
id: 'box-1',
|
|
||||||
name: '共享方盒',
|
|
||||||
type: 'box' as const,
|
|
||||||
pose: {
|
|
||||||
position: [0, 0, 0.5] as [number, number, number],
|
|
||||||
quaternion: [1, 0, 0, 0] as [number, number, number, number],
|
|
||||||
},
|
|
||||||
parameters: { sizeX: 1, sizeY: 1, sizeZ: 1 },
|
|
||||||
friction: [1, 0.005, 0.0001] as [number, number, number],
|
|
||||||
rgba: [0.5, 0.6, 0.7, 1] as [number, number, number, number],
|
|
||||||
placementMode: 'auto_ground' as const,
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
spawnPoints: [],
|
|
||||||
};
|
|
||||||
render(
|
|
||||||
<MapAssetLibrary
|
|
||||||
disabled={false}
|
|
||||||
terrainSize={8}
|
|
||||||
documents={new Map([['maps/warehouse/map.json', editable]])}
|
|
||||||
placedMaps={[
|
|
||||||
{
|
|
||||||
id: 'map-a',
|
|
||||||
name: '仓库 A',
|
|
||||||
selection: { kind: 'project', descriptorPath: 'maps/warehouse/map.json' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'map-b',
|
|
||||||
name: '仓库 B',
|
|
||||||
selection: { kind: 'project', descriptorPath: 'maps/warehouse/map.json' },
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
activeMapId="map-a"
|
|
||||||
onAdd={() => {}}
|
|
||||||
onSelectTerrain={() => {}}
|
|
||||||
onSelectObject={onSelectObject}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByLabelText('场景资产树')).toHaveTextContent('2 个实例 · 2 个对象');
|
|
||||||
fireEvent.click(screen.getAllByRole('button', { name: /共享方盒/ })[1]);
|
|
||||||
expect(onSelectObject).toHaveBeenCalledWith('map-b', 'box-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('工程地图源使用与资产卡片一致的拖放入口', () => {
|
|
||||||
const values = new Map<string, string>();
|
const values = new Map<string, string>();
|
||||||
|
const onAddProjectMap = vi.fn();
|
||||||
const dataTransfer = {
|
const dataTransfer = {
|
||||||
effectAllowed: 'none',
|
effectAllowed: 'none',
|
||||||
setData: (type: string, value: string) => values.set(type, value),
|
setData: (type: string, value: string) => values.set(type, value),
|
||||||
@@ -159,20 +48,21 @@ describe('MapAssetLibrary', () => {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onAdd={() => {}}
|
onAdd={() => {}}
|
||||||
|
onAddProjectMap={onAddProjectMap}
|
||||||
onSelectTerrain={() => {}}
|
onSelectTerrain={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const source = document.querySelector('[data-project-map="maps/warehouse/map.json"]')!;
|
const source = document.querySelector('[data-project-map="maps/warehouse/map.json"]')!;
|
||||||
expect(source).toHaveAttribute('draggable', 'true');
|
|
||||||
fireEvent.dragStart(source, { dataTransfer });
|
fireEvent.dragStart(source, { dataTransfer });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '放置工程地图 仓库' }));
|
||||||
expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({
|
expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({
|
||||||
kind: 'project',
|
kind: 'project',
|
||||||
descriptorPath: 'maps/warehouse/map.json',
|
descriptorPath: 'maps/warehouse/map.json',
|
||||||
});
|
});
|
||||||
expect(values.get('text/plain')).toBe('仓库');
|
expect(onAddProjectMap).toHaveBeenCalledWith('maps/warehouse/map.json');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('按所选放置方式添加认证资产', () => {
|
it('按所选放置方式添加物理几何原语', () => {
|
||||||
const onAdd = vi.fn();
|
const onAdd = vi.fn();
|
||||||
renderLibrary(onAdd);
|
renderLibrary(onAdd);
|
||||||
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
|
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
|
||||||
@@ -182,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',
|
||||||
@@ -200,76 +109,21 @@ describe('MapAssetLibrary', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('以示意图卡片选择系统参数化地形', () => {
|
it('以示意图卡片添加或拖动预设地形', () => {
|
||||||
const onSelectTerrain = vi.fn();
|
const onSelectTerrain = vi.fn();
|
||||||
renderLibrary(undefined, onSelectTerrain);
|
|
||||||
expect(screen.getAllByText('8.00 × 8.00 m')).toHaveLength(9);
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '添加随机粗糙地形' }));
|
|
||||||
expect(onSelectTerrain).toHaveBeenCalledWith('rough');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('可把参数化地形拖到画布,并写入独立拖放类型', () => {
|
|
||||||
const values = new Map<string, string>();
|
const values = new Map<string, string>();
|
||||||
const dataTransfer = {
|
const dataTransfer = {
|
||||||
effectAllowed: 'none',
|
effectAllowed: 'none',
|
||||||
setData: (type: string, value: string) => values.set(type, value),
|
setData: (type: string, value: string) => values.set(type, value),
|
||||||
} as unknown as DataTransfer;
|
} as unknown as DataTransfer;
|
||||||
renderLibrary();
|
renderLibrary(undefined, onSelectTerrain);
|
||||||
const terrain = document.querySelector('[data-system-terrain="rough"]')!;
|
expect(screen.getAllByText('8.00 × 8.00 m')).toHaveLength(9);
|
||||||
expect(terrain).toHaveAttribute('draggable', 'true');
|
fireEvent.click(screen.getByRole('button', { name: '添加随机粗糙地形' }));
|
||||||
fireEvent.dragStart(terrain, { dataTransfer });
|
fireEvent.dragStart(document.querySelector('[data-system-terrain="wave"]')!, { dataTransfer });
|
||||||
|
expect(onSelectTerrain).toHaveBeenCalledWith('rough');
|
||||||
expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({
|
expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({
|
||||||
kind: 'terrain',
|
kind: 'terrain',
|
||||||
preset: 'rough',
|
preset: 'wave',
|
||||||
});
|
});
|
||||||
expect(values.get('text/plain')).toBe('随机粗糙地形');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('集中显示认证资产与参数地形更改,并只触发一次场景编译', () => {
|
|
||||||
const onApplyScene = vi.fn();
|
|
||||||
const onDiscardScene = vi.fn();
|
|
||||||
render(
|
|
||||||
<MapAssetLibrary
|
|
||||||
disabled={false}
|
|
||||||
terrainSize={8}
|
|
||||||
pendingSceneChangeCount={3}
|
|
||||||
pendingSceneIds={['terrain-a']}
|
|
||||||
placedMaps={[
|
|
||||||
{
|
|
||||||
id: 'terrain-a',
|
|
||||||
name: '粗糙地形',
|
|
||||||
selection: {
|
|
||||||
kind: 'builtin',
|
|
||||||
config: {
|
|
||||||
preset: 'rough',
|
|
||||||
size: 8,
|
|
||||||
friction: 1,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
yawDeg: 0,
|
|
||||||
slopeAngle: 12,
|
|
||||||
stairCount: 8,
|
|
||||||
obstacleCount: 10,
|
|
||||||
seed: 1,
|
|
||||||
terrainDifficulty: 0.5,
|
|
||||||
terrainHorizontalScale: 0.12,
|
|
||||||
terrainVerticalScale: 0.01,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onAdd={() => {}}
|
|
||||||
onSelectTerrain={() => {}}
|
|
||||||
onApplyScene={onApplyScene}
|
|
||||||
onDiscardScene={onDiscardScene}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByRole('status')).toHaveTextContent('3 项场景更改待应用');
|
|
||||||
expect(screen.getByRole('status')).toHaveTextContent('认证资产与参数地形共享同一草稿');
|
|
||||||
expect(screen.getByLabelText('场景资产树')).toHaveTextContent('待应用');
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '一次编译应用' }));
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '放弃场景更改' }));
|
|
||||||
expect(onApplyScene).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onDiscardScene).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import { BadgeCheck, CheckCircle2, MapPinned, Mountain, Plus } from 'lucide-react';
|
||||||
BadgeCheck,
|
|
||||||
CheckCircle2,
|
|
||||||
Layers3,
|
|
||||||
MapPinned,
|
|
||||||
Mountain,
|
|
||||||
Plus,
|
|
||||||
RotateCcw,
|
|
||||||
Trash2,
|
|
||||||
Zap,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Button, Select } from '../components/ui';
|
import { Button, Select } from '../components/ui';
|
||||||
import {
|
import {
|
||||||
CERTIFIED_MAP_ASSETS,
|
CERTIFIED_MAP_ASSETS,
|
||||||
@@ -18,7 +8,6 @@ import {
|
|||||||
} 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';
|
||||||
@@ -26,7 +15,6 @@ import type { MapEntry } from '../project/types';
|
|||||||
import {
|
import {
|
||||||
PHYSICAL_MAP_PRESET_LABELS,
|
PHYSICAL_MAP_PRESET_LABELS,
|
||||||
SYSTEM_TERRAIN_PRESETS,
|
SYSTEM_TERRAIN_PRESETS,
|
||||||
type PlacedMapAsset,
|
|
||||||
type SystemTerrainPreset,
|
type SystemTerrainPreset,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -147,182 +135,46 @@ function AddAction({ label }: { label: string }) {
|
|||||||
export function MapAssetLibrary({
|
export function MapAssetLibrary({
|
||||||
disabled,
|
disabled,
|
||||||
terrainSize,
|
terrainSize,
|
||||||
document,
|
|
||||||
documents,
|
|
||||||
maps = [],
|
maps = [],
|
||||||
placedMaps = [],
|
placementMode: controlledPlacementMode,
|
||||||
activeMapId,
|
onPlacementModeChange,
|
||||||
pendingSceneChangeCount = 0,
|
|
||||||
pendingSceneIds = [],
|
|
||||||
onAdd,
|
onAdd,
|
||||||
onAddProjectMap,
|
onAddProjectMap,
|
||||||
onSelectTerrain,
|
onSelectTerrain,
|
||||||
onApplyScene,
|
|
||||||
onDiscardScene,
|
|
||||||
onSelectMap,
|
|
||||||
onRemoveMap,
|
|
||||||
onSelectObject,
|
|
||||||
}: {
|
}: {
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
terrainSize: number;
|
terrainSize: number;
|
||||||
document?: EditableMapDocument | null;
|
|
||||||
documents?: ReadonlyMap<string, EditableMapDocument>;
|
|
||||||
maps?: MapEntry[];
|
maps?: MapEntry[];
|
||||||
placedMaps?: PlacedMapAsset[];
|
placementMode?: MapObjectPlacementMode;
|
||||||
activeMapId?: string;
|
onPlacementModeChange?: (mode: MapObjectPlacementMode) => void;
|
||||||
pendingSceneChangeCount?: number;
|
|
||||||
pendingSceneIds?: string[];
|
|
||||||
onAdd: (
|
onAdd: (
|
||||||
type: EditableMapObjectType,
|
type: EditableMapObjectType,
|
||||||
placementMode: MapObjectPlacementMode,
|
placementMode: MapObjectPlacementMode,
|
||||||
) => void | Promise<void>;
|
) => void | Promise<void>;
|
||||||
onAddProjectMap?: (descriptorPath: string) => void;
|
onAddProjectMap?: (descriptorPath: string) => void;
|
||||||
onSelectTerrain: (preset: SystemTerrainPreset) => void;
|
onSelectTerrain: (preset: SystemTerrainPreset) => void;
|
||||||
onApplyScene?: () => void;
|
|
||||||
onDiscardScene?: () => void;
|
|
||||||
onSelectMap?: (id: string) => void;
|
|
||||||
onRemoveMap?: (id: string) => void;
|
|
||||||
onSelectObject?: (mapId: string, objectId: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [placementMode, setPlacementMode] = useState<MapObjectPlacementMode>('auto_ground');
|
const [internalPlacementMode, setInternalPlacementMode] =
|
||||||
const pendingSceneIdSet = new Set(pendingSceneIds);
|
useState<MapObjectPlacementMode>('auto_ground');
|
||||||
const placedObjectCount = placedMaps.reduce(
|
const placementMode = controlledPlacementMode ?? internalPlacementMode;
|
||||||
(total, map) =>
|
const changePlacementMode = (mode: MapObjectPlacementMode) => {
|
||||||
total +
|
setInternalPlacementMode(mode);
|
||||||
(map.selection.kind === 'project'
|
onPlacementModeChange?.(mode);
|
||||||
? (documents?.get(map.selection.descriptorPath)?.objects.length ?? 0)
|
};
|
||||||
: 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="地图资产库" className="space-y-3 p-3">
|
<section aria-label="地图资产库" className="space-y-3 p-3">
|
||||||
<header>
|
<header>
|
||||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-text-primary">
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-text-primary">
|
||||||
<MapPinned className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
|
<MapPinned 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>
|
||||||
</header>
|
</header>
|
||||||
<div
|
|
||||||
className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5"
|
|
||||||
aria-label="场景资产树"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<Layers3 className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
|
|
||||||
已放置地图
|
|
||||||
</span>
|
|
||||||
<span className="technical-value text-[10px] text-text-tertiary">
|
|
||||||
{placedMaps.length} 个实例
|
|
||||||
{placedObjectCount ? ` · ${placedObjectCount} 个对象` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{placedMaps.length ? (
|
|
||||||
<div className="mt-2 max-h-52 space-y-0.5 overflow-auto panel-scroll" role="tree">
|
|
||||||
{placedMaps.map((map) => {
|
|
||||||
const active = map.id === activeMapId;
|
|
||||||
const mapDocument =
|
|
||||||
map.selection.kind === 'project'
|
|
||||||
? (documents?.get(map.selection.descriptorPath) ?? (active ? document : null))
|
|
||||||
: null;
|
|
||||||
return (
|
|
||||||
<div key={map.id} role="treeitem" aria-selected={active}>
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-1 rounded ${active ? 'bg-accent-soft text-accent' : 'text-text-secondary hover:bg-element-hover'}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-[11px] disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
aria-label={`选择地图实例 ${map.name}`}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => onSelectMap?.(map.id)}
|
|
||||||
>
|
|
||||||
<MapPinned className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
|
||||||
<span className="min-w-0 flex-1 truncate">{map.name}</span>
|
|
||||||
<span className="text-[9px] text-text-tertiary">
|
|
||||||
{pendingSceneIdSet.has(map.id)
|
|
||||||
? '待应用'
|
|
||||||
: map.selection.kind === 'builtin'
|
|
||||||
? '参数地形'
|
|
||||||
: '工程地图'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mr-1 rounded p-1 text-text-tertiary hover:bg-danger/10 hover:text-danger"
|
|
||||||
aria-label={`删除地图实例 ${map.name}`}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => onRemoveMap?.(map.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{mapDocument?.objects.length ? (
|
|
||||||
<div className="ml-4 border-l border-border pl-1" role="group">
|
|
||||||
{mapDocument.objects.map((object) => (
|
|
||||||
<button
|
|
||||||
key={object.id}
|
|
||||||
type="button"
|
|
||||||
className={`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-[10px] hover:bg-element-hover hover:text-text-primary ${active ? 'text-text-secondary' : 'text-text-tertiary'}`}
|
|
||||||
onClick={() => onSelectObject?.(map.id, object.id)}
|
|
||||||
>
|
|
||||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />
|
|
||||||
<span className="min-w-0 flex-1 truncate">{object.name}</span>
|
|
||||||
<span className="text-[9px] text-text-tertiary">{object.type}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
|
|
||||||
尚未放置地图。从下方工程地图、认证资产或参数地形添加,实例会在这里统一管理。
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pendingSceneChangeCount > 0 && (
|
|
||||||
<div
|
|
||||||
role="status"
|
|
||||||
className="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>
|
|
||||||
<p className="mt-1 leading-relaxed">
|
|
||||||
工程地图、认证资产与参数地形共享同一草稿;继续编辑不会触发 MuJoCo 编译。
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
className="min-w-0 flex-1"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onApplyScene}
|
|
||||||
>
|
|
||||||
一次编译应用
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
aria-label="放弃场景更改"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onDiscardScene}
|
|
||||||
>
|
|
||||||
<RotateCcw className="mr-1 h-3 w-3" aria-hidden="true" />
|
|
||||||
放弃
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{maps.length > 0 && (
|
{maps.length > 0 && (
|
||||||
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
|
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
|
||||||
<div className="text-xs font-medium text-text-primary">工程地图源</div>
|
<div className="text-xs font-medium text-text-primary">地图包</div>
|
||||||
<p className="mt-1 text-[10px] text-text-tertiary">
|
<p className="mt-1 text-[10px] text-text-tertiary">
|
||||||
点击添加或拖到画布定位;实例位姿独立,源内容由同源实例共享。
|
点击添加或拖到画布定位;实例位姿独立,源内容由同源实例共享。
|
||||||
</p>
|
</p>
|
||||||
@@ -370,7 +222,7 @@ export function MapAssetLibrary({
|
|||||||
<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">
|
||||||
点击添加,或按住资产拖到画布落位。
|
点击添加,或按住资产拖到画布落位。
|
||||||
@@ -383,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}>
|
||||||
@@ -444,7 +296,7 @@ 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">
|
||||||
点击添加或拖到画布定位。新增先进入轻量预览,可连续放置后一次编译。
|
点击添加或拖到画布定位。新增先进入轻量预览,可连续放置后一次编译。
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ const interactionProps = {
|
|||||||
canConvert: true,
|
canConvert: true,
|
||||||
onBindInteraction: () => {},
|
onBindInteraction: () => {},
|
||||||
onSelectPreview: () => {},
|
onSelectPreview: () => {},
|
||||||
onTransformMode: () => {},
|
onSessionStateChange: () => {},
|
||||||
onSnapping: () => {},
|
|
||||||
};
|
};
|
||||||
const document: EditableMapDocument = {
|
const document: EditableMapDocument = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -114,22 +113,61 @@ describe('MapEditorPanel', () => {
|
|||||||
expect(screen.getByLabelText('对象名称')).toHaveValue('认证方盒');
|
expect(screen.getByLabelText('对象名称')).toHaveValue('认证方盒');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('锁定放置方式后禁用位姿编辑', () => {
|
it('视口贴地命令通过会话端口锁定位姿编辑', async () => {
|
||||||
|
let interaction: MapEditorInteractionCallbacks | null = null;
|
||||||
|
let selectedId: string | null = null;
|
||||||
render(
|
render(
|
||||||
<MapEditorPanel
|
<MapEditorPanel
|
||||||
{...interactionProps}
|
{...interactionProps}
|
||||||
document={document}
|
document={document}
|
||||||
loading={false}
|
loading={false}
|
||||||
onPreview={() => {}}
|
onPreview={() => {}}
|
||||||
|
onSelectPreview={(id) => {
|
||||||
|
selectedId = id;
|
||||||
|
}}
|
||||||
|
onBindInteraction={(callbacks) => {
|
||||||
|
interaction = callbacks;
|
||||||
|
}}
|
||||||
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(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,17 +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 { MapTransformToolbar } from './MapTransformToolbar';
|
import type {
|
||||||
import {
|
EditableMapDocument,
|
||||||
editableObjectGroundHeight,
|
EditableMapObject,
|
||||||
MAP_OBJECT_PLACEMENT_LABELS,
|
EditableMapObjectType,
|
||||||
type EditableMapDocument,
|
MapEditorInteractionCallbacks,
|
||||||
type EditableMapObject,
|
MapEditorSessionState,
|
||||||
type EditableMapObjectType,
|
|
||||||
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: '方盒',
|
||||||
@@ -20,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],
|
||||||
@@ -65,10 +58,10 @@ export function MapEditorPanel({
|
|||||||
onExport,
|
onExport,
|
||||||
onConvert,
|
onConvert,
|
||||||
canConvert,
|
canConvert,
|
||||||
|
selectedObjectId,
|
||||||
onBindInteraction,
|
onBindInteraction,
|
||||||
onSelectPreview,
|
onSelectPreview,
|
||||||
onTransformMode,
|
onSessionStateChange,
|
||||||
onSnapping,
|
|
||||||
onSurfaceHeight,
|
onSurfaceHeight,
|
||||||
}: {
|
}: {
|
||||||
document: EditableMapDocument | null;
|
document: EditableMapDocument | null;
|
||||||
@@ -81,10 +74,10 @@ export function MapEditorPanel({
|
|||||||
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;
|
onSurfaceHeight?: (position: readonly [number, number, number]) => number | null;
|
||||||
}) {
|
}) {
|
||||||
// draftDocument 仅用于恢复重新挂载的编辑器;会话存活期间由 MapEditSession 持有最新草稿。
|
// draftDocument 仅用于恢复重新挂载的编辑器;会话存活期间由 MapEditSession 持有最新草稿。
|
||||||
@@ -95,30 +88,71 @@ export function MapEditorPanel({
|
|||||||
[document, descriptorPath, onSurfaceHeight],
|
[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;
|
const next = session?.document ?? null;
|
||||||
render((value) => value + 1);
|
render((value) => value + 1);
|
||||||
onPreview(next);
|
onPreview(next);
|
||||||
if (session && next) onDraftChange(descriptorPath, next, session.dirty);
|
if (session && next) onDraftChange(descriptorPath, next, session.dirty);
|
||||||
|
reportSessionState();
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const next = session?.document ?? null;
|
const next = session?.document ?? null;
|
||||||
onPreview(next);
|
onPreview(next);
|
||||||
if (session && next) onDraftChange(descriptorPath, next, session.dirty);
|
if (session && next) onDraftChange(descriptorPath, next, session.dirty);
|
||||||
return () => onPreview(null);
|
onSessionStateChange?.(
|
||||||
}, [session, descriptorPath, onPreview, onDraftChange]);
|
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;
|
if (loading) return;
|
||||||
const object = session.document.objects.find((item) => item.id === id);
|
const object = session.document.objects.find((item) => item.id === id);
|
||||||
@@ -127,20 +161,46 @@ export function MapEditorPanel({
|
|||||||
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);
|
|
||||||
onDraftChange(descriptorPath, session.document, session.dirty);
|
|
||||||
},
|
},
|
||||||
onAddAsset: (type, position, placementMode, externalSupportTop) => {
|
onAddAsset: (type, position, placementMode, externalSupportTop) => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
const object = session.addAsset(type, position, placementMode, externalSupportTop);
|
const object = session.addAsset(type, position, placementMode, externalSupportTop);
|
||||||
setSelected(object.id);
|
setSelected(object.id);
|
||||||
render((value) => value + 1);
|
publish();
|
||||||
onPreview(session.document);
|
|
||||||
onDraftChange(descriptorPath, session.document, session.dirty);
|
|
||||||
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);
|
||||||
@@ -152,14 +212,11 @@ export function MapEditorPanel({
|
|||||||
onPreview,
|
onPreview,
|
||||||
onDraftChange,
|
onDraftChange,
|
||||||
onSelectPreview,
|
onSelectPreview,
|
||||||
|
onSessionStateChange,
|
||||||
]);
|
]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session && selected) onSelectPreview(selected);
|
if (session && selectedId) onSelectPreview(selectedId);
|
||||||
}, [session, selected, onSelectPreview]);
|
}, [session, selectedId, onSelectPreview]);
|
||||||
useEffect(() => {
|
|
||||||
onTransformMode(transformMode);
|
|
||||||
onSnapping(snapping ? 0.1 : null, snapping ? 5 : null);
|
|
||||||
}, [transformMode, snapping, onTransformMode, onSnapping]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const keydown = (event: KeyboardEvent) => {
|
const keydown = (event: KeyboardEvent) => {
|
||||||
@@ -172,38 +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);
|
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);
|
||||||
onDraftChange(descriptorPath, session.document, session.dirty);
|
onDraftChange(descriptorPath, session.document, session.dirty);
|
||||||
} else if (!event.ctrlKey && !event.metaKey && key === 'w') setTransformMode('translate');
|
onSessionStateChange?.(descriptorPath, {
|
||||||
else if (!event.ctrlKey && !event.metaKey && key === 'e') setTransformMode('rotate');
|
dirty: session.dirty,
|
||||||
else if (!event.ctrlKey && !event.metaKey && key === 's') setTransformMode('scale');
|
changeCount: session.changeCount,
|
||||||
else if (event.key === 'Escape') {
|
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);
|
|
||||||
onDraftChange(descriptorPath, session.document, session.dirty);
|
|
||||||
onSelectPreview(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', keydown);
|
window.addEventListener('keydown', keydown);
|
||||||
return () => window.removeEventListener('keydown', keydown);
|
return () => window.removeEventListener('keydown', keydown);
|
||||||
}, [session, descriptorPath, selected, loading, onPreview, onDraftChange, 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">
|
||||||
@@ -231,41 +298,7 @@ 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 (loading || !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 (loading || !active || !Number.isFinite(value) || value <= 0) return;
|
|
||||||
session.update(active.id, {
|
|
||||||
parameters: { ...active.parameters, [key]: key === 'count' ? Math.round(value) : value },
|
|
||||||
});
|
|
||||||
refresh();
|
|
||||||
};
|
|
||||||
const updateFriction = (index: number, value: number) => {
|
|
||||||
if (loading || !active || !Number.isFinite(value) || value < 0) return;
|
|
||||||
const friction = [...active.friction] as [number, number, number];
|
|
||||||
friction[index] = value;
|
|
||||||
session.update(active.id, { friction });
|
|
||||||
refresh();
|
|
||||||
};
|
|
||||||
const updateColor = (value: string) => {
|
|
||||||
if (loading || !active || !/^#[0-9a-f]{6}$/i.test(value)) return;
|
|
||||||
session.update(active.id, {
|
|
||||||
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,
|
|
||||||
active.rgba[3],
|
|
||||||
],
|
|
||||||
});
|
|
||||||
refresh();
|
|
||||||
};
|
|
||||||
const updateSpawnPosition = (
|
const updateSpawnPosition = (
|
||||||
id: string,
|
id: string,
|
||||||
position: [number, number, number],
|
position: [number, number, number],
|
||||||
@@ -281,281 +314,79 @@ export function MapEditorPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<MapTransformToolbar
|
{active && (
|
||||||
mode={transformMode}
|
<MapObjectInspector
|
||||||
snapping={snapping}
|
object={active}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
hint="在视口点击对象后拖动操纵轴;点击空白区域取消选择。只允许绕世界 Z 轴旋转,缩放会写入原语尺寸。"
|
onUpdate={(patch) => {
|
||||||
onModeChange={setTransformMode}
|
session.update(active.id, patch);
|
||||||
onSnappingChange={setSnapping}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select
|
|
||||||
aria-label="新增地图对象类型"
|
|
||||||
className="min-w-0 flex-1"
|
|
||||||
value={selectedType}
|
|
||||||
disabled={loading}
|
|
||||||
onChange={(event) => setSelectedType(event.target.value as EditableMapObjectType)}
|
|
||||||
>
|
|
||||||
{(Object.keys(labels) as EditableMapObjectType[]).map((type) => (
|
|
||||||
<option key={type} value={type}>
|
|
||||||
{labels[type]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => {
|
|
||||||
const object = session.add(selectedType);
|
|
||||||
setSelected(object.id);
|
|
||||||
refresh();
|
refresh();
|
||||||
onSelectPreview(object.id);
|
|
||||||
}}
|
}}
|
||||||
>
|
onDuplicate={() => {
|
||||||
新增
|
const copy = session.duplicate(active.id);
|
||||||
</Button>
|
setSelected(copy.id);
|
||||||
</div>
|
refresh();
|
||||||
<div className="max-h-36 space-y-1 overflow-auto" aria-label="地图对象列表">
|
onSelectPreview(copy.id);
|
||||||
{current.objects.map((object) => (
|
}}
|
||||||
<button
|
onDelete={() => {
|
||||||
key={object.id}
|
session.remove(active.id);
|
||||||
type="button"
|
setSelected(undefined);
|
||||||
className={`w-full rounded border px-2 py-1 text-left text-xs ${selected === object.id ? 'border-accent bg-accent/10' : 'border-border-subtle'}`}
|
refresh();
|
||||||
|
onSelectPreview(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="space-y-2 rounded-lg border border-border-subtle p-2.5">
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-text-tertiary">
|
||||||
|
地图对象
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
aria-label="新增地图对象类型"
|
||||||
|
className="min-w-0 flex-1"
|
||||||
|
value={selectedType}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={(event) => setSelectedType(event.target.value as EditableMapObjectType)}
|
||||||
|
>
|
||||||
|
{(Object.keys(labels) as EditableMapObjectType[]).map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{labels[type]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
disabled={loading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const object = session.add(selectedType);
|
||||||
setSelected(object.id);
|
setSelected(object.id);
|
||||||
|
refresh();
|
||||||
onSelectPreview(object.id);
|
onSelectPreview(object.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{object.name} · {labels[object.type]}
|
新增
|
||||||
{object.placementMode === 'locked' ? ' · 已锁定' : ''}
|
</Button>
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{!current.objects.length && <div className="text-xs text-text-tertiary">暂无对象</div>}
|
|
||||||
</div>
|
|
||||||
{active && (
|
|
||||||
<div className="space-y-2 rounded border border-border-subtle p-2">
|
|
||||||
<label className="block text-[11px] text-text-secondary">
|
|
||||||
名称
|
|
||||||
<input
|
|
||||||
aria-label="对象名称"
|
|
||||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
|
||||||
value={active.name}
|
|
||||||
disabled={loading}
|
|
||||||
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>
|
|
||||||
<label className="block text-[11px] text-text-secondary">
|
|
||||||
绕 Z 旋转(°)
|
|
||||||
<input
|
|
||||||
aria-label="对象绕Z旋转"
|
|
||||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
|
||||||
type="number"
|
|
||||||
step="1"
|
|
||||||
value={Number(yawDegrees(active).toFixed(4))}
|
|
||||||
disabled={loading || poseLocked}
|
|
||||||
onChange={(event) => {
|
|
||||||
const value = Number(event.target.value);
|
|
||||||
if (!Number.isFinite(value)) return;
|
|
||||||
const half = (value * Math.PI) / 360;
|
|
||||||
session.update(active.id, {
|
|
||||||
pose: { ...active.pose, quaternion: [Math.cos(half), 0, 0, Math.sin(half)] },
|
|
||||||
});
|
|
||||||
refresh();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{Object.entries(active.parameters).map(([key, value]) => (
|
|
||||||
<label key={key} className="text-[11px] text-text-secondary">
|
|
||||||
{key}
|
|
||||||
<input
|
|
||||||
aria-label={`对象参数${key}`}
|
|
||||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
|
||||||
type="number"
|
|
||||||
min="0.001"
|
|
||||||
step={key === 'count' ? 1 : 0.1}
|
|
||||||
value={value}
|
|
||||||
disabled={loading}
|
|
||||||
onChange={(event) => updateParameter(key, Number(event.target.value))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="rounded border border-border-subtle p-2">
|
|
||||||
<div className="mb-2 text-[10px] font-medium text-text-primary">物理与外观</div>
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
{active.friction.map((value, index) => (
|
|
||||||
<label key={index} className="text-[10px] text-text-secondary">
|
|
||||||
{['滑动摩擦', '扭转摩擦', '滚动摩擦'][index]}
|
|
||||||
<input
|
|
||||||
aria-label={`对象${['滑动摩擦', '扭转摩擦', '滚动摩擦'][index]}`}
|
|
||||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="5"
|
|
||||||
step={index === 0 ? 0.05 : 0.0001}
|
|
||||||
value={value}
|
|
||||||
disabled={loading}
|
|
||||||
onChange={(event) => updateFriction(index, Number(event.target.value))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
|
||||||
<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={`#${active.rgba
|
|
||||||
.slice(0, 3)
|
|
||||||
.map((channel) =>
|
|
||||||
Math.round(Math.min(1, Math.max(0, channel)) * 255)
|
|
||||||
.toString(16)
|
|
||||||
.padStart(2, '0'),
|
|
||||||
)
|
|
||||||
.join('')}`}
|
|
||||||
disabled={loading}
|
|
||||||
onChange={(event) => updateColor(event.target.value)}
|
|
||||||
/>
|
|
||||||
</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 px-1"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.05"
|
|
||||||
value={active.rgba[3]}
|
|
||||||
disabled={loading}
|
|
||||||
onChange={(event) => {
|
|
||||||
const alpha = Number(event.target.value);
|
|
||||||
if (!Number.isFinite(alpha)) return;
|
|
||||||
session.update(active.id, {
|
|
||||||
rgba: [
|
|
||||||
active.rgba[0],
|
|
||||||
active.rgba[1],
|
|
||||||
active.rgba[2],
|
|
||||||
Math.min(1, Math.max(0, alpha)),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
refresh();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center justify-between text-[11px] text-text-secondary">
|
|
||||||
<span>启用对象</span>
|
|
||||||
<input
|
|
||||||
aria-label="启用地图对象"
|
|
||||||
type="checkbox"
|
|
||||||
checked={active.enabled}
|
|
||||||
disabled={loading}
|
|
||||||
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>
|
||||||
)}
|
<div className="max-h-36 space-y-1 overflow-auto" aria-label="地图对象列表">
|
||||||
|
{current.objects.map((object) => (
|
||||||
|
<button
|
||||||
|
key={object.id}
|
||||||
|
type="button"
|
||||||
|
className={`w-full rounded border px-2 py-1 text-left text-xs ${selectedId === object.id ? 'border-accent bg-accent/10' : 'border-border-subtle'}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelected(object.id);
|
||||||
|
onSelectPreview(object.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{object.name} · {labels[object.type]}
|
||||||
|
{object.placementMode === 'locked' ? ' · 已锁定' : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!current.objects.length && (
|
||||||
|
<div className="text-xs text-text-tertiary">暂无对象,可从左侧资产库拖入视口</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<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">
|
||||||
@@ -585,43 +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"
|
value={value}
|
||||||
type="number"
|
step={0.1}
|
||||||
step="0.1"
|
|
||||||
value={value}
|
|
||||||
disabled={loading}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateSpawnPosition(
|
|
||||||
spawn.id,
|
|
||||||
spawn.position,
|
|
||||||
index,
|
|
||||||
Number(event.target.value),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
<label className="text-[10px] text-text-secondary">
|
|
||||||
Yaw°
|
|
||||||
<input
|
|
||||||
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}
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onChange={(event) => {
|
onValueChange={(next) =>
|
||||||
const value = Number(event.target.value);
|
updateSpawnPosition(spawn.id, spawn.position, index, next)
|
||||||
if (!Number.isFinite(value)) return;
|
}
|
||||||
session.updateSpawn(spawn.id, { yawDeg: value });
|
|
||||||
refresh();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
))}
|
||||||
|
<ScrubbableNumberInput
|
||||||
|
label="Yaw°"
|
||||||
|
aria-label={`出生点${spawn.id}朝向`}
|
||||||
|
value={spawn.yawDeg}
|
||||||
|
step={1}
|
||||||
|
disabled={loading}
|
||||||
|
onValueChange={(yawDeg) => {
|
||||||
|
session.updateSpawn(spawn.id, { yawDeg });
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Button } from '../components/ui';
|
|
||||||
import type { MapEditorTransformMode } from './editor/types';
|
|
||||||
|
|
||||||
export function MapTransformToolbar({
|
|
||||||
mode,
|
|
||||||
snapping,
|
|
||||||
loading,
|
|
||||||
allowScale = true,
|
|
||||||
hint,
|
|
||||||
onModeChange,
|
|
||||||
onSnappingChange,
|
|
||||||
}: {
|
|
||||||
mode: MapEditorTransformMode;
|
|
||||||
snapping: boolean;
|
|
||||||
loading: boolean;
|
|
||||||
allowScale?: boolean;
|
|
||||||
hint: string;
|
|
||||||
onModeChange: (mode: MapEditorTransformMode) => void;
|
|
||||||
onSnappingChange: (value: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="grid grid-cols-3 gap-2 rounded border border-border-subtle p-2"
|
|
||||||
aria-label="视口选择与变换"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant={mode === 'translate' ? 'primary' : 'secondary'}
|
|
||||||
aria-pressed={mode === 'translate'}
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => onModeChange('translate')}
|
|
||||||
>
|
|
||||||
移动工具 W
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={mode === 'rotate' ? 'primary' : 'secondary'}
|
|
||||||
aria-pressed={mode === 'rotate'}
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => onModeChange('rotate')}
|
|
||||||
>
|
|
||||||
旋转工具 E
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={mode === 'scale' ? 'primary' : 'secondary'}
|
|
||||||
aria-pressed={mode === 'scale'}
|
|
||||||
disabled={loading || !allowScale}
|
|
||||||
title={allowScale ? undefined : '参数地形请通过尺寸参数调整大小'}
|
|
||||||
onClick={() => onModeChange('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) => onSnappingChange(event.target.checked)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<p className="col-span-3 text-[10px] leading-relaxed text-text-tertiary">{hint}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -17,8 +17,7 @@ const common = {
|
|||||||
onEditorConvert: async () => true,
|
onEditorConvert: async () => true,
|
||||||
onEditorBindInteraction: () => {},
|
onEditorBindInteraction: () => {},
|
||||||
onEditorSelect: () => {},
|
onEditorSelect: () => {},
|
||||||
onEditorTransformMode: () => {},
|
onEditorSessionStateChange: () => {},
|
||||||
onEditorSnapping: () => {},
|
|
||||||
onMapDisplay: () => {},
|
onMapDisplay: () => {},
|
||||||
onDraft: () => {},
|
onDraft: () => {},
|
||||||
};
|
};
|
||||||
@@ -88,25 +87,16 @@ describe('PhysicalMapPanel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('参数地形与认证资产共用视口变换界面', () => {
|
it('参数地形属性不再承载视口变换工具', () => {
|
||||||
const onTransformMode = vi.fn();
|
|
||||||
const onSnapping = vi.fn();
|
|
||||||
render(
|
render(
|
||||||
<PhysicalMapPanel
|
<PhysicalMapPanel
|
||||||
{...common}
|
{...common}
|
||||||
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough' } }}
|
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'rough' } }}
|
||||||
onEditorTransformMode={onTransformMode}
|
|
||||||
onEditorSnapping={onSnapping}
|
|
||||||
onApply={() => {}}
|
onApply={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByLabelText('视口选择与变换')).toBeInTheDocument();
|
expect(screen.queryByLabelText('地图变换模式')).not.toBeInTheDocument();
|
||||||
expect(screen.getByText(/点击空白区域取消选择/)).toBeInTheDocument();
|
expect(screen.getByText(/已统一到视口浮动工具条/)).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: '缩放工具 S' })).toBeDisabled();
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '旋转工具 E' }));
|
|
||||||
expect(onTransformMode).toHaveBeenLastCalledWith('rotate');
|
|
||||||
fireEvent.click(screen.getByLabelText('地图编辑吸附'));
|
|
||||||
expect(onSnapping).toHaveBeenLastCalledWith(null, null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('表单参数与外部视口变换合并到同一份草稿', () => {
|
it('表单参数与外部视口变换合并到同一份草稿', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, 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,
|
||||||
@@ -15,10 +15,9 @@ 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';
|
||||||
import { MapTransformToolbar } from './MapTransformToolbar';
|
|
||||||
|
|
||||||
function NumberField({
|
function NumberField({
|
||||||
label,
|
label,
|
||||||
@@ -38,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
|
value={value}
|
||||||
type="number"
|
min={min}
|
||||||
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"
|
max={max}
|
||||||
value={value}
|
step={step}
|
||||||
min={min}
|
disabled={disabled}
|
||||||
max={max}
|
onValueChange={onChange}
|
||||||
step={step}
|
/>
|
||||||
disabled={disabled}
|
|
||||||
onChange={(event) => onChange(Number(event.target.value))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,10 +82,10 @@ export function PhysicalMapPanel({
|
|||||||
onEditorApply,
|
onEditorApply,
|
||||||
onEditorExport,
|
onEditorExport,
|
||||||
onEditorConvert,
|
onEditorConvert,
|
||||||
|
selectedEditorObjectId,
|
||||||
onEditorBindInteraction,
|
onEditorBindInteraction,
|
||||||
onEditorSelect,
|
onEditorSelect,
|
||||||
onEditorTransformMode,
|
onEditorSessionStateChange,
|
||||||
onEditorSnapping,
|
|
||||||
onEditorSurfaceHeight,
|
onEditorSurfaceHeight,
|
||||||
onMapDisplay,
|
onMapDisplay,
|
||||||
onDraft,
|
onDraft,
|
||||||
@@ -115,28 +110,24 @@ export function PhysicalMapPanel({
|
|||||||
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;
|
onEditorSurfaceHeight?: (position: readonly [number, number, number]) => number | null;
|
||||||
onMapDisplay: (visual: boolean, collision: boolean) => void;
|
onMapDisplay: (visual: boolean, collision: boolean) => void;
|
||||||
onDraft: (value: PlacedMapSelection) => 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),
|
[previousValue, setPreviousValue] = useState(value);
|
||||||
[terrainTransformMode, setTerrainTransformMode] = useState<MapEditorTransformMode>('translate'),
|
|
||||||
[terrainSnapping, setTerrainSnapping] = useState(true);
|
|
||||||
if (previousValue !== value) {
|
if (previousValue !== value) {
|
||||||
setPreviousValue(value);
|
setPreviousValue(value);
|
||||||
setDraft(value);
|
setDraft(value);
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
|
||||||
if (draft.kind !== 'builtin') return;
|
|
||||||
onEditorTransformMode(terrainTransformMode);
|
|
||||||
onEditorSnapping(terrainSnapping ? 0.1 : null, terrainSnapping ? 5 : null);
|
|
||||||
}, [draft.kind, terrainTransformMode, terrainSnapping, onEditorTransformMode, onEditorSnapping]);
|
|
||||||
const normalized = normalizeSelection(draft);
|
const normalized = normalizeSelection(draft);
|
||||||
const changed = JSON.stringify(normalized) !== JSON.stringify(normalizeSelection(value));
|
const changed = JSON.stringify(normalized) !== JSON.stringify(normalizeSelection(value));
|
||||||
const compileDisabled = loading || (nativeUrdf && normalized.kind !== 'none');
|
const compileDisabled = loading || (nativeUrdf && normalized.kind !== 'none');
|
||||||
@@ -187,6 +178,37 @@ export function PhysicalMapPanel({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
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')
|
if (value.kind === 'none' && draft.kind === 'none')
|
||||||
return (
|
return (
|
||||||
@@ -245,6 +267,8 @@ export function PhysicalMapPanel({
|
|||||||
</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
|
||||||
@@ -267,15 +291,9 @@ export function PhysicalMapPanel({
|
|||||||
{draft.kind === 'builtin' && (
|
{draft.kind === 'builtin' && (
|
||||||
<>
|
<>
|
||||||
<div className="text-xs font-medium text-text-primary">参数地形属性</div>
|
<div className="text-xs font-medium text-text-primary">参数地形属性</div>
|
||||||
<MapTransformToolbar
|
<p className="rounded border border-border-subtle bg-panel-muted/50 p-2 text-[10px] leading-relaxed text-text-tertiary">
|
||||||
mode={terrainTransformMode}
|
移动、旋转与网格吸附已统一到视口浮动工具条;尺寸继续通过下方参数精确调整。
|
||||||
snapping={terrainSnapping}
|
</p>
|
||||||
loading={loading}
|
|
||||||
allowScale={false}
|
|
||||||
hint="在视口点击参数地形后拖动操纵轴;点击空白区域取消选择。移动和旋转先进入草稿,确认后再编译。"
|
|
||||||
onModeChange={setTerrainTransformMode}
|
|
||||||
onSnappingChange={setTerrainSnapping}
|
|
||||||
/>
|
|
||||||
<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
|
||||||
@@ -558,33 +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">认证资产与场景对象属性</div>
|
|
||||||
<p className="text-[10px] leading-relaxed text-text-tertiary">
|
|
||||||
此处编辑地图源内容;场景中引用同一来源的实例会同步更新,实例位姿彼此独立。
|
|
||||||
</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)}
|
|
||||||
onBindInteraction={onEditorBindInteraction}
|
|
||||||
onSelectPreview={onEditorSelect}
|
|
||||||
onTransformMode={onEditorTransformMode}
|
|
||||||
onSnapping={onEditorSnapping}
|
|
||||||
onSurfaceHeight={onEditorSurfaceHeight}
|
|
||||||
/>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 @@ 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', () => {
|
it('重新挂载时恢复未提交草稿并保持 dirty', () => {
|
||||||
@@ -103,6 +106,7 @@ describe('地图 V3 编辑核心', () => {
|
|||||||
draft.objects.push(createEditableObject('box', 'pending_box'));
|
draft.objects.push(createEditableObject('box', 'pending_box'));
|
||||||
const session = new MapEditSession(saved, draft);
|
const session = new MapEditSession(saved, draft);
|
||||||
expect(session.dirty).toBe(true);
|
expect(session.dirty).toBe(true);
|
||||||
|
expect(session.changeCount).toBe(1);
|
||||||
expect(session.document.objects[0].id).toBe('pending_box');
|
expect(session.document.objects[0].id).toBe('pending_box');
|
||||||
session.discard();
|
session.discard();
|
||||||
expect(session.dirty).toBe(false);
|
expect(session.dirty).toBe(false);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -30,6 +40,10 @@ export interface MapEditorInteractionCallbacks {
|
|||||||
placementMode?: MapObjectPlacementMode,
|
placementMode?: MapObjectPlacementMode,
|
||||||
externalSupportTop?: number,
|
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 {
|
||||||
|
|||||||
@@ -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"
|
disabled={disabled}
|
||||||
step="any"
|
onValueChange={(next) => {
|
||||||
defaultValue={Number.isFinite(value) ? value : 0}
|
if (next !== value) onCommit(next);
|
||||||
disabled={disabled}
|
}}
|
||||||
className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40"
|
/>
|
||||||
onBlur={(event) => {
|
|
||||||
const next = Number(event.currentTarget.value);
|
|
||||||
if (Number.isFinite(next) && next !== value) onCommit(next);
|
|
||||||
else event.currentTarget.value = String(value);
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter') event.currentTarget.blur();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function ParameterToggle({
|
function ParameterToggle({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+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;
|
||||||
|
|||||||
@@ -70,16 +70,18 @@ describe('LocalTrainingPanel', () => {
|
|||||||
);
|
);
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
|
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
|
||||||
|
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||||
|
const dashboardButton = screen.getByRole('button', { name: '打开自调参 Agent 工作台' });
|
||||||
|
expect(dashboardButton).toBeEnabled();
|
||||||
|
fireEvent.click(dashboardButton);
|
||||||
|
expect(open).toHaveBeenCalledWith(expect.any(URL), '_blank');
|
||||||
|
expect(String(open.mock.calls[0][0])).toContain('tuning.html');
|
||||||
|
expect(String(open.mock.calls[0][0])).not.toContain('secret-token');
|
||||||
fireEvent.change(screen.getByLabelText('训练服务访问令牌'), {
|
fireEvent.change(screen.getByLabelText('训练服务访问令牌'), {
|
||||||
target: { value: 'secret-token' },
|
target: { value: 'secret-token' },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole('button', { name: '连接' }));
|
fireEvent.click(screen.getByRole('button', { name: '连接' }));
|
||||||
expect(await screen.findByText('/opt/unitree_rl_mjlab')).toBeInTheDocument();
|
expect(await screen.findByText('/opt/unitree_rl_mjlab')).toBeInTheDocument();
|
||||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '打开自调参 Agent 工作台' }));
|
|
||||||
expect(open).toHaveBeenCalledWith(expect.any(URL), '_blank');
|
|
||||||
expect(String(open.mock.calls[0][0])).toContain('tuning.html');
|
|
||||||
expect(String(open.mock.calls[0][0])).not.toContain('secret-token');
|
|
||||||
fireEvent.change(screen.getByLabelText('并行环境'), { target: { value: '32' } });
|
fireEvent.change(screen.getByLabelText('并行环境'), { target: { value: '32' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
|
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
|
||||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));
|
||||||
|
|||||||
@@ -280,15 +280,13 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
|
|||||||
{server?.ready ? '可用' : '离线'}
|
{server?.ready ? '可用' : '离线'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{server?.ready && (
|
<Button
|
||||||
<Button
|
className="mt-2 w-full"
|
||||||
className="mt-2 w-full"
|
icon={<ExternalLink className="h-3.5 w-3.5" />}
|
||||||
icon={<ExternalLink className="h-3.5 w-3.5" />}
|
onClick={openTuningDashboard}
|
||||||
onClick={openTuningDashboard}
|
>
|
||||||
>
|
打开自调参 Agent 工作台
|
||||||
打开自调参 Agent 工作台
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{server?.ready && !job && (
|
{server?.ready && !job && (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
<Field label="训练任务">
|
<Field label="训练任务">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
|
||||||
import uPlot from 'uplot';
|
import uPlot from 'uplot';
|
||||||
import type { ScalarSeries } from '../training/types';
|
import type { ScalarSeries } from '../training/types';
|
||||||
|
|
||||||
@@ -14,8 +15,19 @@ function smooth(values: (number | null)[], factor: number): (number | null)[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScalarChart({ series, smoothing }: { series: ScalarSeries[]; smoothing: number }) {
|
export function ScalarChart({
|
||||||
|
series,
|
||||||
|
smoothing,
|
||||||
|
title = '训练与评估 Scalars',
|
||||||
|
}: {
|
||||||
|
series: ScalarSeries[];
|
||||||
|
smoothing: number;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
const host = useRef<HTMLDivElement>(null);
|
const host = useRef<HTMLDivElement>(null);
|
||||||
|
const chartRef = useRef<uPlot | null>(null);
|
||||||
|
const trackZoom = useRef(false);
|
||||||
|
const zoomRanges = useRef<Partial<Record<'x' | 'y', { min: number; max: number }>>>({});
|
||||||
const prepared = useMemo(() => {
|
const prepared = useMemo(() => {
|
||||||
const steps = Array.from(
|
const steps = Array.from(
|
||||||
new Set(series.flatMap((item) => item.points.map((point) => point.step))),
|
new Set(series.flatMap((item) => item.points.map((point) => point.step))),
|
||||||
@@ -36,13 +48,24 @@ export function ScalarChart({ series, smoothing }: { series: ScalarSeries[]; smo
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!host.current || series.length === 0 || prepared[0].length === 0) return;
|
if (!host.current || series.length === 0 || prepared[0].length === 0) return;
|
||||||
const element = host.current;
|
const element = host.current;
|
||||||
|
const width = Math.max(280, Math.floor(element.getBoundingClientRect().width));
|
||||||
|
trackZoom.current = false;
|
||||||
const chart = new uPlot(
|
const chart = new uPlot(
|
||||||
{
|
{
|
||||||
width: Math.max(320, element.clientWidth),
|
width,
|
||||||
height: 360,
|
height: 280,
|
||||||
title: '训练与评估 Scalars',
|
|
||||||
cursor: { drag: { x: true, y: true, setScale: true } },
|
cursor: { drag: { x: true, y: true, setScale: true } },
|
||||||
scales: { x: { time: false } },
|
scales: { x: { time: false } },
|
||||||
|
hooks: {
|
||||||
|
setScale: [
|
||||||
|
(instance, key) => {
|
||||||
|
if (!trackZoom.current || (key !== 'x' && key !== 'y')) return;
|
||||||
|
const scale = instance.scales[key];
|
||||||
|
if (typeof scale.min === 'number' && typeof scale.max === 'number')
|
||||||
|
zoomRanges.current[key] = { min: scale.min, max: scale.max };
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
axes: [
|
axes: [
|
||||||
{ stroke: '#8fa0b5', grid: { stroke: '#213044' } },
|
{ stroke: '#8fa0b5', grid: { stroke: '#213044' } },
|
||||||
{ stroke: '#8fa0b5', grid: { stroke: '#213044' } },
|
{ stroke: '#8fa0b5', grid: { stroke: '#213044' } },
|
||||||
@@ -60,22 +83,123 @@ export function ScalarChart({ series, smoothing }: { series: ScalarSeries[]; smo
|
|||||||
prepared,
|
prepared,
|
||||||
element,
|
element,
|
||||||
);
|
);
|
||||||
const observer = new ResizeObserver((entries) => {
|
chartRef.current = chart;
|
||||||
const width = entries[0]?.contentRect.width;
|
trackZoom.current = true;
|
||||||
if (width) chart.setSize({ width: Math.max(320, Math.floor(width)), height: 360 });
|
for (const key of ['x', 'y'] as const) {
|
||||||
|
const range = zoomRanges.current[key];
|
||||||
|
if (range) chart.setScale(key, range);
|
||||||
|
}
|
||||||
|
const wheelZoom = (event: WheelEvent) => {
|
||||||
|
if (!event.deltaY) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const bounds = chart.over.getBoundingClientRect();
|
||||||
|
if (!bounds.width || !bounds.height) return;
|
||||||
|
const xRatio = Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width));
|
||||||
|
const yRatio = Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height));
|
||||||
|
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1;
|
||||||
|
const factor = Math.min(2, Math.max(0.5, Math.exp(event.deltaY * unit * 0.002)));
|
||||||
|
for (const key of ['x', 'y'] as const) {
|
||||||
|
const scale = chart.scales[key];
|
||||||
|
if (typeof scale.min !== 'number' || typeof scale.max !== 'number') continue;
|
||||||
|
const ratio = key === 'x' ? xRatio : 1 - yRatio;
|
||||||
|
const anchor = scale.min + (scale.max - scale.min) * ratio;
|
||||||
|
chart.setScale(key, {
|
||||||
|
min: anchor - (anchor - scale.min) * factor,
|
||||||
|
max: anchor + (scale.max - anchor) * factor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
chart.over.addEventListener('wheel', wheelZoom, { passive: false });
|
||||||
|
let frame = 0;
|
||||||
|
let lastWidth = width;
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
window.cancelAnimationFrame(frame);
|
||||||
|
frame = window.requestAnimationFrame(() => {
|
||||||
|
const nextWidth = Math.max(280, Math.floor(element.getBoundingClientRect().width));
|
||||||
|
if (nextWidth !== lastWidth) {
|
||||||
|
lastWidth = nextWidth;
|
||||||
|
chart.setSize({ width: nextWidth, height: 280 });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
observer.observe(element);
|
observer.observe(element);
|
||||||
return () => {
|
return () => {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
|
chart.over.removeEventListener('wheel', wheelZoom);
|
||||||
|
window.cancelAnimationFrame(frame);
|
||||||
|
trackZoom.current = false;
|
||||||
|
chartRef.current = null;
|
||||||
chart.destroy();
|
chart.destroy();
|
||||||
};
|
};
|
||||||
}, [prepared, series]);
|
}, [prepared, series]);
|
||||||
|
|
||||||
|
const zoom = (factor: number) => {
|
||||||
|
const chart = chartRef.current;
|
||||||
|
if (!chart) return;
|
||||||
|
for (const key of ['x', 'y']) {
|
||||||
|
const scale = chart.scales[key];
|
||||||
|
if (typeof scale.min !== 'number' || typeof scale.max !== 'number') continue;
|
||||||
|
const center = (scale.min + scale.max) / 2;
|
||||||
|
const radius = ((scale.max - scale.min) * factor) / 2 || 1;
|
||||||
|
chart.setScale(key, { min: center - radius, max: center + radius });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const resetZoom = () => {
|
||||||
|
const chart = chartRef.current;
|
||||||
|
if (!chart) return;
|
||||||
|
zoomRanges.current = {};
|
||||||
|
trackZoom.current = false;
|
||||||
|
chart.setData(chart.data, true);
|
||||||
|
trackZoom.current = true;
|
||||||
|
};
|
||||||
|
|
||||||
if (series.length === 0)
|
if (series.length === 0)
|
||||||
return (
|
return (
|
||||||
<div className="grid h-[360px] place-items-center rounded-lg border border-border bg-app text-xs text-text-tertiary">
|
<div className="grid h-[280px] place-items-center rounded-lg border border-border bg-app text-xs text-text-tertiary">
|
||||||
当前 trial 尚无 scalar 数据
|
当前 trial 尚无 scalar 数据
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
return <div ref={host} className="min-w-0 overflow-hidden rounded-lg bg-app p-2" />;
|
return (
|
||||||
|
<section className="min-w-0 overflow-hidden rounded-lg border border-border bg-app">
|
||||||
|
<header className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
|
||||||
|
<h3 className="min-w-0 truncate text-[10px] font-semibold" title={title}>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${title} 放大`}
|
||||||
|
title="放大"
|
||||||
|
className="rounded p-1 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
|
||||||
|
onClick={() => zoom(0.7)}
|
||||||
|
>
|
||||||
|
<ZoomIn className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${title} 缩小`}
|
||||||
|
title="缩小"
|
||||||
|
className="rounded p-1 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
|
||||||
|
onClick={() => zoom(1.4)}
|
||||||
|
>
|
||||||
|
<ZoomOut className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${title} 重置缩放`}
|
||||||
|
title="重置缩放"
|
||||||
|
className="rounded p-1 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
|
||||||
|
onClick={resetZoom}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div ref={host} className="min-w-0 w-full overflow-hidden" />
|
||||||
|
<p className="border-t border-border px-3 py-1.5 text-[9px] text-text-tertiary">
|
||||||
|
图表区域滚轮以指针位置缩放;也可拖拽框选,或使用右上角按钮缩放和复位。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,20 @@ describe('MapEditorLayer', () => {
|
|||||||
layer.dispose();
|
layer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('选中对象时显示贴地对齐指示器并提供聚焦边界', () => {
|
||||||
|
const { scene, layer } = createLayer();
|
||||||
|
layer.setDocument(documentValue);
|
||||||
|
layer.selectObject('box_1');
|
||||||
|
const indicator = scene.getObjectByName('__platform_snap_surface_indicator__');
|
||||||
|
expect(indicator?.visible).toBe(true);
|
||||||
|
expect(layer.selectedBoundingSphere()?.radius).toBeGreaterThan(0);
|
||||||
|
layer.flashSurfaceAlignment('box_1');
|
||||||
|
layer.update(performance.now() + 100);
|
||||||
|
expect(indicator?.scale.x).toBeGreaterThan(0);
|
||||||
|
layer.dispose();
|
||||||
|
expect(scene.getObjectByName('__platform_snap_surface_indicator__')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('锁定对象可选中但不挂载变换操纵器', () => {
|
it('锁定对象可选中但不挂载变换操纵器', () => {
|
||||||
const { layer } = createLayer();
|
const { layer } = createLayer();
|
||||||
layer.setDocument({
|
layer.setDocument({
|
||||||
@@ -151,7 +165,7 @@ describe('MapEditorLayer', () => {
|
|||||||
layer.dispose();
|
layer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('提交操纵器变换并保持 W/E 模式约束', () => {
|
it('提交操纵器变换并保持 W/E/R 模式约束', () => {
|
||||||
const { callbacks, layer } = createLayer();
|
const { callbacks, layer } = createLayer();
|
||||||
layer.setDocument(documentValue);
|
layer.setDocument(documentValue);
|
||||||
layer.selectObject('box_1');
|
layer.selectObject('box_1');
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ function objectPreview(object: EditableMapObject): THREE.Group {
|
|||||||
group.name = `__platform_map_editor_${object.id}`;
|
group.name = `__platform_map_editor_${object.id}`;
|
||||||
group.userData.mapEditorObjectId = object.id;
|
group.userData.mapEditorObjectId = object.id;
|
||||||
group.userData.mapEditorLocked = object.placementMode === 'locked';
|
group.userData.mapEditorLocked = object.placementMode === 'locked';
|
||||||
|
group.userData.mapEditorPlacementMode = object.placementMode;
|
||||||
group.position.fromArray(object.pose.position);
|
group.position.fromArray(object.pose.position);
|
||||||
group.quaternion.set(
|
group.quaternion.set(
|
||||||
object.pose.quaternion[1],
|
object.pose.quaternion[1],
|
||||||
@@ -132,8 +133,25 @@ export class MapEditorLayer {
|
|||||||
private readonly pointer = new THREE.Vector2();
|
private readonly pointer = new THREE.Vector2();
|
||||||
private readonly objects = new Map<string, THREE.Group>();
|
private readonly objects = new Map<string, THREE.Group>();
|
||||||
private readonly previewInstances = new Map<string, THREE.Group>();
|
private readonly previewInstances = new Map<string, THREE.Group>();
|
||||||
|
private readonly surfaceIndicator = new THREE.Group();
|
||||||
|
private readonly surfaceRingMaterial = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x34d399,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.42,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
private readonly surfaceLineMaterial = new THREE.LineBasicMaterial({
|
||||||
|
color: 0x6ee7b7,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.7,
|
||||||
|
depthTest: false,
|
||||||
|
});
|
||||||
private selectedId: string | null = null;
|
private selectedId: string | null = null;
|
||||||
private documentLoaded = false;
|
private documentLoaded = false;
|
||||||
|
private surfaceIndicatorScale = 0.25;
|
||||||
|
private surfacePulseStarted = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly scene: THREE.Scene,
|
private readonly scene: THREE.Scene,
|
||||||
@@ -143,7 +161,25 @@ export class MapEditorLayer {
|
|||||||
) {
|
) {
|
||||||
this.previewGroup.name = '__platform_map_editor_scene_previews__';
|
this.previewGroup.name = '__platform_map_editor_scene_previews__';
|
||||||
this.group.name = '__platform_map_editor__';
|
this.group.name = '__platform_map_editor__';
|
||||||
scene.add(this.previewGroup, this.group);
|
this.surfaceIndicator.name = '__platform_snap_surface_indicator__';
|
||||||
|
const surfaceRing = new THREE.Mesh(
|
||||||
|
new THREE.RingGeometry(0.72, 1, 48),
|
||||||
|
this.surfaceRingMaterial,
|
||||||
|
);
|
||||||
|
surfaceRing.renderOrder = 110;
|
||||||
|
const surfaceCross = new THREE.LineSegments(
|
||||||
|
new THREE.BufferGeometry().setFromPoints([
|
||||||
|
new THREE.Vector3(-1.18, 0, 0),
|
||||||
|
new THREE.Vector3(1.18, 0, 0),
|
||||||
|
new THREE.Vector3(0, -1.18, 0),
|
||||||
|
new THREE.Vector3(0, 1.18, 0),
|
||||||
|
]),
|
||||||
|
this.surfaceLineMaterial,
|
||||||
|
);
|
||||||
|
surfaceCross.renderOrder = 110;
|
||||||
|
this.surfaceIndicator.add(surfaceRing, surfaceCross);
|
||||||
|
this.surfaceIndicator.visible = false;
|
||||||
|
scene.add(this.previewGroup, this.group, this.surfaceIndicator);
|
||||||
this.transform = new TransformControls(camera, domElement);
|
this.transform = new TransformControls(camera, domElement);
|
||||||
this.transform.setSpace('world');
|
this.transform.setSpace('world');
|
||||||
this.transform.setSize(0.8);
|
this.transform.setSize(0.8);
|
||||||
@@ -164,6 +200,39 @@ export class MapEditorLayer {
|
|||||||
return this.documentLoaded || this.previewInstances.size > 0;
|
return this.documentLoaded || this.previewInstances.size > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectedBoundingSphere(): THREE.Sphere | null {
|
||||||
|
const selected = this.selectedId ? this.objects.get(this.selectedId) : undefined;
|
||||||
|
if (!selected) return null;
|
||||||
|
selected.updateWorldMatrix(true, true);
|
||||||
|
const bounds = new THREE.Box3().setFromObject(selected);
|
||||||
|
if (bounds.isEmpty()) return null;
|
||||||
|
return bounds.getBoundingSphere(new THREE.Sphere());
|
||||||
|
}
|
||||||
|
|
||||||
|
flashSurfaceAlignment(id?: string): void {
|
||||||
|
if (id && id !== this.selectedId) return;
|
||||||
|
const selected = this.selectedId ? this.objects.get(this.selectedId) : undefined;
|
||||||
|
if (!selected || selected.userData.mapEditorLocked) return;
|
||||||
|
this.positionSurfaceIndicator(selected);
|
||||||
|
this.startSurfacePulse();
|
||||||
|
this.surfaceIndicator.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(now: number): void {
|
||||||
|
const selected = this.selectedId ? this.objects.get(this.selectedId) : undefined;
|
||||||
|
if (!selected || selected.userData.mapEditorLocked) {
|
||||||
|
this.surfaceIndicator.visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.positionSurfaceIndicator(selected);
|
||||||
|
const elapsed = now - this.surfacePulseStarted;
|
||||||
|
const pulse = elapsed >= 0 && elapsed < 720 ? 1 - elapsed / 720 : 0;
|
||||||
|
const wave = pulse * (0.16 + Math.sin((elapsed / 720) * Math.PI * 4) * 0.08);
|
||||||
|
this.surfaceIndicator.scale.setScalar(this.surfaceIndicatorScale * (1 + wave));
|
||||||
|
this.surfaceRingMaterial.opacity = 0.32 + pulse * 0.38;
|
||||||
|
this.surfaceLineMaterial.opacity = 0.5 + pulse * 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
setDocument(
|
setDocument(
|
||||||
document: EditableMapDocument | null,
|
document: EditableMapDocument | null,
|
||||||
instanceTransform: MapInstanceTransform = DEFAULT_MAP_INSTANCE_TRANSFORM,
|
instanceTransform: MapInstanceTransform = DEFAULT_MAP_INSTANCE_TRANSFORM,
|
||||||
@@ -231,6 +300,7 @@ export class MapEditorLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectObject(id: string | null, notify = false): void {
|
selectObject(id: string | null, notify = false): void {
|
||||||
|
const previousId = this.selectedId;
|
||||||
this.selectedId = id && this.objects.has(id) ? id : null;
|
this.selectedId = id && this.objects.has(id) ? id : null;
|
||||||
this.transform.detach();
|
this.transform.detach();
|
||||||
for (const [objectId, root] of this.objects)
|
for (const [objectId, root] of this.objects)
|
||||||
@@ -246,7 +316,11 @@ export class MapEditorLayer {
|
|||||||
});
|
});
|
||||||
const selected = this.selectedId ? this.objects.get(this.selectedId) : undefined;
|
const selected = this.selectedId ? this.objects.get(this.selectedId) : undefined;
|
||||||
const editable = Boolean(selected && !selected.userData.mapEditorLocked);
|
const editable = Boolean(selected && !selected.userData.mapEditorLocked);
|
||||||
if (selected && editable) this.transform.attach(selected);
|
if (selected && editable) {
|
||||||
|
this.transform.attach(selected);
|
||||||
|
this.positionSurfaceIndicator(selected);
|
||||||
|
if (previousId !== this.selectedId) this.startSurfacePulse();
|
||||||
|
} else this.surfaceIndicator.visible = false;
|
||||||
this.helper.visible = editable;
|
this.helper.visible = editable;
|
||||||
if (notify) this.callbacks.onSelect(this.selectedId);
|
if (notify) this.callbacks.onSelect(this.selectedId);
|
||||||
}
|
}
|
||||||
@@ -285,6 +359,32 @@ export class MapEditorLayer {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startSurfacePulse(): void {
|
||||||
|
const now = performance.now();
|
||||||
|
this.surfacePulseStarted = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||||
|
? now - 720
|
||||||
|
: now;
|
||||||
|
}
|
||||||
|
|
||||||
|
private positionSurfaceIndicator(selected: THREE.Group): void {
|
||||||
|
selected.updateWorldMatrix(true, true);
|
||||||
|
const bounds = new THREE.Box3().setFromObject(selected);
|
||||||
|
if (bounds.isEmpty()) {
|
||||||
|
this.surfaceIndicator.visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const center = bounds.getCenter(new THREE.Vector3());
|
||||||
|
const size = bounds.getSize(new THREE.Vector3());
|
||||||
|
const placementMode = selected.userData.mapEditorPlacementMode;
|
||||||
|
const color = placementMode === 'gravity' ? 0x38bdf8 : 0x34d399;
|
||||||
|
this.surfaceRingMaterial.color.setHex(color);
|
||||||
|
this.surfaceLineMaterial.color.setHex(color);
|
||||||
|
this.surfaceIndicatorScale = Math.min(1.5, Math.max(0.16, Math.max(size.x, size.y) * 0.62));
|
||||||
|
this.surfaceIndicator.position.set(center.x, center.y, bounds.min.z + 0.004);
|
||||||
|
this.surfaceIndicator.scale.setScalar(this.surfaceIndicatorScale);
|
||||||
|
this.surfaceIndicator.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
private commitTransform(): void {
|
private commitTransform(): void {
|
||||||
if (!this.selectedId) return;
|
if (!this.selectedId) return;
|
||||||
const object = this.objects.get(this.selectedId);
|
const object = this.objects.get(this.selectedId);
|
||||||
@@ -323,6 +423,8 @@ export class MapEditorLayer {
|
|||||||
this.clearPreviewInstances();
|
this.clearPreviewInstances();
|
||||||
this.group.position.set(0, 0, 0);
|
this.group.position.set(0, 0, 0);
|
||||||
this.group.rotation.set(0, 0, 0);
|
this.group.rotation.set(0, 0, 0);
|
||||||
|
this.surfaceIndicator.visible = false;
|
||||||
|
this.surfacePulseStarted = 0;
|
||||||
this.transform.enabled = false;
|
this.transform.enabled = false;
|
||||||
this.callbacks.onDragging(false);
|
this.callbacks.onDragging(false);
|
||||||
}
|
}
|
||||||
@@ -332,6 +434,11 @@ export class MapEditorLayer {
|
|||||||
this.callbacks.onDragging(false);
|
this.callbacks.onDragging(false);
|
||||||
this.transform.dispose();
|
this.transform.dispose();
|
||||||
this.helper.removeFromParent();
|
this.helper.removeFromParent();
|
||||||
|
for (const child of this.surfaceIndicator.children)
|
||||||
|
if (child instanceof THREE.Mesh || child instanceof THREE.Line) child.geometry.dispose();
|
||||||
|
this.surfaceRingMaterial.dispose();
|
||||||
|
this.surfaceLineMaterial.dispose();
|
||||||
|
this.surfaceIndicator.removeFromParent();
|
||||||
this.previewGroup.removeFromParent();
|
this.previewGroup.removeFromParent();
|
||||||
this.group.removeFromParent();
|
this.group.removeFromParent();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,13 @@ export class MuJoCoViewer {
|
|||||||
fromGround: THREE.Color;
|
fromGround: THREE.Color;
|
||||||
toGround: THREE.Color;
|
toGround: THREE.Color;
|
||||||
};
|
};
|
||||||
|
private cameraFocusTransition?: {
|
||||||
|
started: number;
|
||||||
|
fromPosition: THREE.Vector3;
|
||||||
|
fromTarget: THREE.Vector3;
|
||||||
|
toPosition: THREE.Vector3;
|
||||||
|
toTarget: THREE.Vector3;
|
||||||
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly host: HTMLElement,
|
private readonly host: HTMLElement,
|
||||||
@@ -150,6 +157,9 @@ export class MuJoCoViewer {
|
|||||||
this.camera.position.set(3, -3, 2);
|
this.camera.position.set(3, -3, 2);
|
||||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||||
this.controls.enableDamping = true;
|
this.controls.enableDamping = true;
|
||||||
|
this.controls.addEventListener('start', () => {
|
||||||
|
this.cameraFocusTransition = undefined;
|
||||||
|
});
|
||||||
this.scene.background = new THREE.Color(0x0b1220);
|
this.scene.background = new THREE.Color(0x0b1220);
|
||||||
this.hemisphere = new THREE.HemisphereLight(0xffffff, 0x223344, 1.3);
|
this.hemisphere = new THREE.HemisphereLight(0xffffff, 0x223344, 1.3);
|
||||||
this.scene.add(this.hemisphere);
|
this.scene.add(this.hemisphere);
|
||||||
@@ -405,7 +415,55 @@ export class MuJoCoViewer {
|
|||||||
this.option.geomgroup[5] = 0;
|
this.option.geomgroup[5] = 0;
|
||||||
this.visualMapLayer.visible = this.showVisualMap && this.displayOptions.showVisual;
|
this.visualMapLayer.visible = this.showVisualMap && this.displayOptions.showVisual;
|
||||||
}
|
}
|
||||||
|
focusSelection(bodyId?: number): boolean {
|
||||||
|
let sphere =
|
||||||
|
this.mapEditorLayer.selectedBoundingSphere() ??
|
||||||
|
this.parametricMapPreviewLayer.selectedBoundingSphere();
|
||||||
|
if (!sphere && bodyId !== undefined) {
|
||||||
|
const bounds = new THREE.Box3();
|
||||||
|
let found = false;
|
||||||
|
for (const mesh of this.meshes) {
|
||||||
|
if (Number(mesh.userData.bodyId) !== bodyId) continue;
|
||||||
|
mesh.updateWorldMatrix(true, true);
|
||||||
|
bounds.expandByObject(mesh);
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
if (found && !bounds.isEmpty()) sphere = bounds.getBoundingSphere(new THREE.Sphere());
|
||||||
|
}
|
||||||
|
if (!sphere && this.selected) {
|
||||||
|
this.selected.updateWorldMatrix(true, true);
|
||||||
|
const bounds = new THREE.Box3().setFromObject(this.selected);
|
||||||
|
if (!bounds.isEmpty()) sphere = bounds.getBoundingSphere(new THREE.Sphere());
|
||||||
|
}
|
||||||
|
if (!sphere) return false;
|
||||||
|
const direction = this.camera.position.clone().sub(this.controls.target);
|
||||||
|
if (direction.lengthSq() < 1e-6) direction.set(1, -1, 0.7);
|
||||||
|
direction.normalize();
|
||||||
|
const verticalFov = THREE.MathUtils.degToRad(this.camera.fov);
|
||||||
|
const fitDistance = sphere.radius / Math.max(0.1, Math.sin(verticalFov / 2));
|
||||||
|
const distance = Math.max(0.65, fitDistance * 1.35);
|
||||||
|
const toPosition = sphere.center.clone().addScaledVector(direction, distance);
|
||||||
|
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
this.cameraFocusTransition = undefined;
|
||||||
|
this.camera.position.copy(toPosition);
|
||||||
|
this.controls.target.copy(sphere.center);
|
||||||
|
this.controls.update();
|
||||||
|
} else {
|
||||||
|
this.cameraFocusTransition = {
|
||||||
|
started: performance.now(),
|
||||||
|
fromPosition: this.camera.position.clone(),
|
||||||
|
fromTarget: this.controls.target.clone(),
|
||||||
|
toPosition,
|
||||||
|
toTarget: sphere.center.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
flashMapEditorSurfaceAlignment(id?: string): void {
|
||||||
|
this.mapEditorLayer.flashSurfaceAlignment(id);
|
||||||
|
}
|
||||||
resetCamera(): void {
|
resetCamera(): void {
|
||||||
|
this.cameraFocusTransition = undefined;
|
||||||
if (this.session) this.fitCamera(this.session);
|
if (this.session) this.fitCamera(this.session);
|
||||||
}
|
}
|
||||||
highlightJoint(jointId: number | null): void {
|
highlightJoint(jointId: number | null): void {
|
||||||
@@ -461,6 +519,7 @@ export class MuJoCoViewer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fitCamera(session: SimulationSession): void {
|
private fitCamera(session: SimulationSession): void {
|
||||||
|
this.cameraFocusTransition = undefined;
|
||||||
const { extent, center } = session.geometryBounds();
|
const { extent, center } = session.geometryBounds();
|
||||||
this.modelExtent = extent;
|
this.modelExtent = extent;
|
||||||
this.controls.target.set(center[0], center[1], center[2]);
|
this.controls.target.set(center[0], center[1], center[2]);
|
||||||
@@ -474,6 +533,15 @@ export class MuJoCoViewer {
|
|||||||
this.camera.updateProjectionMatrix();
|
this.camera.updateProjectionMatrix();
|
||||||
this.controls.update();
|
this.controls.update();
|
||||||
}
|
}
|
||||||
|
private updateCameraFocusTransition(now: number): void {
|
||||||
|
const transition = this.cameraFocusTransition;
|
||||||
|
if (!transition) return;
|
||||||
|
const progress = Math.min(1, Math.max(0, (now - transition.started) / 320));
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
this.camera.position.lerpVectors(transition.fromPosition, transition.toPosition, eased);
|
||||||
|
this.controls.target.lerpVectors(transition.fromTarget, transition.toTarget, eased);
|
||||||
|
if (progress === 1) this.cameraFocusTransition = undefined;
|
||||||
|
}
|
||||||
private resize(): void {
|
private resize(): void {
|
||||||
const w = Math.max(1, this.host.clientWidth),
|
const w = Math.max(1, this.host.clientWidth),
|
||||||
h = Math.max(1, this.host.clientHeight);
|
h = Math.max(1, this.host.clientHeight);
|
||||||
@@ -489,6 +557,8 @@ export class MuJoCoViewer {
|
|||||||
try {
|
try {
|
||||||
const result = this.session?.advance(now) ?? { steps: 0, stepMs: 0, overBudget: false };
|
const result = this.session?.advance(now) ?? { steps: 0, stepMs: 0, overBudget: false };
|
||||||
this.updateThemeTransition(now);
|
this.updateThemeTransition(now);
|
||||||
|
this.updateCameraFocusTransition(now);
|
||||||
|
this.mapEditorLayer.update(now);
|
||||||
this.controls.update();
|
this.controls.update();
|
||||||
this.orientationGizmo.update(this.camera);
|
this.orientationGizmo.update(this.camera);
|
||||||
if (this.session) {
|
if (this.session) {
|
||||||
|
|||||||
@@ -171,6 +171,15 @@ export class ParametricMapPreviewLayer {
|
|||||||
return this.objects.size > 0;
|
return this.objects.size > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectedBoundingSphere(): THREE.Sphere | null {
|
||||||
|
const selected = this.selectedId ? this.objects.get(this.selectedId)?.root : undefined;
|
||||||
|
if (!selected) return null;
|
||||||
|
selected.updateWorldMatrix(true, true);
|
||||||
|
const bounds = new THREE.Box3().setFromObject(selected);
|
||||||
|
if (bounds.isEmpty()) return null;
|
||||||
|
return bounds.getBoundingSphere(new THREE.Sphere());
|
||||||
|
}
|
||||||
|
|
||||||
setAssets(assets: readonly PlacedMapAsset[], previewIds?: readonly string[]): void {
|
setAssets(assets: readonly PlacedMapAsset[], previewIds?: readonly string[]): void {
|
||||||
this.previewIds = new Set(previewIds ?? assets.map((asset) => asset.id));
|
this.previewIds = new Set(previewIds ?? assets.map((asset) => asset.id));
|
||||||
const desired = new Map(
|
const desired = new Map(
|
||||||
|
|||||||
Reference in New Issue
Block a user