From 72e27b5a6c2ca44f8e4556ef47869bfe5516be52 Mon Sep 17 00:00:00 2001 From: cen617-code <1057290604@qq.com> Date: Fri, 4 Sep 2026 15:34:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(web-platform):=20release=20V0.8.3=20?= =?UTF-8?q?=E5=B8=B8=E8=A7=84=E7=95=8C=E9=9D=A2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 +- package.json | 2 +- plans/editor-interaction-style-guide.md | 94 +++ plans/map-editor-interaction-flow.md | 129 ++++ plans/sidebar-information-architecture.md | 64 ++ web_platform/e2e/app.spec.ts | 161 +++-- web_platform/src/app/App.tsx | 441 ++++++++++--- .../app/components/LayoutSettingsDialog.tsx | 2 +- .../app/components/MapViewportTools.test.tsx | 99 +++ .../src/app/components/MapViewportTools.tsx | 202 ++++++ .../components/ModelControlsSidebar.test.tsx | 162 +++++ .../app/components/ModelControlsSidebar.tsx | 535 ++++++---------- .../src/app/components/ProjectSidebar.tsx | 263 ++++---- .../src/app/components/RightSidebarTabs.tsx | 65 ++ .../src/app/components/SceneOutliner.test.tsx | 120 ++++ .../src/app/components/SceneOutliner.tsx | 275 ++++++++ .../src/app/components/ShortcutHelpDialog.tsx | 13 +- .../src/app/components/SidebarPanel.tsx | 8 +- .../src/app/components/ViewerToolDock.tsx | 20 +- .../src/app/components/ViewportHUD.tsx | 24 +- .../src/app/components/WorkbenchHeader.tsx | 6 +- .../components/WorkspaceToolsPanel.test.tsx | 80 +++ .../app/components/WorkspaceToolsPanel.tsx | 295 +++++++++ web_platform/src/app/editorSelection.ts | 11 + .../app/hooks/useMapEditorShortcuts.test.tsx | 103 +++ .../src/app/hooks/useMapEditorShortcuts.ts | 101 +++ web_platform/src/components/ui/Badge.tsx | 2 +- web_platform/src/components/ui/Button.tsx | 2 +- web_platform/src/components/ui/IconButton.tsx | 2 +- .../src/components/ui/ResizablePanel.tsx | 2 +- .../ui/ScrubbableNumberInput.test.tsx | 45 ++ .../components/ui/ScrubbableNumberInput.tsx | 190 ++++++ .../components/ui/VerticalSplitPane.test.tsx | 55 ++ .../src/components/ui/VerticalSplitPane.tsx | 210 +++++++ web_platform/src/components/ui/index.ts | 2 + web_platform/src/map/MapAssetLibrary.test.tsx | 224 ++----- web_platform/src/map/MapAssetLibrary.tsx | 184 +----- web_platform/src/map/MapEditorPanel.test.tsx | 48 +- web_platform/src/map/MapEditorPanel.tsx | 593 ++++++------------ web_platform/src/map/MapObjectInspector.tsx | 321 ++++++++++ web_platform/src/map/MapTransformToolbar.tsx | 64 -- .../src/map/PhysicalMapPanel.test.tsx | 18 +- web_platform/src/map/PhysicalMapPanel.tsx | 121 ++-- web_platform/src/map/editor/MapEditSession.ts | 6 + web_platform/src/map/editor/editor.test.ts | 4 + web_platform/src/map/editor/types.ts | 14 + .../src/project/ModelStructureTree.tsx | 68 +- web_platform/src/project/importer.test.ts | 25 + web_platform/src/project/importer.ts | 45 +- .../src/simulation/ActuatorControl.tsx | 30 +- .../src/simulation/RobotInspector.tsx | 327 ++++++++++ web_platform/src/styles.css | 107 +++- .../src/training/LocalTrainingPanel.test.tsx | 12 +- .../src/training/LocalTrainingPanel.tsx | 16 +- web_platform/src/tuning/ScalarChart.tsx | 142 ++++- .../src/viewer/MapEditorLayer.test.ts | 16 +- web_platform/src/viewer/MapEditorLayer.ts | 111 +++- web_platform/src/viewer/MuJoCoViewer.ts | 70 +++ .../src/viewer/ParametricMapPreviewLayer.ts | 9 + 59 files changed, 4779 insertions(+), 1585 deletions(-) create mode 100644 plans/editor-interaction-style-guide.md create mode 100644 plans/map-editor-interaction-flow.md create mode 100644 plans/sidebar-information-architecture.md create mode 100644 web_platform/src/app/components/MapViewportTools.test.tsx create mode 100644 web_platform/src/app/components/MapViewportTools.tsx create mode 100644 web_platform/src/app/components/ModelControlsSidebar.test.tsx create mode 100644 web_platform/src/app/components/RightSidebarTabs.tsx create mode 100644 web_platform/src/app/components/SceneOutliner.test.tsx create mode 100644 web_platform/src/app/components/SceneOutliner.tsx create mode 100644 web_platform/src/app/components/WorkspaceToolsPanel.test.tsx create mode 100644 web_platform/src/app/components/WorkspaceToolsPanel.tsx create mode 100644 web_platform/src/app/editorSelection.ts create mode 100644 web_platform/src/app/hooks/useMapEditorShortcuts.test.tsx create mode 100644 web_platform/src/app/hooks/useMapEditorShortcuts.ts create mode 100644 web_platform/src/components/ui/ScrubbableNumberInput.test.tsx create mode 100644 web_platform/src/components/ui/ScrubbableNumberInput.tsx create mode 100644 web_platform/src/components/ui/VerticalSplitPane.test.tsx create mode 100644 web_platform/src/components/ui/VerticalSplitPane.tsx create mode 100644 web_platform/src/map/MapObjectInspector.tsx delete mode 100644 web_platform/src/map/MapTransformToolbar.tsx create mode 100644 web_platform/src/simulation/RobotInspector.tsx diff --git a/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