From fa5485049a0483d68d6fa603bc7a4aa1dc8a023f Mon Sep 17 00:00:00 2001 From: cen617-code <1057290604@qq.com> Date: Fri, 4 Sep 2026 15:34:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(web-platform):=20release=20V0.8.3=20?= =?UTF-8?q?=E5=B8=B8=E8=A7=84=E7=95=8C=E9=9D=A2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- package-lock.json | 4 +- package.json | 2 +- plans/map-v3-design.md | 205 ------ plans/reward-auto-tuning-agent.md | 104 --- plans/urdf-studio-ui-components.md | 178 ------ web_platform/e2e/app.spec.ts | 161 +++-- web_platform/src/app/App.tsx | 441 ++++++++++--- .../app/components/LayoutSettingsDialog.tsx | 2 +- .../app/components/MapViewportTools.test.tsx | 99 +++ .../src/app/components/MapViewportTools.tsx | 202 ++++++ .../components/ModelControlsSidebar.test.tsx | 162 +++++ .../app/components/ModelControlsSidebar.tsx | 535 ++++++---------- .../src/app/components/ProjectSidebar.tsx | 263 ++++---- .../src/app/components/RightSidebarTabs.tsx | 65 ++ .../src/app/components/SceneOutliner.test.tsx | 120 ++++ .../src/app/components/SceneOutliner.tsx | 275 ++++++++ .../src/app/components/ShortcutHelpDialog.tsx | 13 +- .../src/app/components/SidebarPanel.tsx | 8 +- .../src/app/components/ViewerToolDock.tsx | 20 +- .../src/app/components/ViewportHUD.tsx | 24 +- .../src/app/components/WorkbenchHeader.tsx | 6 +- .../components/WorkspaceToolsPanel.test.tsx | 80 +++ .../app/components/WorkspaceToolsPanel.tsx | 295 +++++++++ web_platform/src/app/editorSelection.ts | 11 + .../app/hooks/useMapEditorShortcuts.test.tsx | 103 +++ .../src/app/hooks/useMapEditorShortcuts.ts | 101 +++ web_platform/src/components/ui/Badge.tsx | 2 +- web_platform/src/components/ui/Button.tsx | 2 +- web_platform/src/components/ui/IconButton.tsx | 2 +- .../src/components/ui/ResizablePanel.tsx | 2 +- .../ui/ScrubbableNumberInput.test.tsx | 45 ++ .../components/ui/ScrubbableNumberInput.tsx | 190 ++++++ .../components/ui/VerticalSplitPane.test.tsx | 55 ++ .../src/components/ui/VerticalSplitPane.tsx | 210 +++++++ web_platform/src/components/ui/index.ts | 2 + web_platform/src/map/MapAssetLibrary.test.tsx | 224 ++----- web_platform/src/map/MapAssetLibrary.tsx | 184 +----- web_platform/src/map/MapEditorPanel.test.tsx | 48 +- web_platform/src/map/MapEditorPanel.tsx | 593 ++++++------------ web_platform/src/map/MapObjectInspector.tsx | 321 ++++++++++ web_platform/src/map/MapTransformToolbar.tsx | 64 -- .../src/map/PhysicalMapPanel.test.tsx | 18 +- web_platform/src/map/PhysicalMapPanel.tsx | 121 ++-- web_platform/src/map/editor/MapEditSession.ts | 6 + web_platform/src/map/editor/editor.test.ts | 4 + web_platform/src/map/editor/types.ts | 14 + .../src/project/ModelStructureTree.tsx | 68 +- web_platform/src/project/importer.test.ts | 25 + web_platform/src/project/importer.ts | 45 +- .../src/simulation/ActuatorControl.tsx | 30 +- .../src/simulation/RobotInspector.tsx | 327 ++++++++++ web_platform/src/styles.css | 107 +++- .../src/training/LocalTrainingPanel.test.tsx | 12 +- .../src/training/LocalTrainingPanel.tsx | 16 +- web_platform/src/tuning/ScalarChart.tsx | 142 ++++- .../src/viewer/MapEditorLayer.test.ts | 16 +- web_platform/src/viewer/MapEditorLayer.ts | 111 +++- web_platform/src/viewer/MuJoCoViewer.ts | 70 +++ .../src/viewer/ParametricMapPreviewLayer.ts | 9 + 60 files changed, 4494 insertions(+), 2073 deletions(-) delete mode 100644 plans/map-v3-design.md delete mode 100644 plans/reward-auto-tuning-agent.md delete mode 100644 plans/urdf-studio-ui-components.md create mode 100644 web_platform/src/app/components/MapViewportTools.test.tsx create mode 100644 web_platform/src/app/components/MapViewportTools.tsx create mode 100644 web_platform/src/app/components/ModelControlsSidebar.test.tsx create mode 100644 web_platform/src/app/components/RightSidebarTabs.tsx create mode 100644 web_platform/src/app/components/SceneOutliner.test.tsx create mode 100644 web_platform/src/app/components/SceneOutliner.tsx create mode 100644 web_platform/src/app/components/WorkspaceToolsPanel.test.tsx create mode 100644 web_platform/src/app/components/WorkspaceToolsPanel.tsx create mode 100644 web_platform/src/app/editorSelection.ts create mode 100644 web_platform/src/app/hooks/useMapEditorShortcuts.test.tsx create mode 100644 web_platform/src/app/hooks/useMapEditorShortcuts.ts create mode 100644 web_platform/src/components/ui/ScrubbableNumberInput.test.tsx create mode 100644 web_platform/src/components/ui/ScrubbableNumberInput.tsx create mode 100644 web_platform/src/components/ui/VerticalSplitPane.test.tsx create mode 100644 web_platform/src/components/ui/VerticalSplitPane.tsx create mode 100644 web_platform/src/map/MapObjectInspector.tsx delete mode 100644 web_platform/src/map/MapTransformToolbar.tsx create mode 100644 web_platform/src/simulation/RobotInspector.tsx diff --git a/.gitignore b/.gitignore index 4ce07305..32bc15ea 100644 --- a/.gitignore +++ b/.gitignore @@ -16,9 +16,10 @@ __pycache__/ *.py[cod] *.egg-info/ -# Local build and cache directories +# Local build, cache, and planning directories build/ .cache/ +/plans/ # Editors and operating systems .vscode/ diff --git a/package-lock.json b/package-lock.json index 639559aa..1a8b8740 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mujoco-web-platform", - "version": "0.8.2", + "version": "0.8.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mujoco-web-platform", - "version": "0.8.2", + "version": "0.8.3", "license": "Apache-2.0", "dependencies": { "@monaco-editor/react": "^4.7.0", diff --git a/package.json b/package.json index c97d62f7..8eb467d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mujoco-web-platform", - "version": "0.8.2", + "version": "0.8.3", "description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台", "private": true, "type": "module", diff --git a/plans/map-v3-design.md b/plans/map-v3-design.md deleted file mode 100644 index d653b2ac..00000000 --- a/plans/map-v3-design.md +++ /dev/null @@ -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__...` 与机器人隔离。 - -## 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 资产完整性; -- 三类地图来源的统一拖放、场景树和单次提交边界; -- 普通模型重载不提交场景草稿,应用失败后草稿仍可放弃或重试; -- 工程地图实例物理/视觉/出生点变换一致; -- 首个认证资产临时场景的完整放弃; -- 认证资产在程序地形上的重力落位,以及坑洞不被无限平面填平。 diff --git a/plans/reward-auto-tuning-agent.md b/plans/reward-auto-tuning-agent.md deleted file mode 100644 index 95170471..00000000 --- a/plans/reward-auto-tuning-agent.md +++ /dev/null @@ -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/`、`MetricsManager` 的 `Episode_Metrics/`、`TerminationManager` 的 `Episode_Termination/`,以及 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 或浏览器存储中。 diff --git a/plans/urdf-studio-ui-components.md b/plans/urdf-studio-ui-components.md deleted file mode 100644 index d49faf31..00000000 --- a/plans/urdf-studio-ui-components.md +++ /dev/null @@ -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 的新组件都需再次向用户确认。 diff --git a/web_platform/e2e/app.spec.ts b/web_platform/e2e/app.spec.ts index 3a0d4b68..74822e3c 100644 --- a/web_platform/e2e/app.spec.ts +++ b/web_platform/e2e/app.spec.ts @@ -145,6 +145,7 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => { }); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('button', { name: '显示设置' }).click(); const displayDialog = page.getByRole('dialog', { name: '视图显示设置' }); await expect(displayDialog).toBeVisible(); @@ -162,16 +163,22 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => { await page.getByRole('button', { name: /FPS .*物理/ }).click(); await expect(page.getByRole('dialog', { name: '性能详情' })).toBeVisible(); await page.keyboard.press('Escape'); - await page.getByRole('tab', { name: '控制' }).click(); - await page.getByRole('button', { name: 'Actuator' }).click(); - await expect(page.getByText('motor', { exact: true })).toBeVisible(); - await expect( - page.getByRole('tabpanel', { name: '控制' }).getByText('slide', { exact: true }), - ).toBeVisible(); - await page.getByRole('tab', { name: '模型结构' }).click(); + await page.getByRole('tab', { name: '控制台' }).click(); + const tools = page.getByRole('tabpanel', { name: '控制台' }); + await expect(page.getByRole('dialog', { name: '工作区工具' })).toHaveCount(0); + await expect(page.locator('main canvas')).toBeVisible(); + await tools.getByRole('button', { name: /执行器实时控制/ }).click(); + await expect(tools.getByText('motor', { exact: true })).toBeVisible(); + await expect(tools.getByText('关节:slide', { exact: true })).toBeVisible(); + await page.getByRole('tab', { name: '数据录制' }).click(); + await expect(page.getByRole('tabpanel', { name: '数据录制' })).toContainText('仿真遥测记录'); + await expect(page.locator('main canvas')).toBeVisible(); + await page.getByRole('tab', { name: '检查器' }).click(); const structure = page.getByRole('navigation', { name: '模型结构树' }); await expect(structure).toBeVisible(); - await structure.getByRole('treeitem', { name: /hinge/ }).hover(); + const hinge = structure.getByRole('treeitem', { name: /hinge/ }); + await hinge.hover(); + await hinge.click(); await expect(page.getByRole('alert')).toHaveCount(0); await expect(page.getByRole('button', { name: '重置关节' })).toBeVisible(); await page.getByRole('button', { name: '高级' }).click(); @@ -195,10 +202,10 @@ test('窄视口默认保留完整视口并可按需打开侧栏', async ({ page await page.goto('/'); await expect(page.getByRole('main')).toBeInViewport(); await expect(page.getByRole('button', { name: '显示工程面板' })).toBeVisible(); - await expect(page.getByRole('button', { name: '显示属性面板' })).toBeVisible(); - await page.getByRole('button', { name: '显示属性面板' }).click(); + await expect(page.getByRole('button', { name: '显示右侧面板' })).toBeVisible(); + await page.getByRole('button', { name: '显示右侧面板' }).click(); await expect( - page.getByRole('complementary').filter({ hasText: '导入模型后显示属性' }), + page.getByRole('complementary').filter({ hasText: '导入模型后显示检查器' }), ).toBeVisible(); }); @@ -296,7 +303,6 @@ test('加载引用 OBJ 的 URDF 工程', async ({ page }) => { await options.getByRole('button', { name: '转换并加载' }).click(); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('2 个文件')).toBeVisible(); - await page.getByRole('button', { name: 'URDF 处理方式' }).click(); await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf'); await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating'); await page.getByRole('button', { name: '通知中心' }).click(); @@ -344,8 +350,11 @@ test('URDF 自动生成的关节驱动器与摄像头可通过 MuJoCo 编译', a await expect(notifications).toContainText('已为 1 个 hinge/slide 关节生成 motor 驱动器'); await expect(notifications).toContainText('已将 640×480 摄像头固连到 arm'); await page.keyboard.press('Escape'); - await page.getByRole('tab', { name: '控制' }).click(); - await page.getByRole('button', { name: 'Actuator' }).click(); + await page.getByRole('tab', { name: '控制台' }).click(); + await page + .getByRole('tabpanel', { name: '控制台' }) + .getByRole('button', { name: /执行器实时控制/ }) + .click(); await expect(page.getByText('shoulder_motor')).toBeVisible(); await expect(page.getByText('关节:shoulder')).toBeVisible(); await expect(page.getByText('N·m', { exact: true })).toBeVisible(); @@ -413,8 +422,10 @@ test('slide 关节向屏幕轴正方向拖动时 qpos 同向增加', async ({ pa await page.mouse.down(); await page.mouse.move(x + 70, y, { steps: 8 }); await page.mouse.up(); - await page.getByRole('tab', { name: '控制' }).click(); - const jointSection = page.getByRole('button', { name: '关节 1' }); + await page.getByRole('tab', { name: '控制台' }).click(); + const jointSection = page + .getByRole('tabpanel', { name: '控制台' }) + .getByRole('button', { name: /关节姿态调试/ }); if ((await jointSection.getAttribute('aria-expanded')) === 'false') await jointSection.click(); const output = page.getByText('screen_x').locator('..').locator('output'); await expect @@ -429,7 +440,11 @@ test('可导入并启用 Python 控制器', async ({ page }) => { .first() .setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) }); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); - await page.getByRole('tab', { name: '控制' }).click(); + await page.getByRole('tab', { name: '控制台' }).click(); + await page + .getByRole('tabpanel', { name: '控制台' }) + .getByRole('button', { name: /Python 脚本控制/ }) + .click(); const python = `NAME = "测试 PD 控制器"\nCONTROL_HZ = 100\ndef init(api):\n return {"joint": api.joint("slide"), "actuator": api.actuator("motor"), "body": api.body("box")}\ndef step(ctx, state):\n assert len(ctx.body_quat(state["body"])) == 4\n assert len(ctx.body_position(state["body"])) == 3\n ctx.set_control(state["actuator"], -ctx.qpos(state["joint"]) - 0.1 * ctx.qvel(state["joint"]))\n`; await page .locator('input[accept=".py,text/x-python"]') @@ -437,7 +452,9 @@ test('可导入并启用 Python 控制器', async ({ page }) => { await expect(page.getByText('测试 PD 控制器', { exact: true })).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('Python / Pyodide')).toBeVisible(); await page.getByRole('button', { name: '启用', exact: true }).click(); - await expect(page.getByText('运行中')).toBeVisible(); + await expect( + page.getByRole('tabpanel', { name: '控制台' }).getByRole('button', { name: /Python 脚本控制/ }), + ).toContainText('运行'); }); test('中等规模模型持续步进并可重复加载', async ({ page }) => { @@ -479,16 +496,15 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page }) buffer: Buffer.from(SIMPLE_MODEL), }); await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 }); - await page.getByRole('tab', { name: '地图' }).click(); const library = page.getByLabel('地图资产库'); - await expect(library.getByText('点击添加,或按住资产拖到画布落位。')).toBeVisible(); + await expect(library.getByText('物理几何原语', { exact: true })).toBeVisible(); await library.getByRole('button', { name: '添加基础方盒' }).click(); await expect(page.getByText('正在加载 MuJoCo 与模型…')).toHaveCount(0); await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json', { timeout: 30_000, }); - await expect(page.getByText('认证资产与场景对象属性')).toBeVisible(); + await expect(page.getByLabel('地图物体检查器')).toBeVisible(); await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible(); 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 page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒').click(); - await page.getByLabel('对象放置方式').selectOption('locked'); + await page.getByLabel('贴地检测模式').selectOption('locked'); await expect(page.getByLabel('对象位置X')).toBeDisabled(); - await page.getByLabel('对象放置方式').selectOption('auto_ground'); + await page.getByLabel('贴地检测模式').selectOption('auto_ground'); await expect(page.getByLabel('对象位置X')).toBeEnabled(); const gizmoLine = page.getByRole('img', { name: 'XYZ 方向指示器' }).locator('line').first(); @@ -526,20 +542,19 @@ test('认证资产自动打开地图属性并与参数地形一次编译', async buffer: Buffer.from(SIMPLE_MODEL), }); await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 }); - await page.getByRole('tab', { name: '地图' }).click(); const library = page.getByLabel('地图资产库'); - await page.getByRole('tab', { name: '属性' }).click(); await library.getByRole('button', { name: '添加基础方盒' }).click(); - await expect(page.getByRole('tab', { name: '地图' })).toHaveAttribute('aria-selected', 'true'); - await expect(page.getByText('认证资产与场景对象属性')).toBeVisible(); + await expect(page.getByText('Map / Object')).toBeVisible(); + await expect(page.getByLabel('地图物体检查器')).toBeVisible(); await expect(page.getByText('1 项场景更改待应用')).toBeVisible(); const sceneTree = page.getByLabel('场景资产树'); await expect(sceneTree.getByText('基础方盒')).toBeVisible(); - await page.getByRole('tab', { name: '属性' }).click(); - await sceneTree.getByRole('button', { name: /基础方盒/ }).click(); - await expect(page.getByRole('tab', { name: '地图' })).toHaveAttribute('aria-selected', 'true'); + await sceneTree.getByRole('treeitem', { name: 'box', exact: true }).click(); + await expect(page.getByText('Robot / Body')).toBeVisible(); + await sceneTree.getByRole('treeitem', { name: /基础方盒/ }).click(); + await expect(page.getByText('Map / Object')).toBeVisible(); await library.getByRole('button', { name: '添加随机粗糙地形' }).click(); await page.getByLabel('位置 X(m)').fill('4'); @@ -564,10 +579,10 @@ test('认证资产自动打开地图属性并与参数地形一次编译', async } expect(selectedDraftAsset).toBe(true); 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 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(/已加载工程地图“场景 1”(1 个物理几何/)).toBeVisible(); @@ -610,15 +625,14 @@ test('放弃首次认证资产会完整回滚临时场景和工程文件', async buffer: Buffer.from(SIMPLE_MODEL), }); await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 }); - await page.getByRole('tab', { name: '地图' }).click(); const library = page.getByLabel('地图资产库'); await library.getByRole('button', { name: '添加基础方盒' }).click(); await expect(page.getByText('1 项场景更改待应用')).toBeVisible(); await expect(page.getByLabel('场景资产树')).toContainText('场景 1'); - await page.getByRole('button', { name: '放弃场景更改' }).click(); + await page.getByRole('button', { name: '放弃更改' }).click(); 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); // 文件和 map id 也必须回滚;再次创建应复用 scene_1,而不是泄漏出 scene_2。 @@ -654,7 +668,6 @@ test('工程地图与参数地形共享放置草稿、实例变换和回滚入 buffer: Buffer.from(project), }); await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 }); - await page.getByRole('tab', { name: '地图' }).click(); const library = page.getByLabel('地图资产库'); await library.getByRole('button', { name: '放置工程地图 草稿仓库' }).click(); @@ -667,7 +680,7 @@ test('工程地图与参数地形共享放置草稿、实例变换和回滚入 await library.getByRole('button', { name: '添加波浪地形' }).click(); 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(/已加载工程地图“草稿仓库”/)).toBeVisible(); await expect(page.getByText(/已加载波浪地形物理地图/)).toBeVisible(); @@ -675,7 +688,7 @@ test('工程地图与参数地形共享放置草稿、实例变换和回滚入 await page.getByRole('button', { name: '删除地图实例 草稿仓库' }).click(); await expect(page.getByText('1 项场景更改待应用')).toBeVisible(); await expect(page.getByLabel('场景资产树')).not.toContainText('草稿仓库'); - await page.getByRole('button', { name: '放弃场景更改' }).click(); + await page.getByRole('button', { name: '放弃更改' }).click(); await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0); await expect(page.getByLabel('场景资产树')).toContainText('草稿仓库'); }); @@ -692,7 +705,6 @@ test('认证资产重力放置使用参数地形的真实承载高度', async ({ buffer: Buffer.from(SIMPLE_MODEL), }); await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 }); - await page.getByRole('tab', { name: '地图' }).click(); const library = page.getByLabel('地图资产库'); await library.getByRole('button', { name: '添加深坑地形' }).click(); @@ -700,7 +712,7 @@ test('认证资产重力放置使用参数地形的真实承载高度', async ({ await library.getByLabel('新增资产放置方式').selectOption('gravity'); 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.getByText('2 项场景更改待应用')).toBeVisible(); }); @@ -717,7 +729,6 @@ test('参数化地形可连续拖到画布并一次性编译', async ({ page }) buffer: Buffer.from(SIMPLE_MODEL), }); 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 expect(page.getByText('1 项场景更改待应用')).toBeVisible(); @@ -727,18 +738,19 @@ test('参数化地形可连续拖到画布并一次性编译', async ({ page }) 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(); expect(canvasBox).not.toBeNull(); await page.mouse.click( canvasBox!.x + canvasBox!.width * 0.5, canvasBox!.y + canvasBox!.height * 0.78, ); - await expect(page.getByRole('tab', { name: '地图' })).toHaveAttribute('aria-selected', 'true'); - await expect(page.getByLabel('视口选择与变换')).toBeVisible(); + await expect(page.getByText('Map / Instance')).toBeVisible(); + await expect(page.getByLabel('地图视口工具')).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(/已加载随机粗糙地形物理地图/)).toBeVisible({ timeout: 30_000, @@ -758,7 +770,6 @@ test('应用内置 MJCF 楼梯物理地图', async ({ page }) => { buffer: Buffer.from(SIMPLE_MODEL), }); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); - await page.getByRole('tab', { name: '地图' }).click(); await page.getByLabel('地图来源').selectOption('builtin'); await page.getByLabel('物理地图预设').selectOption('stairs'); await page.getByLabel('台阶数量').fill('6'); @@ -790,7 +801,6 @@ test('依次应用全部系统参数化地形', async ({ page }) => { buffer: Buffer.from(SIMPLE_MODEL), }); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); - await page.getByRole('tab', { name: '地图' }).click(); await page.getByLabel('地图来源').selectOption('builtin'); for (const [preset, label] of terrains) { await page.getByLabel('物理地图预设').selectOption(preset); @@ -834,7 +844,6 @@ test('导入并应用分层工程地图包', async ({ page }) => { buffer: Buffer.from(project), }); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); - await page.getByRole('tab', { name: '地图' }).click(); await page.getByLabel('地图来源').selectOption({ label: '测试场景' }); await expect(page.getByLabel('地图出生点')).toHaveValue('start'); await page.getByRole('button', { name: '应用并重新编译' }).click(); @@ -869,14 +878,15 @@ test('将受支持的只读物理地图转换为可编辑副本', async ({ page buffer: Buffer.from(project), }); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); - await page.getByRole('tab', { name: '地图' }).click(); await page.getByLabel('地图来源').selectOption({ label: '旧版基础场景' }); await page.getByRole('button', { name: '应用并重新编译' }).click(); const editor = page.getByText('认证资产与场景对象属性').locator('..'); await expect(editor.getByText(/保持只读/)).toBeVisible({ timeout: 30_000 }); await editor.getByRole('button', { name: '创建可编辑副本' }).click(); await expect(page.getByText('已创建可编辑地图副本')).toBeVisible({ timeout: 30_000 }); - await expect(editor.getByRole('button', { name: '移动工具 W' })).toBeVisible(); + await expect( + page.getByLabel('地图视口工具').getByRole('button', { name: '移动工具 W' }), + ).toBeVisible(); await expect(editor.getByRole('button', { name: /floor · 方盒/ })).toBeVisible(); await expect(editor.getByRole('button', { name: /wall · 方盒/ })).toBeVisible(); }); @@ -915,30 +925,56 @@ test('编辑 V3 地图对象并事务式应用', async ({ page }) => { buffer: Buffer.from(project), }); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); - await page.getByRole('tab', { name: '地图' }).click(); await page.getByLabel('地图来源').selectOption({ label: '可编辑场景' }); await page.getByRole('button', { name: '应用并重新编译' }).click(); await expect(page.getByText('认证资产与场景对象属性')).toBeVisible({ timeout: 30_000 }); - const editor = page.getByText('认证资产与场景对象属性').locator('..'); - await expect(editor.getByRole('button', { name: '移动工具 W' })).toHaveAttribute( + const mapTools = page.getByLabel('地图视口工具'); + await expect(mapTools.getByRole('button', { name: '移动工具 W' })).toHaveAttribute( 'aria-pressed', 'true', ); await page.keyboard.press('e'); - await expect(editor.getByRole('button', { name: '旋转工具 E' })).toHaveAttribute( + await expect(mapTools.getByRole('button', { name: '旋转工具 E' })).toHaveAttribute( 'aria-pressed', 'true', ); - await page.keyboard.press('s'); - await expect(editor.getByRole('button', { name: '缩放工具 S' })).toHaveAttribute( + await page.keyboard.press('r'); + await expect(mapTools.getByRole('button', { name: '缩放工具 R' })).toHaveAttribute( 'aria-pressed', 'true', ); - await editor.getByRole('button', { name: '新增', exact: true }).click(); - await editor.getByLabel('对象位置X').fill('2'); - await editor.getByRole('button', { name: '应用并重新编译' }).click(); - await expect(editor.getByRole('button', { name: /box · 方盒/ })).toBeVisible({ timeout: 30_000 }); - await expect(editor.getByText('地图草稿尚未应用')).toHaveCount(0); + await page.getByRole('button', { name: '新增', exact: true }).click(); + await page.getByLabel('对象位置X').fill('2'); + await page.getByLabel('对象位置X').blur(); + await page.keyboard.press('f'); + const draftStatus = page.getByLabel('地图草稿状态'); + await expect(draftStatus).toContainText('未保存改动'); + await page.keyboard.press('Control+s'); + await expect( + page.getByLabel('地图对象列表').getByRole('button', { name: /box · 方盒/ }), + ).toBeVisible({ + timeout: 30_000, + }); + await expect(draftStatus).toContainText('地图草稿已同步'); + + await page + .getByLabel('地图对象列表') + .getByRole('button', { name: /box · 方盒/ }) + .click(); + await page.getByLabel('对象位置X').fill('3'); + await expect(draftStatus).toContainText('未保存改动'); + await draftStatus.getByRole('button', { name: '丢弃地图草稿' }).click(); + await page + .getByLabel('地图对象列表') + .getByRole('button', { name: /box · 方盒/ }) + .click(); + await expect(page.getByLabel('对象位置X')).toHaveValue('2'); + await expect(draftStatus).toContainText('地图草稿已同步'); + + await page.getByRole('button', { name: '新增', exact: true }).click(); + await expect(page.getByLabel('地图对象列表').getByRole('button')).toHaveCount(2); + await page.keyboard.press('Delete'); + await expect(page.getByLabel('地图对象列表').getByRole('button')).toHaveCount(1); await expect(page.getByText('WASM 已加载')).toBeVisible(); }); @@ -974,7 +1010,6 @@ test('工程地图编译失败时保留上一仿真会话', async ({ page }) => const runningTime = Number( (await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0, ); - await page.getByRole('tab', { name: '地图' }).click(); await page.getByLabel('地图来源').selectOption({ label: '动态错误地图' }); await page.getByRole('button', { name: '应用并重新编译' }).click(); 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), ) .toBeGreaterThan(runningTime); - await page.getByRole('button', { name: '放弃场景更改' }).click(); + await page.getByRole('button', { name: '放弃更改' }).click(); await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0); await expect(page.getByLabel('地图来源')).toHaveValue('none'); }); diff --git a/web_platform/src/app/App.tsx b/web_platform/src/app/App.tsx index 565bf790..852fb76a 100644 --- a/web_platform/src/app/App.tsx +++ b/web_platform/src/app/App.tsx @@ -19,6 +19,7 @@ import { CircleHelp, Code2, Crosshair, + Database, Download, Hand, Maximize, @@ -28,6 +29,7 @@ import { Play, RotateCcw, Settings as SettingsIcon, + SlidersHorizontal, SunMoon, } from 'lucide-react'; import { DEFAULT_IMPORT_LIMITS, type MapEntry, type ProjectManifest } from '../project/types'; @@ -55,8 +57,17 @@ import { import { useAppStore, type AppDiagnostic } from '../stores/useAppStore'; import { WorkbenchHeader } from './components/WorkbenchHeader'; import { ViewerToolDock } from './components/ViewerToolDock'; +import { + MapAssetDropIndicator, + MapDraftStatusOverlay, + MapViewportToolbar, + type MapAssetDropTarget, +} from './components/MapViewportTools'; import { ModelControlsSidebar } from './components/ModelControlsSidebar'; -import { ProjectSidebar } from './components/ProjectSidebar'; +import { ProjectSidebar, type ProjectResourceTab } from './components/ProjectSidebar'; +import type { WorkspaceTool } from './components/WorkspaceToolsPanel'; +import type { EditorSelection } from './editorSelection'; +import { useMapEditorShortcuts } from './hooks/useMapEditorShortcuts'; import { WorkspaceOverlays, type ImportProgress } from './components/WorkspaceOverlays'; import { EntrySelectionDialog } from './components/EntrySelectionDialog'; import { ErrorRecoveryPanel } from './components/ErrorRecoveryPanel'; @@ -107,6 +118,7 @@ import type { EditableMapDocument, EditableMapObjectType, MapEditorInteractionCallbacks, + MapEditorSessionState, MapEditorTransformMode, MapObjectPlacementMode, } from '../map/editor/types'; @@ -131,6 +143,11 @@ const SourceEditorDialog = lazy(() => default: module.SourceEditorDialog, })), ); +const WorkspaceToolsPanel = lazy(() => + import('./components/WorkspaceToolsPanel').then((module) => ({ + default: module.WorkspaceToolsPanel, + })), +); function hasTransferType(dataTransfer: DataTransfer, type: string): boolean { return Array.from(dataTransfer.types).includes(type); @@ -253,6 +270,7 @@ export function App() { importInFlight = useRef(false), adapter = useRef(new MainThreadPhysicsAdapter()), root = useRef(null), + viewportShell = useRef(null), viewerHost = useRef(null), viewer = useRef(null), viewerReady = useRef | null>(null), @@ -307,12 +325,16 @@ export function App() { [committedEditorDocuments, setCommittedEditorDocuments] = useState< Map >(() => new Map()), - [projectSidebarTab, setProjectSidebarTab] = useState<'project' | 'structure' | 'assets'>( - 'project', + [projectSidebarTab, setProjectSidebarTab] = useState('assets'), + [editorSelection, setEditorSelection] = useState(null), + [workspaceTool, setWorkspaceTool] = useState(null), + [mapTransformMode, setMapTransformMode] = useState('translate'), + [mapSnapping, setMapSnapping] = useState(true), + [assetPlacementMode, setAssetPlacementMode] = useState('auto_ground'), + [editorSessionStates, setEditorSessionStates] = useState>( + () => new Map(), ), - [modelControlsTab, setModelControlsTab] = useState<'properties' | 'controls' | 'data' | 'map'>( - 'properties', - ); + [mapAssetDropTarget, setMapAssetDropTarget] = useState(); const [urdfMode, setUrdfMode] = useState('mjcf'), urdfModeRef = useRef('mjcf'); const [baseMode, setBaseMode] = useState('floating'), @@ -356,10 +378,27 @@ export function App() { : [], ), ); - return [...editorDrafts.keys()].filter((path) => !coveredDescriptors.has(path)).length; - }, [mapSceneDraft.changedIds, placedMapAssets, editorDrafts]); + return [...editorDrafts.keys()] + .filter((path) => !coveredDescriptors.has(path)) + .reduce( + (count, path) => count + Math.max(1, editorSessionStates.get(path)?.changeCount ?? 1), + 0, + ); + }, [mapSceneDraft.changedIds, placedMapAssets, editorDrafts, editorSessionStates]); const sceneDraftChangeCount = mapSceneDraft.changeCount + editorOnlyDraftCount, mapSceneDirty = sceneDraftChangeCount > 0; + const activeEditorView = + mapSelection.kind === 'project' + ? (editorDrafts.get(mapSelection.descriptorPath) ?? editorDocument) + : null; + const selectedMapObject = + editorSelection?.kind === 'map-object' && editorSelection.mapAssetId === activeMapAssetId + ? activeEditorView?.objects.find((object) => object.id === editorSelection.objectId) + : undefined; + const mapEditingActive = + Boolean(activeMapAssetId) && + (editorSelection?.kind === 'map' || editorSelection?.kind === 'map-object'); + const activePlacementMode = selectedMapObject?.placementMode ?? assetPlacementMode; const viewerSettings = useRef({ mode: state.mode, forceScale, @@ -402,10 +441,8 @@ export function App() { return; } state.setSelection(selection); - if (selection) { - setRightOpen(true); - setModelControlsTab('properties'); - } + setEditorSelection(selection ? { kind: 'body', bodyId: selection.bodyId } : null); + if (selection) setRightOpen(true); }, onFrame: (frame, fps, snapshot) => { const memory = (performance as Performance & { memory?: { usedJSHeapSize: number } }) @@ -434,10 +471,15 @@ export function App() { }, onMapEditorSelect: (id) => { editorInteraction.current?.onSelect(id); - if (id) { - setRightOpen(true); - setModelControlsTab('map'); - } + const mapAssetId = activeMapAssetIdRef.current; + setEditorSelection( + mapAssetId + ? id + ? { kind: 'map-object', mapAssetId, objectId: id } + : { kind: 'map', mapAssetId } + : null, + ); + if (mapAssetId) setRightOpen(true); }, onMapEditorPreviewSelect: (mapAssetId, objectId) => mapEditorPreviewInteraction.current(mapAssetId, objectId), @@ -487,6 +529,12 @@ export function App() { useEffect(() => { viewer.current?.setMode(state.mode); }, [state.mode]); + useEffect(() => { + viewer.current?.setMapEditorTransformMode(mapTransformMode); + }, [mapTransformMode]); + useEffect(() => { + viewer.current?.setMapEditorSnapping(mapSnapping ? 0.1 : null, mapSnapping ? 5 : null); + }, [mapSnapping]); useEffect(() => { if (viewer.current) viewer.current.forceScale = forceScale; }, [forceScale]); @@ -611,6 +659,7 @@ export function App() { } state.setSnapshot(snapshot); state.setSelection(null); + setEditorSelection(null); state.setPaused(true); setImportProgress({ title: '正在准备仿真', @@ -759,7 +808,8 @@ export function App() { manifest.current = next; setProjectMaps(next.maps); setCommittedEditorDocuments(manifestEditorDocuments(next)); - setProjectSidebarTab('project'); + setProjectSidebarTab('assets'); + setEditorSelection(null); setEditorDocument(null); editorDraftsRef.current = new Map(); setEditorDrafts(new Map()); @@ -827,10 +877,12 @@ export function App() { setPolicyStatus(undefined); setProjectMaps([]); setCommittedEditorDocuments(new Map()); - setProjectSidebarTab('project'); + setProjectSidebarTab('assets'); + setEditorSelection(null); setEditorDocument(null); editorDraftsRef.current = new Map(); setEditorDrafts(new Map()); + setEditorSessionStates(new Map()); provisionalMapFilesRef.current.clear(); viewer.current?.setMapEditorDocument(null); placedMapAssetsRef.current = []; @@ -902,6 +954,7 @@ export function App() { const clearEditorDrafts = useCallback(() => { editorDraftsRef.current = new Map(); setEditorDrafts(new Map()); + setEditorSessionStates(new Map()); }, []); const bindEditorInteraction = useCallback((callbacks: MapEditorInteractionCallbacks | null) => { editorInteraction.current = callbacks; @@ -923,16 +976,62 @@ export function App() { }, []); const selectEditorObject = useCallback((id: string | null) => { viewer.current?.selectMapEditorObject(id); + const mapAssetId = activeMapAssetIdRef.current; + setEditorSelection( + mapAssetId + ? id + ? { kind: 'map-object', mapAssetId, objectId: id } + : { kind: 'map', mapAssetId } + : null, + ); }, []); - const setEditorTransformMode = useCallback((mode: MapEditorTransformMode) => { - viewer.current?.setMapEditorTransformMode(mode); - }, []); - const setEditorSnapping = useCallback( - (translation: number | null, rotationDegrees: number | null) => { - viewer.current?.setMapEditorSnapping(translation, rotationDegrees); + const updateEditorSessionState = useCallback( + (descriptorPath: string, sessionState: MapEditorSessionState | null) => { + setEditorSessionStates((current) => { + const next = new Map(current); + if (sessionState?.dirty) next.set(descriptorPath, sessionState); + else next.delete(descriptorPath); + return next; + }); }, [], ); + const activateMapEditing = useCallback(() => { + state.setMode('select'); + }, []); + const changeMapTransformMode = useCallback((nextMode: MapEditorTransformMode) => { + setMapTransformMode(nextMode); + state.setMode('select'); + }, []); + const changeMapPlacementMode = useCallback( + (placementMode: MapObjectPlacementMode) => { + setAssetPlacementMode(placementMode); + if (placementMode === 'locked') + setMapTransformMode((currentMode) => (currentMode === 'scale' ? 'translate' : currentMode)); + if (editorSelection?.kind === 'map-object') { + editorInteraction.current?.onSetPlacementMode(editorSelection.objectId, placementMode); + if (placementMode !== 'locked') + viewer.current?.flashMapEditorSurfaceAlignment(editorSelection.objectId); + } + }, + [editorSelection], + ); + const alignSelectedMapObject = useCallback(() => { + if (editorSelection?.kind !== 'map-object') return; + editorInteraction.current?.onAlignToSurface(editorSelection.objectId); + viewer.current?.flashMapEditorSurfaceAlignment(editorSelection.objectId); + }, [editorSelection]); + const focusSelectedObject = useCallback(() => { + const bodyId = + editorSelection?.kind === 'body' || editorSelection?.kind === 'joint' + ? editorSelection.bodyId + : undefined; + viewer.current?.focusSelection(bodyId); + }, [editorSelection]); + const deleteSelectedMapObject = useCallback(() => { + if (editorSelection?.kind === 'map-object') + editorInteraction.current?.onDelete(editorSelection.objectId); + }, [editorSelection]); const readEditorDocument = useCallback((selection: MapSelection): EditableMapDocument | null => { if (selection.kind !== 'project' || !manifest.current) return null; const resolved = resolveProjectMap(manifest.current, selection.descriptorPath); @@ -1001,12 +1100,13 @@ export function App() { setActiveMapAssetId(active?.id); mapSelectionRef.current = nextSelection; setMapSelection(nextSelection); + if (nextSelection.kind === 'builtin') + setMapTransformMode((currentMode) => (currentMode === 'scale' ? 'translate' : currentMode)); }, [], ); const focusMapProperties = useCallback(() => { setRightOpen(true); - setModelControlsTab('map'); }, []); const previewVisualMapScene = useCallback( (assets: readonly PlacedMapAsset[], reload: boolean) => { @@ -1070,7 +1170,19 @@ export function App() { : null; setEditorDocument(committedEditorDocument); 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, previewEditorDocument, readEditorDocument, setMapScene], @@ -1116,8 +1228,8 @@ export function App() { adapter.current.setPaused(true); useAppStore.getState().setPaused(true); useAppStore.getState().setSelection(null); + setEditorSelection({ kind: 'map', mapAssetId: id }); setRightOpen(true); - setModelControlsTab('map'); }, [activateMapAsset], ); @@ -1133,8 +1245,8 @@ export function App() { setEditorDocument(null); adapter.current.setPaused(true); useAppStore.getState().setPaused(true); + setEditorSelection({ kind: 'map', mapAssetId: id }); setRightOpen(true); - setModelControlsTab('map'); }, [setMapScene], ); @@ -1252,6 +1364,8 @@ export function App() { setProjectMaps(maps); setCommittedEditorDocuments(manifestEditorDocuments(candidate)); setMapScene([...placedMapAssetsRef.current, placed], placed.id, selection); + setEditorSelection({ kind: 'map', mapAssetId: placed.id }); + setRightOpen(true); setEditorDocument(document); previewEditorDocument(document); state.setProject( @@ -1304,6 +1418,8 @@ export function App() { const placed = createPlacedMapAsset(selection, name); const assets = [...placedMapAssetsRef.current, placed]; setMapScene(assets, placed.id, selection); + setEditorSelection({ kind: 'map', mapAssetId: placed.id }); + setRightOpen(true); if (selection.kind === 'project') previewVisualMapScene(assets, true); setEditorDocument(readEditorDocument(selection)); viewer.current?.setMapEditorDocument(null); @@ -1321,6 +1437,7 @@ export function App() { ? (nextAssets.at(-1) ?? undefined) : nextAssets.find((asset) => asset.id === previousActiveId); setMapScene(nextAssets, nextActive?.id, nextActive?.selection); + setEditorSelection(nextActive ? { kind: 'map', mapAssetId: nextActive.id } : null); setEditorDocument(nextActive ? readEditorDocument(nextActive.selection) : null); viewer.current?.setMapEditorDocument(null); return true; @@ -1390,6 +1507,12 @@ export function App() { } editorDraftsRef.current = remainingDrafts; setEditorDrafts(remainingDrafts); + setEditorSessionStates((currentStates) => { + const nextStates = new Map(currentStates); + for (const path of submittedDrafts.keys()) + if (!remainingDrafts.has(path)) nextStates.delete(path); + return nextStates; + }); setProjectMaps(maps); setCommittedEditorDocuments(manifestEditorDocuments(committed)); const committedEditorDocument = readEditorDocument(mapSelectionRef.current); @@ -1406,10 +1529,13 @@ export function App() { entry.path, ); state.setSnapshot(adapter.current.snapshot() ?? undefined); + const selectedMapAssetId = activeMapAssetIdRef.current; + setEditorSelection(selectedMapAssetId ? { kind: 'map', mapAssetId: selectedMapAssetId } : null); return true; }; const discardMapSceneDraft = () => { if (state.loading || loadInFlight.current) return; + editorInteraction.current?.onDiscard(); clearEditorDrafts(); const omittedPaths = new Set(); for (const paths of provisionalMapFilesRef.current.values()) @@ -1426,11 +1552,13 @@ export function App() { restoredManifest.entries, state.selectedEntry, ); + state.setSnapshot(adapter.current.snapshot() ?? undefined); } const assets = restoreAppliedMapScene(appliedMapAssetsRef.current); const active = assets.find((asset) => asset.id === activeMapAssetIdRef.current) ?? assets.at(-1); setMapScene(assets, active?.id, active?.selection); + setEditorSelection(active ? { kind: 'map', mapAssetId: active.id } : null); previewVisualMapScene(assets, true); setEditorDocument(active ? readEditorDocument(active.selection) : null); viewer.current?.setMapEditorDocument(null); @@ -1465,6 +1593,7 @@ export function App() { ); const nextAssets = updatePlacedMapAsset(placedMapAssetsRef.current, active.id, value, nextName); setMapScene(nextAssets, active.id, value); + setEditorSelection({ kind: 'map', mapAssetId: active.id }); setEditorDocument(readEditorDocument(value)); viewer.current?.setMapEditorDocument(null); void commitMapScene(); @@ -1578,6 +1707,10 @@ export function App() { entryPath, ); state.setSnapshot(adapter.current.snapshot() ?? undefined); + const selectedMapAssetId = activeMapAssetIdRef.current; + setEditorSelection( + selectedMapAssetId ? { kind: 'map', mapAssetId: selectedMapAssetId } : null, + ); notify('已创建可编辑地图副本', authoringPath); return true; } catch (error) { @@ -1608,17 +1741,30 @@ export function App() { const resetDragState = () => { dragDepth.current = 0; setDragActive(false); + setMapAssetDropTarget(undefined); }; const dragEnter = (event: DragEvent) => { - if ( - hasTransferType(event.dataTransfer, 'Files') && - !hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME) - ) { + if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) { + const bounds = viewportShell.current?.getBoundingClientRect(); + if (bounds) + setMapAssetDropTarget({ + left: event.clientX - bounds.left, + top: event.clientY - bounds.top, + position: viewer.current?.mapPlanePoint(event.clientX, event.clientY) ?? null, + }); + return; + } + if (hasTransferType(event.dataTransfer, 'Files')) { dragDepth.current += 1; if (!state.loading) setDragActive(true); } }; const dragLeave = (event: DragEvent) => { + if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) + setMapAssetDropTarget(undefined); + return; + } if (!hasTransferType(event.dataTransfer, 'Files')) return; dragDepth.current = Math.max(0, dragDepth.current - 1); if (dragDepth.current === 0) setDragActive(false); @@ -1626,9 +1772,15 @@ export function App() { const dragOver = (event: DragEvent) => { event.preventDefault(); if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) { - event.dataTransfer.dropEffect = viewer.current?.mapPlanePoint(event.clientX, event.clientY) - ? 'copy' - : 'none'; + const position = viewer.current?.mapPlanePoint(event.clientX, event.clientY) ?? null; + const bounds = viewportShell.current?.getBoundingClientRect(); + if (bounds) + setMapAssetDropTarget({ + left: event.clientX - bounds.left, + top: event.clientY - bounds.top, + position, + }); + event.dataTransfer.dropEffect = position ? 'copy' : 'none'; return; } if (hasTransferType(event.dataTransfer, 'Files')) @@ -1981,6 +2133,10 @@ export function App() { if (document.fullscreenElement) void document.exitFullscreen().catch(() => {}); else if (root.current) void root.current.requestFullscreen().catch(() => {}); }; + const showWorkspaceTool = (tool: WorkspaceTool) => { + setWorkspaceTool(tool); + setRightOpen(true); + }; const applyLayoutPreset = (preset: LayoutPreset) => { if (preset === 'viewport') { setLeftOpen(false); @@ -1993,6 +2149,7 @@ export function App() { } else if (preset === 'control') { setLeftOpen(false); setRightOpen(true); + setWorkspaceTool('controls'); dispatchLayoutWidths(288, 384); } else { setLeftOpen(true); @@ -2000,19 +2157,36 @@ export function App() { dispatchLayoutWidths(288, 288); } }; + useMapEditorShortcuts({ + enabled: Boolean(state.snapshot), + mapEditing: mapEditingActive, + dirty: mapSceneDirty, + loading: state.loading, + hasSelection: Boolean(editorSelection ?? state.selection), + canDelete: editorSelection?.kind === 'map-object', + canScale: mapSelection.kind === 'project' && selectedMapObject?.placementMode !== 'locked', + onTransformMode: changeMapTransformMode, + onFocusSelection: focusSelectedObject, + onDeleteSelection: deleteSelectedMapObject, + onToggleSnapping: () => setMapSnapping((value) => !value), + onSave: () => void commitMapScene(), + }); useEffect(() => { const key = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; if ( document.activeElement instanceof HTMLElement && document.activeElement.closest('[role="dialog"]') ) return; + const target = event.target instanceof HTMLElement ? event.target : null; + if (target?.closest('input,select,textarea,[contenteditable="true"]')) return; if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'k') { event.preventDefault(); setCommandOpen(true); return; } - if ((event.target as HTMLElement).matches('input,select,button')) return; + if (target?.closest('button')) return; if (event.code === 'Space') { event.preventDefault(); togglePause(); @@ -2100,6 +2274,22 @@ export function App() { disabled: !state.snapshot, run: exportMjcf, }, + { + id: 'workspace-controls', + label: '在右侧打开控制台', + group: '工具', + icon: , + disabled: !state.snapshot, + run: () => showWorkspaceTool('controls'), + }, + { + id: 'workspace-data', + label: '在右侧打开数据录制', + group: '工具', + icon: , + disabled: !state.snapshot, + run: () => showWorkspaceTool('data'), + }, { id: 'left', label: leftOpen ? '隐藏工程面板' : '显示工程面板', @@ -2109,7 +2299,7 @@ export function App() { }, { id: 'right', - label: rightOpen ? '隐藏属性面板' : '显示属性面板', + label: rightOpen ? '隐藏右侧面板' : '显示右侧面板', group: '布局', icon: rightOpen ? : , run: () => setRightOpen((value) => !value), @@ -2136,6 +2326,63 @@ export function App() { run: () => setHelpOpen(true), }, ]; + const workspaceTools = workspaceTool ? ( + + 正在加载工作区工具… + + } + > + /\.py$/i.test(file.path)) + .map((file) => file.path)} + selectedControllerPath={selectedControllerPath} + controllerStatus={controllerStatus} + policyPaths={state.files + .filter((file) => /\.onnx$/i.test(file.path)) + .map((file) => file.path)} + selectedPolicyPath={selectedPolicyPath} + policyStatus={policyStatus} + onResetJoints={resetJoints} + onToggleJointLimits={toggleJointLimits} + onToggleAdvanced={() => setJointAdvanced((value) => !value)} + onToggleAngleUnit={() => setAngleUnit((value) => (value === 'rad' ? 'deg' : 'rad'))} + onActuator={setActuator} + onActuatorParameters={setActuatorParameters} + onJoint={setJoint} + onForceScale={setForceScale} + onSelectControllerPath={setSelectedControllerPath} + onLoadControllerPath={loadControllerPath} + onImportController={importController} + onToggleController={toggleController} + onControllerCommand={sendControllerCommand} + onRemoveController={removeController} + onSelectPolicyPath={setSelectedPolicyPath} + onLoadPolicyPath={loadPolicyPath} + onImportPolicy={importPolicy} + onTogglePolicy={togglePolicy} + onPolicyCommand={setPolicyCommand} + onRemovePolicy={removePolicy} + onDataRecorderConfigure={configureDataRecorder} + onDataRecordingStart={startDataRecording} + onDataRecordingStop={stopDataRecording} + onDataRecordingClear={clearDataRecording} + onDataRecordingExport={exportDataRecording} + /> + + ) : undefined; return (
viewer.current?.resetCamera()} @@ -2226,44 +2482,87 @@ export function App() { entries={state.entries} selectedEntry={state.selectedEntry} snapshot={state.snapshot} + selection={editorSelection} loading={state.loading} nativeUrdf={selectedFormat === 'urdf' && urdfMode === 'native'} mapSelection={mapSelection} maps={projectMaps} placedMaps={placedMapAssets} - activeMapId={activeMapAssetId} pendingSceneChangeCount={sceneDraftChangeCount} pendingSceneIds={pendingSceneIds} - editorDocument={ - mapSelection.kind === 'project' - ? (editorDrafts.get(mapSelection.descriptorPath) ?? editorDocument) - : editorDocument - } editorDocuments={sceneEditorDocuments} + assetPlacementMode={assetPlacementMode} activeTab={projectSidebarTab} onActiveTabChange={setProjectSidebarTab} onRemove={removeProject} onSelectEntry={requestLoadEntry} - onJointHover={(jointId) => viewer.current?.highlightJoint(jointId)} + onSelectBody={(bodyId) => { + state.setSelection(null); + setEditorSelection({ kind: 'body', bodyId }); + viewer.current?.selectMapEditorObject(null); + viewer.current?.selectParametricMapAsset(null); + viewer.current?.highlightJoint(null); + setRightOpen(true); + }} + onSelectJoint={(jointId, bodyId) => { + state.setSelection(null); + setEditorSelection({ kind: 'joint', jointId, bodyId }); + viewer.current?.selectMapEditorObject(null); + viewer.current?.selectParametricMapAsset(null); + viewer.current?.highlightJoint(jointId); + setRightOpen(true); + }} + onJointHover={(jointId) => + viewer.current?.highlightJoint( + jointId ?? (editorSelection?.kind === 'joint' ? editorSelection.jointId : null), + ) + } onAddMapAsset={(type, placementMode) => addCertifiedMapAsset(type, undefined, placementMode) } onAddProjectMap={addProjectMapAsset} onSelectTerrain={selectTerrainAsset} + onAssetPlacementModeChange={setAssetPlacementMode} onApplyScene={() => void commitMapScene()} onDiscardScene={discardMapSceneDraft} onSelectMap={activateMapAsset} onRemoveMap={removePlacedMapAsset} onSelectMapObject={(mapId, objectId) => activateMapAsset(mapId, objectId)} /> -
+
+ + void commitMapScene()} + onDiscard={discardMapSceneDraft} + /> + /\.py$/i.test(file.path)) - .map((file) => file.path)} - selectedControllerPath={selectedControllerPath} - controllerStatus={controllerStatus} - policyPaths={state.files - .filter((file) => /\.onnx$/i.test(file.path)) - .map((file) => file.path)} - selectedPolicyPath={selectedPolicyPath} - policyStatus={policyStatus} mapSelection={mapSelection} activeMapAssetId={activeMapAssetId} + activeMapAssetName={placedMapAssets.find((asset) => asset.id === activeMapAssetId)?.name} mapSceneDirty={mapSceneDirty} maps={projectMaps} showVisualMap={showVisualMap} @@ -2357,6 +2645,16 @@ export function App() { ? editorDrafts.get(mapSelection.descriptorPath) : undefined } + workspaceTool={workspaceTool} + workspaceTools={workspaceTools} + onWorkspaceToolChange={(tool) => { + setWorkspaceTool(tool); + if (tool) setRightOpen(true); + }} + onSelectJoint={(jointId, bodyId) => { + setEditorSelection({ kind: 'joint', jointId, bodyId }); + viewer.current?.highlightJoint(jointId); + }} onUrdfMode={changeUrdfMode} onBaseMode={changeBaseMode} onShowCollision={setShowCollision} @@ -2367,19 +2665,6 @@ export function App() { onActuator={setActuator} onActuatorParameters={setActuatorParameters} onJoint={setJoint} - onForceScale={setForceScale} - onSelectControllerPath={setSelectedControllerPath} - onLoadControllerPath={loadControllerPath} - onImportController={importController} - onToggleController={toggleController} - onControllerCommand={sendControllerCommand} - onRemoveController={removeController} - onSelectPolicyPath={setSelectedPolicyPath} - onLoadPolicyPath={loadPolicyPath} - onImportPolicy={importPolicy} - onTogglePolicy={togglePolicy} - onPolicyCommand={setPolicyCommand} - onRemovePolicy={removePolicy} onApplyMap={applyMapSelection} onMapDraft={stageMapSelectionDraft} onEditorPreview={previewEditorDocument} @@ -2389,22 +2674,12 @@ export function App() { onEditorConvert={convertSelectedMap} onEditorBindInteraction={bindEditorInteraction} onEditorSelect={selectEditorObject} - onEditorTransformMode={setEditorTransformMode} - onEditorSnapping={setEditorSnapping} + onEditorSessionStateChange={updateEditorSessionState} onEditorSurfaceHeight={editorSurfaceHeight} onMapDisplay={(visual, collision) => { setShowVisualMap(visual); setShowMapCollision(collision); }} - onMapTabOpen={() => { - setLeftOpen(true); - setProjectSidebarTab('assets'); - }} - onDataRecorderConfigure={configureDataRecorder} - onDataRecordingStart={startDataRecording} - onDataRecordingStop={stopDataRecording} - onDataRecordingClear={clearDataRecording} - onDataRecordingExport={exportDataRecording} />
{pendingUrdfPath && ( diff --git a/web_platform/src/app/components/LayoutSettingsDialog.tsx b/web_platform/src/app/components/LayoutSettingsDialog.tsx index 3c8c48fc..62f3c2c2 100644 --- a/web_platform/src/app/components/LayoutSettingsDialog.tsx +++ b/web_platform/src/app/components/LayoutSettingsDialog.tsx @@ -43,7 +43,7 @@ export function LayoutSettingsDialog({ onClick={() => onRightOpen(!rightOpen)} icon={} > - 属性面板 + 右侧面板

布局预设

diff --git a/web_platform/src/app/components/MapViewportTools.test.tsx b/web_platform/src/app/components/MapViewportTools.test.tsx new file mode 100644 index 00000000..c9c5b750 --- /dev/null +++ b/web_platform/src/app/components/MapViewportTools.test.tsx @@ -0,0 +1,99 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MapDraftStatusOverlay, MapViewportToolbar } from './MapViewportTools'; + +const noop = () => {}; + +describe('MapViewportToolbar', () => { + it('统一联动变换、吸附与贴地检测命令', () => { + const onModeChange = vi.fn(); + const onSnappingChange = vi.fn(); + const onPlacementModeChange = vi.fn(); + const onAlignToSurface = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '旋转工具 E' })); + fireEvent.click(screen.getByRole('button', { name: '网格吸附' })); + fireEvent.change(screen.getByLabelText('贴地检测模式'), { + target: { value: 'gravity' }, + }); + fireEvent.click(screen.getByRole('button', { name: '立即贴合承载面' })); + + expect(onModeChange).toHaveBeenCalledWith('rotate'); + expect(onSnappingChange).toHaveBeenCalledWith(false); + expect(onPlacementModeChange).toHaveBeenCalledWith('gravity'); + expect(onAlignToSurface).toHaveBeenCalledOnce(); + }); + + it('参数地形或未选中对象时不提供缩放与贴合动作', () => { + render( + , + ); + expect(screen.queryByRole('button', { name: '缩放工具 R' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '立即贴合承载面' })).toBeDisabled(); + }); +}); + +describe('MapDraftStatusOverlay', () => { + it('常驻显示未保存数量并提供提交与丢弃入口', () => { + const onCommit = vi.fn(); + const onDiscard = vi.fn(); + render( + , + ); + expect(screen.getByText('3 项未保存改动')).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: '提交地图草稿' })); + fireEvent.click(screen.getByRole('button', { name: '丢弃地图草稿' })); + expect(onCommit).toHaveBeenCalledOnce(); + expect(onDiscard).toHaveBeenCalledOnce(); + }); + + it('无草稿时显示已同步并禁用动作', () => { + render( + , + ); + expect(screen.getByText('地图草稿已同步')).toBeVisible(); + expect(screen.getByRole('button', { name: '提交地图草稿' })).toBeDisabled(); + }); +}); diff --git a/web_platform/src/app/components/MapViewportTools.tsx b/web_platform/src/app/components/MapViewportTools.tsx new file mode 100644 index 00000000..d4b6af21 --- /dev/null +++ b/web_platform/src/app/components/MapViewportTools.tsx @@ -0,0 +1,202 @@ +import { + ArrowDownToLine, + Check, + Grid3X3, + LockKeyhole, + MapPinned, + Move3d, + Rotate3d, + Scaling, + Trash2, +} from 'lucide-react'; +import { + Button, + IconButton, + Select, + ToolbarToggleGroup, + type ToolbarItem, +} from '../../components/ui'; +import { + MAP_OBJECT_PLACEMENT_LABELS, + type MapEditorTransformMode, + type MapObjectPlacementMode, +} from '../../map/editor/types'; + +const TRANSFORM_TOOLS: ToolbarItem[] = [ + { value: 'translate', label: '移动工具 W', icon: Move3d }, + { value: 'rotate', label: '旋转工具 E', icon: Rotate3d }, + { value: 'scale', label: '缩放工具 R', icon: Scaling }, +]; + +export function MapViewportToolbar({ + visible, + interactionActive, + mode, + snapping, + placementMode, + hasSelectedObject, + allowScale, + loading, + onActivate, + onModeChange, + onSnappingChange, + onPlacementModeChange, + onAlignToSurface, +}: { + visible: boolean; + interactionActive: boolean; + mode: MapEditorTransformMode; + snapping: boolean; + placementMode: MapObjectPlacementMode; + hasSelectedObject: boolean; + allowScale: boolean; + loading: boolean; + onActivate: () => void; + onModeChange: (mode: MapEditorTransformMode) => void; + onSnappingChange: (value: boolean) => void; + onPlacementModeChange: (mode: MapObjectPlacementMode) => void; + onAlignToSurface: () => void; +}) { + if (!visible) return null; + return ( +
+ + allowScale || item.value !== 'scale')} + value={mode} + onChange={onModeChange} + label="地图变换模式" + /> + onSnappingChange(!snapping)} + > + + +
+ ); +} + +export function MapDraftStatusOverlay({ + visible, + changeCount, + loading, + onCommit, + onDiscard, +}: { + visible: boolean; + changeCount: number; + loading: boolean; + onCommit: () => void; + onDiscard: () => void; +}) { + if (!visible) return null; + const dirty = changeCount > 0; + return ( +
+
+ ); +} + +export interface MapAssetDropTarget { + left: number; + top: number; + position: readonly [number, number, number] | null; +} + +export function MapAssetDropIndicator({ target }: { target?: MapAssetDropTarget }) { + if (!target) return null; + const valid = Boolean(target.position); + return ( +
+
+ + + + + {valid + ? `释放放置 · ${target.position![0].toFixed(1)}, ${target.position![1].toFixed(1)}` + : '请拖到 3D 地面'} + +
+
+ ); +} diff --git a/web_platform/src/app/components/ModelControlsSidebar.test.tsx b/web_platform/src/app/components/ModelControlsSidebar.test.tsx new file mode 100644 index 00000000..755598d7 --- /dev/null +++ b/web_platform/src/app/components/ModelControlsSidebar.test.tsx @@ -0,0 +1,162 @@ +import type { ComponentProps } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { DEFAULT_PHYSICAL_MAP_CONFIG } from '../../map/types'; +import type { SimulationSnapshot } from '../../simulation/SimulationSession'; +import { ModelControlsSidebar } from './ModelControlsSidebar'; + +const snapshot = { + bodies: [ + { id: 0, name: 'world', parentId: 0 }, + { id: 1, name: 'base', parentId: 0 }, + { id: 2, name: 'arm', parentId: 1 }, + ], + joints: [ + { + id: 7, + name: 'arm_joint', + bodyId: 2, + type: 3, + value: 0.25, + min: -1, + max: 1, + limitMin: -1, + limitMax: 1, + limited: true, + limitsIgnored: false, + editable: true, + axis: [0, 0, 1], + }, + ], + actuators: [], + model: { nbody: 3, njnt: 1, ngeom: 2, ncam: 0, nactuator: 0, nu: 0, nq: 1, nv: 1 }, +} as unknown as SimulationSnapshot; + +const noop = () => {}; +const asyncTrue = async () => true; + +function props( + patch: Partial> = {}, +): ComponentProps { + return { + snapshot, + selection: null, + viewerSelection: null, + loading: false, + urdfMode: 'mjcf', + baseMode: 'floating', + showCollision: false, + ignoreJointLimits: false, + jointAdvanced: false, + angleUnit: 'rad', + mapSelection: { kind: 'none' }, + mapSceneDirty: false, + maps: [], + showVisualMap: true, + showMapCollision: false, + editorDocument: null, + workspaceTool: null, + onWorkspaceToolChange: noop, + onSelectJoint: noop, + onUrdfMode: noop, + onBaseMode: noop, + onShowCollision: noop, + onResetJoints: noop, + onToggleJointLimits: noop, + onToggleAdvanced: noop, + onToggleAngleUnit: noop, + onActuator: noop, + onActuatorParameters: noop, + onJoint: noop, + onApplyMap: noop, + onMapDraft: noop, + onEditorPreview: noop, + onEditorDraftChange: noop, + onEditorApply: asyncTrue, + onEditorExport: noop, + onEditorConvert: asyncTrue, + onEditorBindInteraction: noop, + onEditorSelect: noop, + onEditorSessionStateChange: noop, + onMapDisplay: noop, + ...patch, + }; +} + +describe('ModelControlsSidebar', () => { + it('没有选择时显示场景摘要,并保留基础地图快速入口', () => { + render(); + expect(screen.getByText('未选择对象')).toBeVisible(); + expect(screen.getByText('模型摘要')).toBeVisible(); + expect(screen.getByText('未选择地图实例')).toBeVisible(); + }); + + it('按照统一选择自动路由 Body 与 Joint 检查器', () => { + const view = render( + , + ); + expect(screen.getByText('Robot / Body')).toBeVisible(); + expect(screen.getByText('arm', { selector: '[title="arm"]' })).toBeVisible(); + + view.rerender( + , + ); + expect(screen.getByText('Robot / Joint')).toBeVisible(); + expect(screen.getByText('Hinge · arm')).toBeVisible(); + expect(screen.getByText('关联 Actuator')).toBeVisible(); + }); + + it('控制台与数据录制在右侧面板内切换,不创建模态窗口', () => { + const onWorkspaceToolChange = vi.fn(); + render( + + 内嵌控制工具 + + ), + onWorkspaceToolChange, + })} + />, + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('tabpanel', { name: '控制台' })).toBeVisible(); + expect(screen.getByText('内嵌控制工具')).toBeVisible(); + const inspector = document.querySelector('[role="tabpanel"][aria-label="检查器"]'); + expect(inspector).not.toBeNull(); + expect(inspector).not.toBeVisible(); + const controlsTab = screen.getByRole('tab', { name: '控制台' }); + expect(controlsTab).toHaveAttribute('aria-selected', 'true'); + + fireEvent.keyDown(controlsTab, { key: 'ArrowRight' }); + expect(onWorkspaceToolChange).toHaveBeenCalledWith('data'); + fireEvent.click(screen.getByRole('tab', { name: '检查器' })); + expect(onWorkspaceToolChange).toHaveBeenCalledWith(null); + }); + + it('选择地图实例后自动展示地图检查器,并把全局工具移到右侧入口', () => { + const onWorkspaceToolChange = vi.fn(); + render( + , + ); + expect(screen.getByText('Map / Instance')).toBeVisible(); + expect(screen.getByText('随机粗糙地形', { selector: '[title="随机粗糙地形"]' })).toBeVisible(); + fireEvent.click(screen.getByRole('tab', { name: '控制台' })); + fireEvent.click(screen.getByRole('tab', { name: '数据录制' })); + expect(onWorkspaceToolChange.mock.calls).toEqual([['controls'], ['data']]); + }); +}); diff --git a/web_platform/src/app/components/ModelControlsSidebar.tsx b/web_platform/src/app/components/ModelControlsSidebar.tsx index 19d2acd9..09ff8125 100644 --- a/web_platform/src/app/components/ModelControlsSidebar.tsx +++ b/web_platform/src/app/components/ModelControlsSidebar.tsx @@ -1,38 +1,31 @@ -import { useState } from 'react'; -import { Database, Info, Map as MapIcon, SlidersHorizontal } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { Box, Database, MapPinned, PanelRight, SlidersHorizontal } from 'lucide-react'; import type { MapEntry, ModelEntry } from '../../project/types'; import type { ActuatorParameters, SimulationSnapshot } from '../../simulation/SimulationSession'; import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter'; -import type { DataRecorderConfig } from '../../telemetry/DataRecorder'; import type { ViewerSelection } from '../../viewer/MuJoCoViewer'; -import type { ControllerCommand, ControllerStatus } from '../../controller/types'; -import type { RLCommand, RLPolicyStatus } from '../../rl/types'; -import { - Badge, - Button, - CollapsibleSection, - CopyButton, - PropertyRow, - Select, - Tabs, -} from '../../components/ui'; import type { MapSelection, PlacedMapSelection } from '../../map/types'; import type { EditableMapDocument, MapEditorInteractionCallbacks, - MapEditorTransformMode, + MapEditorSessionState, } from '../../map/editor/types'; -import { DataRecordingPanel } from '../../telemetry/DataRecordingPanel'; -import { ActuatorControl, Check, ControlSlider } from '../../simulation/ActuatorControl'; -import { LocalTrainingPanel } from '../../training/LocalTrainingPanel'; import { PhysicalMapPanel } from '../../map/PhysicalMapPanel'; -import { PythonControllerPanel } from '../../controller/PythonControllerPanel'; -import { RLPolicyPanel } from '../../rl/RLPolicyPanel'; +import { + BodyInspector, + InspectorIdentity, + JointInspector, + ModelSummaryInspector, +} from '../../simulation/RobotInspector'; +import type { EditorSelection } from '../editorSelection'; import { SidebarPanel } from './SidebarPanel'; +import type { WorkspaceTool } from './WorkspaceToolsPanel'; +import { RightSidebarTabs } from './RightSidebarTabs'; interface ModelControlsProps { snapshot?: SimulationSnapshot; - selection: ViewerSelection | null; + selection: EditorSelection | null; + viewerSelection: ViewerSelection | null; selectedFormat?: ModelEntry['format']; loading: boolean; visible?: boolean; @@ -42,23 +35,19 @@ interface ModelControlsProps { ignoreJointLimits: boolean; jointAdvanced: boolean; angleUnit: 'rad' | 'deg'; - forceScale: number; - controllerPaths: string[]; - selectedControllerPath?: string; - controllerStatus?: ControllerStatus; - policyPaths: string[]; - selectedPolicyPath?: string; - policyStatus?: RLPolicyStatus; mapSelection: MapSelection; activeMapAssetId?: string; + activeMapAssetName?: string; mapSceneDirty: boolean; maps: MapEntry[]; showVisualMap: boolean; showMapCollision: boolean; editorDocument: EditableMapDocument | null; editorDraftDocument?: EditableMapDocument; - activeTab?: 'properties' | 'controls' | 'data' | 'map'; - onActiveTabChange?: (value: 'properties' | 'controls' | 'data' | 'map') => void; + workspaceTool: WorkspaceTool | null; + workspaceTools?: ReactNode; + onWorkspaceToolChange: (tool: WorkspaceTool | null) => void; + onSelectJoint: (jointId: number, bodyId: number) => void; onUrdfMode: (value: UrdfLoadMode) => void; onBaseMode: (value: UrdfBaseMode) => void; onShowCollision: (value: boolean) => void; @@ -69,19 +58,6 @@ interface ModelControlsProps { onActuator: (id: number, value: number) => void; onActuatorParameters: (id: number, parameters: ActuatorParameters) => void; onJoint: (id: number, value: number) => void; - onForceScale: (value: number) => void; - onSelectControllerPath: (path: string) => void; - onLoadControllerPath: (path: string) => void; - onImportController: (file: File) => void; - onToggleController: (enabled: boolean) => void; - onControllerCommand: (command: ControllerCommand) => void; - onRemoveController: () => void; - onSelectPolicyPath: (path: string) => void; - onLoadPolicyPath: (path: string) => void; - onImportPolicy: (file: File) => void; - onTogglePolicy: (enabled: boolean) => void; - onPolicyCommand: (command: RLCommand) => void; - onRemovePolicy: () => void; onApplyMap: (value: MapSelection) => void; onMapDraft: (value: PlacedMapSelection) => void; onEditorPreview: (document: EditableMapDocument | null) => void; @@ -95,320 +71,183 @@ interface ModelControlsProps { onEditorConvert: () => Promise; onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void; onEditorSelect: (id: string | null) => void; - onEditorTransformMode: (mode: MapEditorTransformMode) => void; - onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void; + onEditorSessionStateChange?: ( + descriptorPath: string, + state: MapEditorSessionState | null, + ) => void; onEditorSurfaceHeight?: (position: readonly [number, number, number]) => number | null; onMapDisplay: (visual: boolean, collision: boolean) => void; - onMapTabOpen?: () => void; - onDataRecorderConfigure: (patch: Partial) => void; - onDataRecordingStart: () => void; - onDataRecordingStop: () => void; - onDataRecordingClear: () => void; - onDataRecordingExport: (format: 'csv' | 'json') => void; } + export function ModelControlsSidebar(props: ModelControlsProps) { - const [internalTab, setInternalTab] = useState<'properties' | 'controls' | 'data' | 'map'>( - 'properties', - ), - tab = props.activeTab ?? internalTab, - s = props.snapshot; - if (!s) + const snapshot = props.snapshot; + const activeView = props.workspaceTool ?? 'inspector'; + const title = + activeView === 'controls' ? '控制台' : activeView === 'data' ? '数据录制' : '检查器'; + const icon = + activeView === 'controls' ? ( + + ) : activeView === 'data' ? ( + + ) : ( + + ); + const tabs = ( + props.onWorkspaceToolChange(value === 'inspector' ? null : value)} + /> + ); + if (!snapshot) return ( - -
导入模型后显示属性
+ + {tabs} + {props.workspaceTool ? ( + props.workspaceTools + ) : ( +
导入模型后显示检查器
+ )}
); - const properties = ( - <> - {s.model.nbody} Body}> -
- - - - - -
-
- {props.selectedFormat === 'urdf' && ( - - - -

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

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

- - 在视口中单击物体 -

- )} -
- + + const selection = props.selection; + const selectedBody = + selection?.kind === 'body' + ? snapshot.bodies.find((body) => body.id === selection.bodyId) + : undefined; + const selectedJoint = + selection?.kind === 'joint' + ? snapshot.joints.find((joint) => joint.id === selection.jointId) + : undefined; + const mapSelected = + (selection?.kind === 'map' || selection?.kind === 'map-object') && + selection.mapAssetId === props.activeMapAssetId; + const selectedMapObject = + selection?.kind === 'map-object' && mapSelected + ? (props.editorDraftDocument ?? props.editorDocument)?.objects.find( + (object) => object.id === selection.objectId, + ) + : undefined; + const mapPanel = ( + + body.id !== 0 && body.parentId === 0 && !body.name.startsWith('__platform_map_'), + ) + .map((body) => body.name)} + loading={props.loading} + nativeUrdf={props.selectedFormat === 'urdf' && props.urdfMode === 'native'} + showVisualMap={props.showVisualMap} + showMapCollision={props.showMapCollision} + editorDocument={props.editorDocument} + editorDraftDocument={props.editorDraftDocument} + sceneDirty={props.mapSceneDirty} + onEditorPreview={props.onEditorPreview} + onEditorDraftChange={props.onEditorDraftChange} + onEditorApply={props.onEditorApply} + onEditorExport={props.onEditorExport} + onEditorConvert={props.onEditorConvert} + selectedEditorObjectId={ + selection?.kind === 'map-object' && mapSelected ? selection.objectId : undefined + } + onEditorBindInteraction={props.onEditorBindInteraction} + onEditorSelect={props.onEditorSelect} + onEditorSessionStateChange={props.onEditorSessionStateChange} + onEditorSurfaceHeight={props.onEditorSurfaceHeight} + onMapDisplay={props.onMapDisplay} + onDraft={props.onMapDraft} + onApply={props.onApplyMap} + /> ); - const controls = ( - <> - {s.rlPolicy.enabled ? '推理' : '停止'} : undefined} - > - - - - - - {s.controller.enabled ? '运行' : '停止'} : undefined} - > - - - {s.actuators.length}} - > - {s.actuators.length ? ( - s.actuators.map((actuator) => ( - props.onActuator(actuator.id, value)} - onParameters={(parameters) => props.onActuatorParameters(actuator.id, parameters)} - /> - )) - ) : ( -

模型没有驱动器

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

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

-
- - ); - return ( - - { - setInternalTab(value); - props.onActiveTabChange?.(value); - if (value === 'map') props.onMapTabOpen?.(); - }} - items={[ - { - value: 'properties', - label: '属性', - icon: , - content: properties, - }, - { - value: 'controls', - label: '控制', - icon: , - content: controls, - }, - { - value: 'data', - label: '数据', - icon: , - content: ( - - ), - }, - { - value: 'map', - label: '地图', - icon: , - content: ( - - body.id !== 0 && - body.parentId === 0 && - !body.name.startsWith('__platform_map_'), - ) - .map((body) => body.name)} - loading={props.loading} - nativeUrdf={props.selectedFormat === 'urdf' && props.urdfMode === 'native'} - showVisualMap={props.showVisualMap} - showMapCollision={props.showMapCollision} - editorDocument={props.editorDocument} - 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} - /> - ), - }, - ]} + + let inspector: ReactNode; + if (selectedBody) { + inspector = ( + + ); + } else if (selectedJoint) { + inspector = ( + + ); + } else if (mapSelected) { + const mapName = selectedMapObject?.name ?? props.activeMapAssetName ?? '地图实例'; + inspector = ( + <> + : } + eyebrow={selectedMapObject ? 'Map / Object' : 'Map / Instance'} + name={mapName} + meta={ + selectedMapObject + ? `${selectedMapObject.type} · ${props.activeMapAssetName ?? '地图'}` + : props.mapSelection.kind === 'builtin' + ? '参数化地形' + : '工程地图包实例' + } + /> + {mapPanel} + + ); + } else { + inspector = ( + <> + + {props.mapSelection.kind === 'none' && mapPanel} + + ); + } + + return ( + + {tabs} + + {props.workspaceTool && props.workspaceTools} ); } diff --git a/web_platform/src/app/components/ProjectSidebar.tsx b/web_platform/src/app/components/ProjectSidebar.tsx index 2bab8f83..d992aa36 100644 --- a/web_platform/src/app/components/ProjectSidebar.tsx +++ b/web_platform/src/app/components/ProjectSidebar.tsx @@ -1,17 +1,13 @@ 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 { countProjectSearchResults, ProjectTree, type ProjectTreeFile, } from '../../project/ProjectTree'; -import { - countModelStructureSearchResults, - ModelStructureTree, -} from '../../project/ModelStructureTree'; import type { SimulationSnapshot } from '../../simulation/SimulationSession'; -import { Button, Tabs } from '../../components/ui'; +import { Button, Tabs, VerticalSplitPane } from '../../components/ui'; import { DEFAULT_PHYSICAL_MAP_CONFIG, type MapSelection, @@ -24,35 +20,42 @@ import type { MapObjectPlacementMode, } from '../../map/editor/types'; import { MapAssetLibrary } from '../../map/MapAssetLibrary'; +import type { EditorSelection } from '../editorSelection'; import { ProjectBreadcrumb } from './ProjectBreadcrumb'; +import { SceneOutliner, countSceneSearchResults } from './SceneOutliner'; import { SidebarPanel } from './SidebarPanel'; import { TreeSearchField } from './TreeSearchField'; +export type ProjectResourceTab = 'assets' | 'files'; + export function ProjectSidebar({ projectName, files, entries, selectedEntry, snapshot, + selection, loading, visible = true, nativeUrdf, mapSelection, maps, placedMaps, - activeMapId, pendingSceneChangeCount, pendingSceneIds, - editorDocument, editorDocuments, + assetPlacementMode, activeTab, onActiveTabChange, onRemove, onSelectEntry, + onSelectBody, + onSelectJoint, onJointHover, onAddMapAsset, onAddProjectMap, onSelectTerrain, + onAssetPlacementModeChange, onApplyScene, onDiscardScene, onSelectMap, @@ -64,21 +67,23 @@ export function ProjectSidebar({ entries: ModelEntry[]; selectedEntry?: string; snapshot?: SimulationSnapshot; + selection: EditorSelection | null; loading: boolean; visible?: boolean; nativeUrdf: boolean; mapSelection: MapSelection; maps: MapEntry[]; placedMaps: PlacedMapAsset[]; - activeMapId?: string; pendingSceneChangeCount: number; pendingSceneIds: string[]; - editorDocument: EditableMapDocument | null; - editorDocuments?: ReadonlyMap; - activeTab?: 'project' | 'structure' | 'assets'; - onActiveTabChange?: (value: 'project' | 'structure' | 'assets') => void; + editorDocuments: ReadonlyMap; + assetPlacementMode?: MapObjectPlacementMode; + activeTab?: ProjectResourceTab; + onActiveTabChange?: (value: ProjectResourceTab) => void; onRemove: () => void; onSelectEntry: (path: string) => void; + onSelectBody: (bodyId: number) => void; + onSelectJoint: (jointId: number, bodyId: number) => void; onJointHover: (jointId: number | null) => void; onAddMapAsset: ( type: EditableMapObjectType, @@ -86,22 +91,22 @@ export function ProjectSidebar({ ) => void | Promise; onAddProjectMap: (descriptorPath: string) => void; onSelectTerrain: (preset: SystemTerrainPreset) => void; + onAssetPlacementModeChange?: (mode: MapObjectPlacementMode) => void; onApplyScene: () => void; onDiscardScene: () => void; onSelectMap: (id: string) => void; onRemoveMap: (id: string) => void; onSelectMapObject: (mapId: string, objectId: string) => void; }) { - const [internalTab, setInternalTab] = useState<'project' | 'structure' | 'assets'>('project'), - [fileQuery, setFileQuery] = useState(''), - [structureQuery, setStructureQuery] = useState(''); - const tab = activeTab ?? internalTab, - fileMatches = countProjectSearchResults(files, fileQuery), - structureMatches = snapshot - ? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery) - : 0; + const [internalTab, setInternalTab] = useState('assets'); + const [fileQuery, setFileQuery] = useState(''); + const [sceneQuery, setSceneQuery] = useState(''); + const tab = activeTab ?? internalTab; + const fileMatches = countProjectSearchResults(files, fileQuery); + const sceneMatches = countSceneSearchResults(snapshot, placedMaps, editorDocuments, sceneQuery); + return ( - + }> {projectName ? ( <>
@@ -109,115 +114,129 @@ export function ProjectSidebar({
{projectName}
-
{files.length} 个文件
+
+ {snapshot + ? `${snapshot.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')).length} Body` + : '模型未加载'}{' '} + · {placedMaps.length} 个地图实例 · {files.length} 个文件 +
- - { - setInternalTab(value); - onActiveTabChange?.(value); - }} - items={[ - { - value: 'project', - label: '工程', - icon: , - content: ( - <> - -
- +
+
+ + + + } + second={ + { + setInternalTab(value); + onActiveTabChange?.(value); + }} + items={[ + { + value: 'assets', + label: '资产库', + icon: , + disabled: !snapshot, + content: ( + -
- - ), - }, - { - value: 'structure', - label: '模型结构', - icon: , - disabled: !snapshot, - content: snapshot ? ( - <> - -
- -
- - ) : ( -

加载模型后显示结构

- ), - }, - { - value: 'assets', - label: '资产', - icon: , - disabled: !snapshot, - content: ( - - ), - }, - ]} + ), + }, + { + value: 'files', + label: '工程文件', + icon: , + content: ( + <> + + +
+ +
+ + ), + }, + ]} + /> + } /> ) : ( -
导入模型后显示工程资源
+
导入模型后显示场景与资产
)}
); diff --git a/web_platform/src/app/components/RightSidebarTabs.tsx b/web_platform/src/app/components/RightSidebarTabs.tsx new file mode 100644 index 00000000..e11e1801 --- /dev/null +++ b/web_platform/src/app/components/RightSidebarTabs.tsx @@ -0,0 +1,65 @@ +import { Database, PanelRight, SlidersHorizontal, type LucideIcon } from 'lucide-react'; + +export type RightSidebarView = 'inspector' | 'controls' | 'data'; + +const VIEWS: ReadonlyArray<{ + value: RightSidebarView; + label: string; + icon: LucideIcon; +}> = [ + { value: 'inspector', label: '检查器', icon: PanelRight }, + { value: 'controls', label: '控制台', icon: SlidersHorizontal }, + { value: 'data', label: '数据录制', icon: Database }, +]; + +export function RightSidebarTabs({ + value, + onChange, +}: { + value: RightSidebarView; + onChange: (value: RightSidebarView) => void; +}) { + return ( +
+ {VIEWS.map((view, index) => { + const Icon = view.icon; + const selected = value === view.value; + return ( + + ); + })} +
+ ); +} diff --git a/web_platform/src/app/components/SceneOutliner.test.tsx b/web_platform/src/app/components/SceneOutliner.test.tsx new file mode 100644 index 00000000..8b954f7d --- /dev/null +++ b/web_platform/src/app/components/SceneOutliner.test.tsx @@ -0,0 +1,120 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import type { EditableMapDocument } from '../../map/editor/types'; +import type { SimulationSnapshot } from '../../simulation/SimulationSession'; +import { SceneOutliner, countSceneSearchResults } from './SceneOutliner'; + +const snapshot = { + bodies: [ + { id: 0, name: 'world', parentId: 0 }, + { id: 1, name: 'base', parentId: 0 }, + { id: 2, name: 'arm', parentId: 1 }, + ], + joints: [ + { + id: 7, + name: 'arm_joint', + bodyId: 2, + type: 3, + value: 0, + min: -1, + max: 1, + limitMin: -1, + limitMax: 1, + limited: true, + limitsIgnored: false, + editable: true, + axis: [0, 0, 1], + }, + ], + model: { nbody: 3 }, +} as SimulationSnapshot; + +const document: EditableMapDocument = { + schemaVersion: 1, + mapId: 'warehouse', + revision: 0, + objects: [ + { + id: 'crate-1', + name: '木箱', + type: 'box', + pose: { position: [0, 0, 0.5], quaternion: [1, 0, 0, 0] }, + parameters: { sizeX: 1, sizeY: 1, sizeZ: 1 }, + friction: [1, 0.005, 0.0001], + rgba: [0.5, 0.6, 0.7, 1], + placementMode: 'auto_ground', + enabled: true, + }, + ], + spawnPoints: [], +}; + +const maps = [ + { + id: 'map-a', + name: '仓库', + selection: { kind: 'project' as const, descriptorPath: 'maps/warehouse/map.json' }, + }, +]; +const documents = new Map([['maps/warehouse/map.json', document]]); + +function renderOutliner(overrides: Record = {}) { + const props = { + snapshot, + maps, + documents, + selection: null, + query: '', + loading: false, + pendingSceneChangeCount: 0, + pendingSceneIds: [], + onSelectBody: vi.fn(), + onSelectJoint: vi.fn(), + onJointHover: vi.fn(), + onSelectMap: vi.fn(), + onSelectMapObject: vi.fn(), + onRemoveMap: vi.fn(), + onApplyScene: vi.fn(), + onDiscardScene: vi.fn(), + ...overrides, + }; + render(); + return props; +} + +describe('SceneOutliner', () => { + it('在一棵场景大纲中统一展示机器人、地图实例和地图物体', () => { + renderOutliner(); + expect(screen.getByLabelText('场景资产树')).toHaveTextContent('机器人'); + expect(screen.getByLabelText('场景资产树')).toHaveTextContent('地图与环境'); + expect(screen.getByRole('treeitem', { name: /arm_joint/ })).toBeVisible(); + expect(screen.getByRole('treeitem', { name: /木箱/ })).toBeVisible(); + expect(countSceneSearchResults(snapshot, maps, documents, '木箱')).toBe(2); + }); + + it('把所有选择统一上报为稳定 id', () => { + const props = renderOutliner(); + fireEvent.click(screen.getByRole('treeitem', { name: 'base' })); + fireEvent.click(screen.getByRole('treeitem', { name: /arm_joint/ })); + fireEvent.click(screen.getByRole('treeitem', { name: /^仓库/ })); + fireEvent.click(screen.getByRole('treeitem', { name: /木箱/ })); + expect(props.onSelectBody).toHaveBeenCalledWith(1); + expect(props.onSelectJoint).toHaveBeenCalledWith(7, 2); + expect(props.onSelectMap).toHaveBeenCalledWith('map-a'); + expect(props.onSelectMapObject).toHaveBeenCalledWith('map-a', 'crate-1'); + }); + + it('在大纲中集中处理场景草稿与实例删除', () => { + const props = renderOutliner({ + pendingSceneChangeCount: 2, + pendingSceneIds: ['map-a'], + }); + expect(screen.getByRole('status')).toHaveTextContent('2 项场景更改待应用'); + fireEvent.click(screen.getByRole('button', { name: '应用场景' })); + fireEvent.click(screen.getByRole('button', { name: '放弃更改' })); + fireEvent.click(screen.getByRole('button', { name: '删除地图实例 仓库' })); + expect(props.onApplyScene).toHaveBeenCalledOnce(); + expect(props.onDiscardScene).toHaveBeenCalledOnce(); + expect(props.onRemoveMap).toHaveBeenCalledWith('map-a'); + }); +}); diff --git a/web_platform/src/app/components/SceneOutliner.tsx b/web_platform/src/app/components/SceneOutliner.tsx new file mode 100644 index 00000000..588fc5d9 --- /dev/null +++ b/web_platform/src/app/components/SceneOutliner.tsx @@ -0,0 +1,275 @@ +import { useMemo } from 'react'; +import { Box, Bot, Layers3, MapPinned, Trash2, Zap } from 'lucide-react'; +import { Button, EmptySearchState, SearchHighlight } from '../../components/ui'; +import type { EditableMapDocument } from '../../map/editor/types'; +import type { PlacedMapAsset } from '../../map/types'; +import { + countModelStructureSearchResults, + ModelStructureTree, +} from '../../project/ModelStructureTree'; +import type { SimulationSnapshot } from '../../simulation/SimulationSession'; +import type { EditorSelection } from '../editorSelection'; + +interface VisibleMap { + asset: PlacedMapAsset; + objects: EditableMapDocument['objects']; +} + +function mapDocument( + asset: PlacedMapAsset, + documents: ReadonlyMap, +): EditableMapDocument | undefined { + return asset.selection.kind === 'project' + ? documents.get(asset.selection.descriptorPath) + : undefined; +} + +// eslint-disable-next-line react-refresh/only-export-components +export function countSceneSearchResults( + snapshot: SimulationSnapshot | undefined, + maps: readonly PlacedMapAsset[], + documents: ReadonlyMap, + query: string, +): number { + const normalized = query.trim().toLocaleLowerCase(); + const robotBodies = + snapshot?.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')) ?? + []; + const robotBodyIds = new Set(robotBodies.map((body) => body.id)); + const robotCount = snapshot + ? countModelStructureSearchResults( + robotBodies, + snapshot.joints.filter((joint) => robotBodyIds.has(joint.bodyId)), + normalized, + ) + : 0; + if (!normalized) + return ( + robotCount + + maps.reduce((count, map) => count + 1 + (mapDocument(map, documents)?.objects.length ?? 0), 0) + ); + return ( + robotCount + + maps.reduce((count, map) => { + const document = mapDocument(map, documents); + if (map.name.toLocaleLowerCase().includes(normalized)) + return count + 1 + (document?.objects.length ?? 0); + const objectCount = + document?.objects.filter((object) => + `${object.name} ${object.type}`.toLocaleLowerCase().includes(normalized), + ).length ?? 0; + return count + (objectCount ? 1 + objectCount : 0); + }, 0) + ); +} + +export function SceneOutliner({ + snapshot, + maps, + documents, + selection, + query, + loading, + pendingSceneChangeCount, + pendingSceneIds, + onSelectBody, + onSelectJoint, + onJointHover, + onSelectMap, + onSelectMapObject, + onRemoveMap, + onApplyScene, + onDiscardScene, +}: { + snapshot?: SimulationSnapshot; + maps: readonly PlacedMapAsset[]; + documents: ReadonlyMap; + selection: EditorSelection | null; + query: string; + loading: boolean; + pendingSceneChangeCount: number; + pendingSceneIds: readonly string[]; + onSelectBody: (bodyId: number) => void; + onSelectJoint: (jointId: number, bodyId: number) => void; + onJointHover: (jointId: number | null) => void; + onSelectMap: (mapId: string) => void; + onSelectMapObject: (mapId: string, objectId: string) => void; + onRemoveMap: (mapId: string) => void; + onApplyScene: () => void; + onDiscardScene: () => void; +}) { + const normalized = query.trim().toLocaleLowerCase(); + const robotBodies = useMemo( + () => + snapshot?.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')) ?? + [], + [snapshot?.bodies], + ); + const robotBodyIds = useMemo(() => new Set(robotBodies.map((body) => body.id)), [robotBodies]); + const robotJoints = useMemo( + () => snapshot?.joints.filter((joint) => robotBodyIds.has(joint.bodyId)) ?? [], + [snapshot?.joints, robotBodyIds], + ); + const visibleMaps = useMemo( + () => + maps.flatMap((asset) => { + const objects = mapDocument(asset, documents)?.objects ?? []; + if (!normalized || asset.name.toLocaleLowerCase().includes(normalized)) + return [{ asset, objects }]; + const matches = objects.filter((object) => + `${object.name} ${object.type}`.toLocaleLowerCase().includes(normalized), + ); + return matches.length ? [{ asset, objects: matches }] : []; + }), + [documents, maps, normalized], + ); + const hasRobotMatches = snapshot + ? countModelStructureSearchResults(robotBodies, robotJoints, normalized) > 0 + : false; + const pending = new Set(pendingSceneIds); + + return ( +
+ {pendingSceneChangeCount > 0 && ( +
+
+
+
+ + +
+
+ )} + + {snapshot && hasRobotMatches && ( +
+ + +
+ onSelectJoint(joint.id, joint.bodyId)} + onJointHover={onJointHover} + /> +
+
+ )} + + {visibleMaps.length > 0 && ( +
+ + +
    + {visibleMaps.map(({ asset, objects }) => { + const mapSelected = + (selection?.kind === 'map' || selection?.kind === 'map-object') && + selection.mapAssetId === asset.id; + return ( +
  • +
    + + +
    + {objects.length > 0 && ( +
      + {objects.map((object) => { + const selected = + selection?.kind === 'map-object' && + selection.mapAssetId === asset.id && + selection.objectId === object.id; + return ( +
    • + +
    • + ); + })} +
    + )} +
  • + ); + })} +
+
+ )} + + {!hasRobotMatches && !visibleMaps.length && ( + + )} +
+ ); +} diff --git a/web_platform/src/app/components/ShortcutHelpDialog.tsx b/web_platform/src/app/components/ShortcutHelpDialog.tsx index f1577a17..fd11e35c 100644 --- a/web_platform/src/app/components/ShortcutHelpDialog.tsx +++ b/web_platform/src/app/components/ShortcutHelpDialog.tsx @@ -1,10 +1,18 @@ import { Dialog, Kbd, Separator } from '../../components/ui'; const shortcuts = [ ['Space', '播放 / 暂停'], - ['R', '重置仿真'], + ['R', '重置仿真(非地图编辑)'], ['1', '选择模式'], ['2', '关节拖动'], ['3', '外力施加'], + ['W / E / R', '地图移动 / 旋转 / 缩放'], + ['F', '聚焦当前对象'], + ['Delete', '删除当前地图对象'], + ['Ctrl / Cmd + S', '保存并编译地图草稿'], + ['Ctrl / Cmd + Z', '撤销地图编辑'], + ['Ctrl / Cmd + Y / Shift + Z', '重做地图编辑'], + ['G', '切换地图网格吸附'], + ['Esc', '取消选择或关闭浮层'], ]; export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) { return ( @@ -29,7 +37,8 @@ export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose:
  • 左键拖动:旋转相机或执行当前交互工具
  • 右键拖动:平移相机
  • 滚轮:缩放视口
  • -
  • 选择物体后可在右侧“属性”中查看信息
  • +
  • 选择物体后可在右侧 Inspector 中编辑尺寸、位姿、材质和摩擦
  • +
  • 从左侧资产库拖动几何障碍物到视口地面即可放置
  • diff --git a/web_platform/src/app/components/SidebarPanel.tsx b/web_platform/src/app/components/SidebarPanel.tsx index c0aa106c..85187274 100644 --- a/web_platform/src/app/components/SidebarPanel.tsx +++ b/web_platform/src/app/components/SidebarPanel.tsx @@ -7,20 +7,22 @@ export function SidebarPanel({ side, children, visible = true, + icon, }: { title: string; side: 'left' | 'right'; children: ReactNode; visible?: boolean; + icon?: ReactNode; }) { return (