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/editor-interaction-style-guide.md b/plans/editor-interaction-style-guide.md new file mode 100644 index 00000000..d8adf700 --- /dev/null +++ b/plans/editor-interaction-style-guide.md @@ -0,0 +1,94 @@ +# 编辑器快捷键与微交互样式规范 + +## 快捷键矩阵 + +`useMapEditorShortcuts` 在模型快照可用时以 capture 阶段统一处理;`F` 可聚焦机器人或地图选择,其余编辑命令受地图上下文约束,避免与浏览器保存、仿真重置以及输入框 Delete 冲突。 + +| 快捷键 | 行为 | 约束 | +| --- | --- | --- | +| `W` | 平移 Gizmo | 自动切回 Viewer `select` 模式 | +| `E` | 旋转 Gizmo | 仅世界 Z 轴 | +| `R` | 缩放 Gizmo | 参数地形及锁定对象会消费按键但不执行缩放 | +| `F` | 聚焦选中对象 | 320ms ease-out 相机过渡;用户拖动相机会中止过渡 | +| `Delete` | 删除选中地图对象 | 输入框和对话框内不拦截;忽略 key repeat | +| `Ctrl/Cmd + S` | 保存并编译场景草稿 | 阻止浏览器“保存网页”;编译失败保留草稿 | +| `G` | 切换网格吸附 | 移动 0.1m / 旋转 5° | +| `Ctrl/Cmd + Z` | 撤销 MapEditSession 操作 | 已有会话行为 | +| `Ctrl/Cmd + Y` | 重做 MapEditSession 操作 | 已有会话行为 | +| `Escape` | 取消对象选择 | 已有会话行为 | + +典型接入: + +```tsx +useMapEditorShortcuts({ + enabled: Boolean(snapshot), + mapEditing: mapEditingActive, + dirty: mapSceneDirty, + loading, + hasSelection: Boolean(selection), + canDelete: selection?.kind === 'map-object', + canScale: selectedObject?.placementMode !== 'locked', + onTransformMode: changeMapTransformMode, + onFocusSelection: () => viewer.focusSelection(), + onDeleteSelection: () => sessionPort.onDelete(objectId), + onToggleSnapping: () => setSnapping((value) => !value), + onSave: commitMapScene, +}); +``` + +## Scrubbable Number Input + +`ScrubbableNumberInput` 同时保留标准 `` 与标签拖拽: + +- 在标签上左右拖拽:`每 4px = 1 step`; +- `Shift`:0.1 倍步长; +- `Alt`:0.01 倍步长; +- `Ctrl/Cmd`:10 倍步长; +- 标签获得焦点时可用左右方向键调节; +- min/max 在组件边界统一约束; +- 拖拽过程中使用等宽数字、accent 边框和低强度辉光。 + +适用于地图位姿、尺寸、材质、摩擦、出生点,以及仿真 Actuator 参数。 + +## 视觉层级 + +### 色彩与材质 + +- 页面底色:`--ui-bg`,暗色模式保持蓝黑而非纯黑; +- 一级面板:`--ui-panel`; +- 二级表面:`--ui-surface`; +- 浮层玻璃:`--ui-glass`,透明度约 78%; +- 强调色只用于选择、有效落点与可提交状态; +- 警告色只用于未保存草稿和物理诊断。 + +### 公共样式类 + +```css +.workbench-header /* 顶栏横向工程渐变 */ +.engineering-glass /* 16px blur、微渐变、内高光、细边框 */ +.engineering-card /* Inspector 二级属性组 */ +.engineering-badge /* 微渐变 Badge 与细阴影 */ +.tool-active-glow /* 激活工具的低强度 accent 光晕 */ +.map-tool-enter /* 浮动工具条入场 */ +.draft-dirty-dot /* 未保存状态呼吸提示 */ +.scrubbable-number-* /* 数值拖拽完整状态 */ +``` + +优先使用这些语义类,组件内部再组合 Tailwind 布局类。例如: + +```tsx +
+
+``` + +## 动效时长 + +| 场景 | 时长 | 曲线 | +| --- | ---: | --- | +| Button hover / press | 150ms | `ease-out` | +| 浮动工具条出现 | 220ms | `cubic-bezier(.22,1,.36,1)` | +| 相机聚焦 | 320ms | cubic ease-out | +| Snap to Surface 3D 指示器 | 720ms | 双脉冲衰减 | +| 草稿状态呼吸 | 1800ms | ease-in-out | + +所有动画必须受 `prefers-reduced-motion: reduce` 约束,不以动画作为唯一状态表达。Snap to Surface 同时使用绿色/青色圆环、十字中心和高度位置表达真实接触面。 diff --git a/plans/map-editor-interaction-flow.md b/plans/map-editor-interaction-flow.md new file mode 100644 index 00000000..64dbdb57 --- /dev/null +++ b/plans/map-editor-interaction-flow.md @@ -0,0 +1,129 @@ +# 地图编辑交互与状态流 + +## 目标 + +地图编辑采用“视口负责空间意图、Inspector 负责精确属性、`MapEditSession` 负责草稿事实”的单向数据流: + +- 顶部 `ViewerToolDock` 决定当前 Viewer 交互域;地图上下文激活时与视口浮动工具条共享状态。 +- `MapViewportToolbar` 负责移动/旋转/缩放、网格吸附以及贴地策略,不再由右侧属性面板持有。 +- 右侧 Inspector 仅显示当前对象的位姿、尺寸、表面材质和摩擦力。 +- `MapDraftStatusOverlay` 常驻视口底部,统一提交或丢弃场景级草稿。 +- 左侧资产库通过标准化拖放载荷把几何原语直接放到 3D 地面。 + +## 状态所有权 + +| 状态 | 所有者 | 消费方 | +| --- | --- | --- | +| `EditorSelection` | `App` | 场景树、Viewer、Inspector、地图工具条 | +| `mapTransformMode` | `App` | `ViewerToolDock`、`MapViewportToolbar`、`MapEditorLayer` | +| `mapSnapping` | `App` | `MapViewportToolbar`、`MapEditorLayer`、参数地形预览层 | +| `assetPlacementMode` | `App` | 左侧资产库、视口贴地策略 | +| 编辑文档与 undo/redo | `MapEditSession` | `MapEditorPanel`、视口命令端口 | +| `editorDrafts` | `App` | 场景预览、事务提交、草稿状态浮窗 | +| `editorSessionStates` | `App` | 未保存改动计数 | + +## MapEditSession 命令端口 + +`MapEditorInteractionCallbacks` 是 Viewer 与编辑会话之间的边界: + +```ts +interface MapEditorInteractionCallbacks { + onSelect(id: string | null): void; + onTransform(transform: MapEditorTransform): void; + onAddAsset(type, position?, placementMode?, externalSupportTop?): void; + onSetPlacementMode(id: string, mode: MapObjectPlacementMode): void; + onAlignToSurface(id: string): void; + onDelete(id: string): void; + onDiscard(): void; +} +``` + +Viewer 和浮动工具条只发送命令,不直接修改 `EditableMapDocument`。每次会话变更都按以下顺序发布: + +1. `MapEditSession.addAsset/update/remove` 创建历史记录; +2. `onPreview(session.document)` 更新 `MapEditorLayer`; +3. `onDraftChange(..., dirty)` 写入 App 的 `editorDrafts`; +4. `onSessionStateChange` 上报 dirty、changeCount、undo/redo 能力; +5. React 根据统一选择刷新 Inspector 与底部草稿状态。 + +## 关键流转 + +### 视口拾取与属性编辑 + +```text +MapEditorLayer.onSelect(objectId) + → App.editorInteraction.onSelect(objectId) + → App.EditorSelection = map-object + → Inspector 定位同一 draftDocument.objects[objectId] + → MapObjectInspector 显示位姿 / 尺寸 / 表面材质 / 摩擦 +``` + +Inspector 输入不会调用 Viewer API,而是更新 `MapEditSession`,再由草稿预览反向更新 Viewer,避免双写。 + +### 浮动变换工具 + +```text +MapViewportToolbar(W/E/R) + → App.mapTransformMode + → Viewer interaction mode 自动切到 select + → MuJoCoViewer.setMapEditorTransformMode + → MapEditorLayer + ParametricMapPreviewLayer 同步 +``` + +网格吸附同样由 App 单点设置为移动 `0.1 m`、旋转 `5°`。切换顶部关节拖动或外力模式时,地图工具仍保留上下文,但标记为非激活;点击顶部地图上下文或任一 W/E/R 会恢复选择模式。 + +贴地策略: + +- `auto_ground`:对象底部对齐世界 `z=0`; +- `gravity`:调用场景承载面查询,沿 `-Z` 落到当前 XY 位置的最高表面; +- `locked`:禁止位姿和 Gizmo 变换,但允许尺寸、材质和摩擦编辑。 + +### 从资产库拖放到 3D 地面 + +```text +MapAssetLibrary dragStart + → application/x-mujoco-map-library-item + → App.dragOver + → MuJoCoViewer.mapPlanePoint(clientX, clientY) + → MapAssetDropIndicator + → drop + → editorInteraction.onAddAsset(...) + → MapEditSession.addAsset +``` + +若当前还没有可编辑地图,`App.createEditableScene` 会创建临时 V3 地图包,再将待放置资产交给新会话。重力放置会把地面射线落点与 `mapSceneSurfaceHeightAt` 的承载高度一起传给会话。 + +### 提交与丢弃 + +提交: + +```text +MapDraftStatusOverlay.commit + → App.commitMapScene(editorDrafts) + → materializeEditableMapDrafts(revision 递增) + → 一次 MuJoCo 编译 + → 成功:更新 appliedMapAssets / committedEditorDocuments / 清理已提交草稿 + → 失败:回滚 manifest,保留草稿与上一仿真会话 +``` + +丢弃: + +```text +MapDraftStatusOverlay.discard + → MapEditSession.discard(清理本地 history) + → App.clearEditorDrafts + → restoreAppliedMapScene + → 恢复已提交 Viewer 预览与 Inspector 选择 +``` + +提交和丢弃均作用于整个场景草稿,因此多个地图实例和编辑文档只触发一次物理重新编译。 + +## 组件更新 + +- `web_platform/src/app/components/MapViewportTools.tsx` +- `web_platform/src/app/components/ViewerToolDock.tsx` +- `web_platform/src/map/MapObjectInspector.tsx` +- `web_platform/src/map/MapEditorPanel.tsx` +- `web_platform/src/map/PhysicalMapPanel.tsx` +- `web_platform/src/map/MapAssetLibrary.tsx` +- `web_platform/src/app/App.tsx` diff --git a/plans/sidebar-information-architecture.md b/plans/sidebar-information-architecture.md new file mode 100644 index 00000000..a4f86c7e --- /dev/null +++ b/plans/sidebar-information-architecture.md @@ -0,0 +1,64 @@ +# 3D 编辑器侧边栏信息架构 + +## 目标布局 + +```text +┌ 场景大纲与资产中心 ┐ ┌──────── 3D Viewport ────────┐ ┌ 右侧工作区 ──┐ +│ 场景大纲(常驻) │ │ │ │ 检查器/控制/数据│ +│ ├─ Robot │ │ │ │ 上下文属性 │ +│ │ ├─ Body │ │ │ │ │ +│ │ └─ Joint │ │ │ │ │ +│ └─ Maps │ │ │ │ │ +│ └─ Object │ │ │ │ │ +├────────────────────┤ │ │ │ │ +│ 资产库 | 工程文件 │ │ │ │ │ +└────────────────────┘ └──────────────────────────────┘ └──────────────┘ +``` + +- 场景大纲常驻,资产库与工程文件共享下半区,放置地图时无需在“结构/资产”之间切换。 +- 右侧使用“检查器 / 控制台 / 数据录制”三个顶层视图;检查器内部根据统一选择自动路由到 Body、Joint、地图实例或地图对象。 +- Python/ONNX 控制器、全局 Actuator/Joint 控制和数据录制不是对象属性,放在右侧独立视图中,不使用模态窗口,因此操作时始终能看到并交互仿真画面。 +- 控制与训练等重型组件仅在首次进入对应右侧视图时懒加载。 +- 控制台分区默认全部折叠,按“执行器控制 → 关节调试 → 外力交互 → Python 控制 → ONNX 推理 → 强化学习训练”排列,并在标题右侧显示“控制 / 调试 / 交互 / 推理 / 训练”功能标记。 + +## 状态与依赖边界 + +`EditorSelection` 是大纲高亮和检查器路由的唯一选择语义,只保存稳定 ID: + +```ts +type EditorSelection = + | { kind: 'body'; bodyId: number } + | { kind: 'joint'; jointId: number; bodyId: number } + | { kind: 'map'; mapAssetId: string } + | { kind: 'map-object'; mapAssetId: string; objectId: string }; +``` + +Viewer 的射线命中详情仍保留为临时 `ViewerSelection`,仅用于展示 geom 和世界位置,不再决定检查器类型。 + +组件职责: + +- `ProjectSidebar`:只编排常驻 `SceneOutliner`、`MapAssetLibrary` 和 `ProjectTree`。 +- `SceneOutliner`:把 simulation 与 map 数据投影成一棵可选择的场景树;不修改业务状态。 +- `MapAssetLibrary`:只负责资产发现、点击添加和拖放,不再保存已放置实例树。 +- `ModelControlsSidebar`:持有右侧栏壳层、顶层视图切换,并在检查器视图中按 `EditorSelection.kind` 路由。 +- `RobotInspector`:承载模型摘要、Body 和 Joint 的领域 UI。 +- `PhysicalMapPanel`:承载地图实例参数与地图对象精确属性,不再持有视口工具状态。 +- `MapViewportToolbar`:在视口内统一承载 Transform、网格吸附与贴地检测,并与顶部 `ViewerToolDock` 共享交互状态。 +- `MapDraftStatusOverlay`:常驻视口底部,统一显示改动计数以及场景级提交/丢弃入口。 +- `WorkspaceToolsPanel`:懒加载并在右侧栏内承载控制器、全局仿真控制和数据录制。 + +所有选择入口遵循同一事件流: + +```text +场景树点击 / Viewer 拾取 + ↓ +App 更新 EditorSelection(稳定 ID) + ↓ +SceneOutliner 高亮 + Inspector 自动路由 + ↓ +领域面板发出 command callback + ↓ +App / MapEditSession / SimulationSession 执行修改 +``` + +该实现只使用 React hooks、受控属性和 Tailwind class,兼容 React 18;当前工程使用 React 19 时无需兼容层。 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 (