diff --git a/README.md b/README.md index 18b2010f..8a30dc64 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ - ROS `package://`、常见 URDF 兼容转换及 DAE 降级处理 - Three.js 模型、碰撞体、坐标系、关节轴、质心和惯量可视化 - 播放、暂停、单步、重置、变速、关节拖动与外力交互 -- 内置平地、坡道、楼梯、随机障碍物及 9 类系统参数化地形(粗糙/波浪、金字塔阶梯、深坑、沟壑等) -- 工程地图包:静态 MJCF/OBJ/STL/高度场碰撞层、GLB 视觉层和机器人出生点 -- V3 地图创作层:认证资产库支持点击添加、拖到画布落位和首个资产自动创建场景,并可通过表单与视口操纵器继续编辑、事务式应用及导出地图 ZIP +- 统一地图资产库:工程地图、认证资产、内置地图及 9 类系统参数化地形共用点击/拖放、场景树、轻量预览、实例变换、放弃和一次编译事务 +- 工程地图包:静态 MJCF/OBJ/STL/高度场碰撞层、GLB 视觉层和机器人出生点;同源实例拥有独立位姿,物理/视觉/出生点同步变换 +- V3 地图创作层:认证资产可在程序地形上自动重力落位,共用右侧属性和视口操纵器,支持源内容编辑、空白取消选择及导出地图 ZIP - 浏览器内 Python 控制器(Pyodide) - ONNX 强化学习策略推理(ONNX Runtime Web) - 内置 Go2 PPO 任务的本机 mjlab 训练桥接服务,以及 DeepSeek 驱动的奖励函数自调参、固定评估和 TensorBoard 风格独立工作台 diff --git a/package-lock.json b/package-lock.json index d31f65c8..22785ca6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mujoco-web-platform", - "version": "0.7.3", + "version": "0.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mujoco-web-platform", - "version": "0.7.3", + "version": "0.8.1", "license": "Apache-2.0", "dependencies": { "@monaco-editor/react": "^4.7.0", diff --git a/package.json b/package.json index 97f82da5..9f0e54ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mujoco-web-platform", - "version": "0.7.3", + "version": "0.8.1", "description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台", "private": true, "type": "module", diff --git a/plans/map-v3-design.md b/plans/map-v3-design.md index b56e3781..d653b2ac 100644 --- a/plans/map-v3-design.md +++ b/plans/map-v3-design.md @@ -110,7 +110,11 @@ Schema V1 支持物理层、视觉层和出生点。Schema V2 增加创作层: ## 7. 编辑交互 -地图面板提供“场景 · 资产库 → 认证资产”入口。用户可以点击添加,或把资产卡片拖到画布的 XY 平面落位;落点按 0.1 m 归一化并根据原语尺寸自动贴地。当前没有可编辑地图时,首个资产会立即创建 `map.json`、空物理层和创作层骨架,切换到新场景并作为未应用草稿进入 Three.js 预览;此阶段不调用 MuJoCo。用户点击“应用并重新编译”后才生成并事务提交实际物理层。 +地图面板提供统一的“场景 · 资产库”,工程地图源、认证资产和系统程序地形都遵循同一条交互链路:点击或拖放到画布、进入轻量草稿、在场景树选择/删除,最后一次应用。三类卡片共享一种受校验的拖放载荷,画布落点统一按 0.1 m 归一化,任何一种来源的增删都不会隐式触发 MuJoCo 编译。 + +地图源与场景实例分离。同一工程地图源可创建多个拥有独立 XY/绕 Z 位姿的引用实例;物理层、视觉层和出生点使用同一个实例变换。编辑 `authoring` 内容属于源编辑,会同步影响引用该 descriptor 的实例,界面必须明确提示这一影响范围,不能伪装成实例级覆盖。 + +当前没有可编辑地图时,首个认证资产会创建临时 `map.json`、空物理层和创作层骨架,切换到新场景并作为未应用草稿进入 Three.js 预览;此阶段不调用 MuJoCo。“放弃场景更改”必须同时撤销临时实例、创作草稿和这三份临时工程文件,下一次创建可以复用同一场景 ID。用户点击“应用并重新编译”后才生成并事务提交实际物理层。 React 中的 `MapEditSession` 是草稿唯一来源。Three.js 预览层只负责: @@ -132,7 +136,9 @@ React 中的 `MapEditSession` 是草稿唯一来源。Three.js 预览层只负 - `Ctrl+Z`:撤销; - `Ctrl+Y` 或 `Ctrl+Shift+Z`:重做。 -拖动 TransformControls 操纵轴期间禁用 OrbitControls;在画布空白区域按住鼠标左键仍可旋转相机。连续拖动只在 `mouseUp` 时提交一次历史记录。缩放必须写回原语参数,不能把 Three.js 节点 scale 作为持久数据。 +拖动 TransformControls 操纵轴期间禁用 OrbitControls;在画布空白区域按住鼠标左键仍可旋转相机。连续拖动只在 `mouseUp` 时提交一次历史记录。缩放必须写回原语参数,不能把 Three.js 节点 scale 作为持久数据。`locked` 的准确语义是“锁定位姿”:禁止位置、旋转和视口变换,但仍允许编辑名称、尺寸、摩擦和颜色。 + +“自动落位(重力)”通过统一地图表面查询器沿世界 `-Z` 求最高承载面。查询器与物理合成共用确定性程序几何,支持有限平地、坡道、楼梯、障碍物、坑/沟壑和高度场,并应用地图实例变换;因此认证资产可正确落到系统程序地形上,而不是始终假设 `z=0`。 ## 8. 只读地图转换 @@ -156,7 +162,7 @@ React 中的 `MapEditSession` 是草稿唯一来源。Three.js 预览层只负 ## 9. 事务边界 -应用编辑草稿或转换地图时,顺序固定为: +地图实例增删、工程地图实例变换、程序地图参数和认证资产源编辑共享同一个应用基线。普通模型入口切换或 URDF 模式重载只能使用上一次成功应用的地图基线,不能静默提交当前场景草稿。应用编辑草稿或转换地图时,顺序固定为: 1. 生成候选创作层; 2. 确定性生成候选物理层; @@ -164,9 +170,10 @@ React 中的 `MapEditSession` 是草稿唯一来源。Three.js 预览层只负 4. 在新的 MEMFS 工作区编译 MuJoCo; 5. Viewer 成功 attach 新会话; 6. 最后提交 manifest、地图选择和编辑状态; -7. 释放旧会话和旧工作区。 +7. 推进地图实例与创作文档的统一应用基线; +8. 释放旧会话和旧工作区。 -任一步失败都必须恢复旧仿真和旧工程状态,同时保留用户草稿。 +任一步失败都必须恢复旧仿真和旧工程状态,同时保留完整用户草稿。编译期间禁用所有草稿写入口;提交完成时只清除本次提交的文档版本,不能丢掉晚到修改。系统程序地形会替换无限地面 plane,避免其填平沟壑、深坑和高度场负高度。 ## 10. 导出 @@ -190,4 +197,9 @@ ZIP 内路径保持工程相对结构,并继续执行路径安全校验。 - Undo/Redo 和视口变换写回; - 只读转换的白名单与整体拒绝; - 编译或 Viewer attach 失败后的事务回滚; -- 地图 ZIP 资产完整性。 +- 地图 ZIP 资产完整性; +- 三类地图来源的统一拖放、场景树和单次提交边界; +- 普通模型重载不提交场景草稿,应用失败后草稿仍可放弃或重试; +- 工程地图实例物理/视觉/出生点变换一致; +- 首个认证资产临时场景的完整放弃; +- 认证资产在程序地形上的重力落位,以及坑洞不被无限平面填平。 diff --git a/web_platform/README.md b/web_platform/README.md index 84e95d3b..83265dbf 100644 --- a/web_platform/README.md +++ b/web_platform/README.md @@ -65,7 +65,7 @@ python3 -m http.server 8080 --directory web-platform-dist ## 地图模块 -模型加载后打开右侧“地图”标签,可以选择平地、坡道、楼梯、随机障碍物、系统参数化地形或工程内地图包。参数化地形包括离散障碍、沟壑、倒金字塔阶梯、深坑、金字塔阶梯、轨道、随机粗糙、踏石和波浪地形;相同参数与随机种子会确定性生成相同碰撞层。点击“应用并重新编译”后,平台会在当前入口同目录生成临时组合 MJCF;原始工程文件不会被修改。模型编译失败时保留上一个可用仿真会话。 +模型加载后打开右侧“地图”标签,即可使用统一地图资产库。工程地图、认证资产、内置程序地图和系统参数化地形共用一条“点击/拖放 → 轻量预览 → 场景树管理 → 一次应用”链路;任何来源的增删或参数修改都先进入同一个场景草稿,不会隐式触发 MuJoCo 编译。参数化地形包括离散障碍、沟壑、倒金字塔阶梯、深坑、金字塔阶梯、轨道、随机粗糙、踏石和波浪地形;相同参数与随机种子会确定性生成相同碰撞层。卡片可直接拖到三维画布,落点按 0.1 m 吸附;所有内置程序地图都可在视口选择、移动和绕 Z 旋转。认证资产的“自动落位(重力)”会查询程序地图真实表面,可落到坡道、楼梯、坑底或高度场,而不是固定假设 `z=0`。最后点击“一次编译应用”才组合 MJCF;编译失败时保留上一个可用仿真会话和完整草稿,普通模型重载也不会提前提交草稿。 工程地图由 `map.json`、静态 MJCF 碰撞层和可选的自包含 GLB 视觉层组成: @@ -94,7 +94,7 @@ maps/warehouse/ 物理地图仅允许静态 `worldbody` 以及 mesh、heightfield、texture、material 等基础 asset,不允许 joint、mocap body、actuator、sensor、include 或 default class。OBJ/STL 应使用简化碰撞模型;高精度模型只放入 GLB。GLB 必须是 2.0 自包含文件,外部 URI 会被拒绝。地图统一使用米制、Z-up、+X 前向坐标系。 -V3 可编辑地图使用 `schemaVersion: 2`,并增加 `"authoring": { "source": "authoring/map.scene.json" }`。创作层支持方盒、圆柱、胶囊、坡道、楼梯和出生点。“场景 · 资产库”中的认证资产可点击添加或拖到画布落位;没有可编辑地图时,首个资产会立即创建 Schema V2 场景草稿,不触发 MuJoCo 重编译。新增对象支持三种放置方式:自动贴地、沿世界 `-Z` 落到最高静态承载面的自动重力落位,以及禁止位姿编辑的锁定模式。只有点击“应用并重新编译”后才提交物理层。可以通过表单或视口 TransformControls 修改位置、绕 Z 轴旋转和原语尺寸,支持移动/旋转吸附、视口拾取、复制、删除及对齐地面;`W`/`E`/`S` 切换移动、旋转和缩放工具,`Delete` 删除,`Ctrl+Z`/`Ctrl+Y` 撤销重做。编辑只更新 Three.js 草稿预览,点击编辑器内“应用并重新编译”后才生成确定性的静态 MJCF。失败时保留旧仿真和草稿。浏览器不会直接写回原目录,可使用“导出地图 ZIP”下载当前已提交地图包。没有 `authoring.source` 的 V1/V2 地图默认只读;仅由 `box`、`cylinder`、`capsule` 构成且不含 asset、材质、碰撞过滤或隐藏姿态语义的静态 MJCF,可通过“创建可编辑副本”显式升级。转换会生成 `authoring/map.scene.json`、Schema V2 描述和确定性物理层;任何不可逆语义都会导致整体拒绝,不会静默丢失内容。 +V3 可编辑地图使用 `schemaVersion: 2`,并增加 `"authoring": { "source": "authoring/map.scene.json" }`。创作层支持方盒、圆柱、胶囊、坡道、楼梯和出生点。“场景 · 资产库”中的认证资产可点击添加或拖到画布落位;没有可编辑地图时,首个资产会创建临时 Schema V2 场景,但不触发 MuJoCo 重编译,“放弃场景更改”会同时移除临时实例和三份临时工程文件。新增对象支持自动贴地、沿世界 `-Z` 自动重力落位及锁定位姿;属性面板可编辑位置、绕 Z 旋转、原语尺寸、摩擦、颜色和透明度。工程地图源与场景实例分离:同一来源可以重复放置,实例拥有独立 XY/绕 Z 位姿,物理层、GLB 视觉层和出生点同步变换;编辑 authoring 源内容会明确同步到同源实例。视口支持吸附、拾取、空白取消选择、复制、删除及对齐地面;`W`/`E`/`S` 切换移动、旋转和缩放工具,`Delete` 删除,`Ctrl+Z`/`Ctrl+Y` 撤销重做。浏览器不会直接写回原目录,可使用“导出地图 ZIP”下载当前已提交地图包。没有 `authoring.source` 的 V1/V2 地图默认只读;仅由 `box`、`cylinder`、`capsule` 构成且不含 asset、材质、碰撞过滤或隐藏姿态语义的静态 MJCF,可通过“创建可编辑副本”显式升级;任何不可逆语义都会导致整体拒绝,不会静默丢失内容。 物理地图超过 2000 个 geom 会产生性能警告,超过 10000 个会被拒绝;GLB 超过 100 万三角面会警告,超过 300 万会被拒绝。当前原生 URDF 模式不支持地图,请切换到“转换为 MJCF”。 diff --git a/web_platform/e2e/app.spec.ts b/web_platform/e2e/app.spec.ts index 87551024..3a0d4b68 100644 --- a/web_platform/e2e/app.spec.ts +++ b/web_platform/e2e/app.spec.ts @@ -481,14 +481,14 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page }) await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 }); await page.getByRole('tab', { name: '地图' }).click(); - const library = page.getByLabel('认证资产'); + const library = page.getByLabel('地图资产库'); await expect(library.getByText('点击添加,或按住资产拖到画布落位。')).toBeVisible(); await 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('V3 地图编辑器')).toBeVisible(); + await expect(page.getByText('认证资产与场景对象属性')).toBeVisible(); await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible(); await page.locator('[data-map-asset="ramp"]').dragTo(page.locator('main canvas')); @@ -510,6 +510,240 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page }) await page.mouse.move(canvasBox!.x + 104, canvasBox!.y + 50, { steps: 8 }); await page.mouse.up({ button: 'left' }); await expect.poll(() => gizmoLine.getAttribute('x2')).not.toBe(beforeRotation); + await expect(page.getByLabel('对象名称')).toHaveCount(0); + await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible(); +}); + +test('认证资产自动打开地图属性并与参数地形一次编译', async ({ page }) => { + await page.goto('/'); + await page.setViewportSize({ width: 1440, height: 900 }); + await page + .locator('input[type="file"]') + .first() + .setInputFiles({ + name: 'unified-map-model.xml', + mimeType: 'text/xml', + buffer: Buffer.from(SIMPLE_MODEL), + }); + await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 }); + 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('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 library.getByRole('button', { name: '添加随机粗糙地形' }).click(); + await page.getByLabel('位置 X(m)').fill('4'); + await expect(page.getByText('2 项场景更改待应用')).toBeVisible(); + + // 切换到参数地形后,尚未编译的认证资产仍保留在画布中并可直接选回。 + const draftCanvasBox = await page.locator('main canvas').first().boundingBox(); + expect(draftCanvasBox).not.toBeNull(); + let selectedDraftAsset = false; + for (let y = 0.15; y <= 0.85 && !selectedDraftAsset; y += 0.05) { + for (let x = 0.05; x <= 0.95; x += 0.05) { + await page.mouse.click( + draftCanvasBox!.x + draftCanvasBox!.width * x, + draftCanvasBox!.y + draftCanvasBox!.height * y, + ); + await page.waitForTimeout(20); + if (await page.getByLabel('对象名称').count()) { + selectedDraftAsset = true; + break; + } + } + } + expect(selectedDraftAsset).toBe(true); + await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json'); + await sceneTree.getByRole('button', { name: '选择地图实例 随机粗糙地形' }).click(); + await expect(page.getByLabel('地图来源')).toHaveValue('builtin'); + + await page.getByRole('button', { name: '一次编译应用' }).click(); + + await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 }); + await expect(page.getByText(/已加载工程地图“场景 1”(1 个物理几何/)).toBeVisible(); + await expect(page.getByText(/已加载随机粗糙地形物理地图/)).toBeVisible(); + await expect(page.getByLabel('地图来源')).toHaveValue('builtin'); + + // 当前仍选中参数地形时,直接点已编译的认证资产也必须反查场景与对象,首次点击即挂载操纵器。 + const canvasBox = await page.locator('main canvas').first().boundingBox(); + expect(canvasBox).not.toBeNull(); + let selectedCompiledAsset = false; + for (let y = 0.15; y <= 0.85 && !selectedCompiledAsset; y += 0.05) { + for (let x = 0.05; x <= 0.95; x += 0.05) { + await page.mouse.click( + canvasBox!.x + canvasBox!.width * x, + canvasBox!.y + canvasBox!.height * y, + ); + await page.waitForTimeout(20); + if (await page.getByLabel('对象名称').count()) { + selectedCompiledAsset = true; + break; + } + } + } + expect(selectedCompiledAsset).toBe(true); + await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json'); + await expect(page.getByLabel('对象名称')).toHaveValue('基础方盒'); + await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible(); + await expect(page.getByText('地图草稿尚未应用')).toHaveCount(0); +}); + +test('放弃首次认证资产会完整回滚临时场景和工程文件', async ({ page }) => { + await page.goto('/'); + await page.setViewportSize({ width: 1440, height: 900 }); + await page + .locator('input[type="file"]') + .first() + .setInputFiles({ + name: 'discard-map-model.xml', + mimeType: 'text/xml', + buffer: Buffer.from(SIMPLE_MODEL), + }); + await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 }); + 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 expect(page.getByText('1 项场景更改待应用')).toHaveCount(0); + await expect(page.getByLabel('场景资产树')).toContainText('0 个实例'); + await expect(library.getByText('场景 1')).toHaveCount(0); + + // 文件和 map id 也必须回滚;再次创建应复用 scene_1,而不是泄漏出 scene_2。 + await library.getByRole('button', { name: '添加基础方盒' }).click(); + await expect(page.getByLabel('场景资产树')).toContainText('场景 1'); + await expect(page.getByLabel('场景资产树')).not.toContainText('场景 2'); +}); + +test('工程地图与参数地形共享放置草稿、实例变换和回滚入口', async ({ page }) => { + const mapJson = JSON.stringify({ + schemaVersion: 1, + id: 'warehouse-draft', + name: '草稿仓库', + coordinateSystem: { units: 'm', up: 'Z', forward: '+X' }, + physics: { source: 'physics/world.xml' }, + spawnPoints: [], + }); + const project = zipSync({ + 'model.xml': Buffer.from(SIMPLE_MODEL), + 'maps/warehouse/map.json': Buffer.from(mapJson), + 'maps/warehouse/physics/world.xml': Buffer.from( + '', + ), + }); + await page.goto('/'); + await page.setViewportSize({ width: 1440, height: 900 }); + await page + .locator('input[type="file"]') + .first() + .setInputFiles({ + name: 'unified-project-map.zip', + mimeType: 'application/zip', + buffer: Buffer.from(project), + }); + await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 }); + 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('待应用'); + await expect(page.getByText('正在准备仿真')).toHaveCount(0); + await page.getByLabel('位置 X(m)').fill('3'); + await page.getByLabel('位置 Y(m)').fill('-2'); + await expect(page.getByText('1 项场景更改待应用')).toBeVisible(); + + await library.getByRole('button', { name: '添加波浪地形' }).click(); + await expect(page.getByText('2 项场景更改待应用')).toBeVisible(); + await page.getByRole('button', { name: '一次编译应用' }).click(); + await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 }); + await expect(page.getByText(/已加载工程地图“草稿仓库”/)).toBeVisible(); + await expect(page.getByText(/已加载波浪地形物理地图/)).toBeVisible(); + + await page.getByRole('button', { name: '删除地图实例 草稿仓库' }).click(); + await expect(page.getByText('1 项场景更改待应用')).toBeVisible(); + await expect(page.getByLabel('场景资产树')).not.toContainText('草稿仓库'); + await page.getByRole('button', { name: '放弃场景更改' }).click(); + await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0); + await expect(page.getByLabel('场景资产树')).toContainText('草稿仓库'); +}); + +test('认证资产重力放置使用参数地形的真实承载高度', async ({ page }) => { + await page.goto('/'); + await page.setViewportSize({ width: 1440, height: 900 }); + await page + .locator('input[type="file"]') + .first() + .setInputFiles({ + name: 'terrain-surface-model.xml', + mimeType: 'text/xml', + buffer: Buffer.from(SIMPLE_MODEL), + }); + await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 }); + await page.getByRole('tab', { name: '地图' }).click(); + + const library = page.getByLabel('地图资产库'); + await library.getByRole('button', { name: '添加深坑地形' }).click(); + await page.getByLabel('地形难度(0–1)').fill('1'); + await library.getByLabel('新增资产放置方式').selectOption('gravity'); + await library.getByRole('button', { name: '添加基础方盒' }).click(); + + await expect(page.getByLabel('对象放置方式')).toHaveValue('gravity'); + await expect(page.getByLabel('对象位置Z')).toHaveValue('-0.3'); + await expect(page.getByText('2 项场景更改待应用')).toBeVisible(); +}); + +test('参数化地形可连续拖到画布并一次性编译', async ({ page }) => { + await page.goto('/'); + await page.setViewportSize({ width: 1440, height: 900 }); + await page + .locator('input[type="file"]') + .first() + .setInputFiles({ + name: 'terrain-draft-model.xml', + mimeType: 'text/xml', + buffer: Buffer.from(SIMPLE_MODEL), + }); + await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 }); + await page.getByRole('tab', { name: '地图' }).click(); + + await page.locator('[data-system-terrain="rough"]').dragTo(page.locator('main canvas')); + await expect(page.getByText('1 项场景更改待应用')).toBeVisible(); + await expect(page.getByText('正在准备仿真')).toHaveCount(0); + await page.locator('[data-system-terrain="wave"]').dragTo(page.locator('main canvas')); + await expect(page.getByText('2 项场景更改待应用')).toBeVisible(); + await expect(page.getByLabel('场景资产树')).toContainText('随机粗糙地形'); + await expect(page.getByLabel('场景资产树')).toContainText('波浪地形'); + + await page.getByRole('tab', { name: '属性' }).click(); + 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('地图与对象属性')).toBeVisible(); + + await page.getByRole('button', { name: '一次编译应用' }).click(); + await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 }); + await expect(page.getByText(/已加载随机粗糙地形物理地图/)).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByText(/已加载波浪地形物理地图/)).toBeVisible(); }); test('应用内置 MJCF 楼梯物理地图', async ({ page }) => { @@ -638,7 +872,7 @@ test('将受支持的只读物理地图转换为可编辑副本', async ({ page await page.getByRole('tab', { name: '地图' }).click(); await page.getByLabel('地图来源').selectOption({ label: '旧版基础场景' }); await page.getByRole('button', { name: '应用并重新编译' }).click(); - const editor = page.getByText('V3 地图编辑器').locator('..'); + const editor = page.getByText('认证资产与场景对象属性').locator('..'); await expect(editor.getByText(/保持只读/)).toBeVisible({ timeout: 30_000 }); await editor.getByRole('button', { name: '创建可编辑副本' }).click(); await expect(page.getByText('已创建可编辑地图副本')).toBeVisible({ timeout: 30_000 }); @@ -684,8 +918,8 @@ test('编辑 V3 地图对象并事务式应用', async ({ page }) => { await page.getByRole('tab', { name: '地图' }).click(); await page.getByLabel('地图来源').selectOption({ label: '可编辑场景' }); await page.getByRole('button', { name: '应用并重新编译' }).click(); - await expect(page.getByText('V3 地图编辑器')).toBeVisible({ timeout: 30_000 }); - const editor = page.getByText('V3 地图编辑器').locator('..'); + await expect(page.getByText('认证资产与场景对象属性')).toBeVisible({ timeout: 30_000 }); + const editor = page.getByText('认证资产与场景对象属性').locator('..'); await expect(editor.getByRole('button', { name: '移动工具 W' })).toHaveAttribute( 'aria-pressed', 'true', @@ -735,15 +969,29 @@ test('工程地图编译失败时保留上一仿真会话', async ({ page }) => buffer: Buffer.from(project), }); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: '▶ 播放' }).click(); + await page.waitForTimeout(200); + const runningTime = Number( + (await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0, + ); await page.getByRole('tab', { name: '地图' }).click(); await page.getByLabel('地图来源').selectOption({ label: '动态错误地图' }); await page.getByRole('button', { name: '应用并重新编译' }).click(); await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 }); - await expect(page.getByLabel('地图来源')).toHaveValue('none'); + await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/invalid/map.json'); + await expect(page.getByText('1 项场景更改待应用')).toBeVisible(); + await expect(page.getByLabel('场景资产树')).toContainText('待应用'); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible(); await page.getByRole('button', { name: '关闭错误' }).click(); await page.getByRole('button', { name: '▶ 播放' }).click(); - await page.waitForTimeout(300); - await expect(page.locator('footer')).not.toContainText('时间 0.000 s'); + await expect + .poll(async () => + Number((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0), + ) + .toBeGreaterThan(runningTime); + await page.getByRole('button', { name: '放弃场景更改' }).click(); + await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0); + await expect(page.getByLabel('地图来源')).toHaveValue('none'); }); test('无效模型显示中文诊断且保留工程树', async ({ page }) => { diff --git a/web_platform/src/app/App.tsx b/web_platform/src/app/App.tsx index 3d756c91..565bf790 100644 --- a/web_platform/src/app/App.tsx +++ b/web_platform/src/app/App.tsx @@ -5,6 +5,7 @@ import { Suspense, useCallback, useEffect, + useMemo, useRef, useState, type ChangeEvent, @@ -87,13 +88,20 @@ import { upsertCachedMjcf, } from '../project/cachedFiles'; import { + createPlacedMapAsset, DEFAULT_MAP_SELECTION, DEFAULT_PHYSICAL_MAP_CONFIG, + PHYSICAL_MAP_PRESET_LABELS, + mapLocalPointToWorld, + mapSelectionTransform, + mapWorldPointToLocal, + updatePlacedMapAsset, type MapSelection, + type PlacedMapAsset, + type PlacedMapSelection, type SystemTerrainPreset, } from '../map/types'; -import { discoverMapEntries, resolveProjectMap, visualMapAsset } from '../map/MapLoader'; -import { decodeMapDefinition } from '../map/mapSchema'; +import { discoverMapEntries, resolveProjectMap, visualMapAssets } from '../map/MapLoader'; import { decodeEditableMapDocument, encodeEditableMapDocument } from '../map/editor/editorSchema'; import type { EditableMapDocument, @@ -102,15 +110,21 @@ import type { MapEditorTransformMode, MapObjectPlacementMode, } from '../map/editor/types'; -import { isMapObjectPlacementMode } from '../map/editor/types'; -import { - isEditableMapObjectType, - MAP_ASSET_DRAG_MIME, - MAP_ASSET_PLACEMENT_MIME, -} from '../map/editor/assetCatalog'; +import { decodeMapLibraryDragPayload, MAP_LIBRARY_DRAG_MIME } from '../map/editor/assetCatalog'; import { compileEditableMapDocument } from '../map/editor/MapDocumentCompiler'; +import { materializeEditableMapDrafts } from '../map/editor/EditableMapDraftCommit'; +import { findCompiledEditableMapPick } from '../map/editor/compiledMapPick'; +import { mapSceneSurfaceHeightAt } from '../map/sceneSurface'; import { importEditableMapDocument } from '../map/editor/MapDocumentImporter'; import { resolveProjectAssetPath } from '../map/mapPaths'; +import { + clonePlacedMapAssets, + mapEditorDraftPreviewInstances, + resolveMapSceneLoadAssets, + restoreAppliedMapScene, + summarizeMapSceneDraft, + transformParametricMapAsset, +} from '../map/mapSceneDraft'; const SourceEditorDialog = lazy(() => import('./components/SourceEditorDialog').then((module) => ({ @@ -118,6 +132,10 @@ const SourceEditorDialog = lazy(() => })), ); +function hasTransferType(dataTransfer: DataTransfer, type: string): boolean { + return Array.from(dataTransfer.types).includes(type); +} + function diagnostic( category: AppDiagnostic['category'], error: unknown, @@ -178,6 +196,30 @@ function urdfLinkNames(project: ProjectManifest | null, path: string | undefined .filter((name): name is string => Boolean(name)); } +function omitManifestFiles( + manifest: ProjectManifest, + omittedPaths: ReadonlySet, +): ProjectManifest { + if (!omittedPaths.size) return manifest; + const files = manifest.files.filter((file) => !omittedPaths.has(file.path)); + return { + ...manifest, + files, + maps: discoverMapEntries(files), + totalBytes: files.reduce((total, file) => total + file.size, 0), + }; +} + +function manifestEditorDocuments(manifest: ProjectManifest): Map { + const documents = new Map(); + for (const map of manifest.maps) { + if (!map.authoringPath) continue; + const file = manifest.files.find((candidate) => candidate.path === map.authoringPath); + if (file) documents.set(map.descriptorPath, decodeEditableMapDocument(file.data)); + } + return documents; +} + export function App() { const state = useAppStore( useShallow((value) => ({ @@ -216,10 +258,20 @@ export function App() { viewerReady = useRef | null>(null), dragDepth = useRef(0), editorInteraction = useRef(null), + editorDraftsRef = useRef>(new Map()), + provisionalMapFilesRef = useRef>(new Map()), + pendingEditorObjectId = useRef(undefined), + compiledMapBodyInteraction = useRef<(bodyName: string) => boolean>(() => false), + mapEditorPreviewInteraction = useRef<(mapAssetId: string, objectId: string) => void>(() => {}), + parametricMapInteraction = useRef<{ + onSelect(id: string | null): void; + onTransform(id: string, position: [number, number], yawDeg: number): void; + } | null>(null), pendingMapAsset = useRef<{ type: EditableMapObjectType; position?: [number, number, number]; placementMode: MapObjectPlacementMode; + externalSupportTop?: number; } | null>(null), urdfEnhancementsRef = useRef({ addActuators: true, @@ -251,8 +303,15 @@ export function App() { [policyStatus, setPolicyStatus] = useState(), [projectMaps, setProjectMaps] = useState([]), [editorDocument, setEditorDocument] = useState(null), + [editorDrafts, setEditorDrafts] = useState>(() => new Map()), + [committedEditorDocuments, setCommittedEditorDocuments] = useState< + Map + >(() => new Map()), [projectSidebarTab, setProjectSidebarTab] = useState<'project' | 'structure' | 'assets'>( 'project', + ), + [modelControlsTab, setModelControlsTab] = useState<'properties' | 'controls' | 'data' | 'map'>( + 'properties', ); const [urdfMode, setUrdfMode] = useState('mjcf'), urdfModeRef = useRef('mjcf'); @@ -260,6 +319,12 @@ export function App() { baseModeRef = useRef('floating'); const [mapSelection, setMapSelection] = useState(DEFAULT_MAP_SELECTION), mapSelectionRef = useRef(DEFAULT_MAP_SELECTION), + [placedMapAssets, setPlacedMapAssets] = useState([]), + placedMapAssetsRef = useRef([]), + [appliedMapAssets, setAppliedMapAssets] = useState([]), + appliedMapAssetsRef = useRef([]), + [activeMapAssetId, setActiveMapAssetId] = useState(), + activeMapAssetIdRef = useRef(undefined), [showVisualMap, setShowVisualMap] = useState(true), [showMapCollision, setShowMapCollision] = useState(false); const [displayOptions, setDisplayOptions] = useState(initialDisplayOptions), @@ -271,6 +336,30 @@ export function App() { const showCollision = displayOptions.showCollision, setShowCollision = (value: boolean) => setDisplayOptions((options) => ({ ...options, showCollision: value })); + const mapSceneDraft = useMemo( + () => summarizeMapSceneDraft(placedMapAssets, appliedMapAssets), + [placedMapAssets, appliedMapAssets], + ); + const pendingSceneIds = useMemo(() => { + const ids = new Set(mapSceneDraft.changedIds); + for (const asset of placedMapAssets) + if (asset.selection.kind === 'project' && editorDrafts.has(asset.selection.descriptorPath)) + ids.add(asset.id); + return [...ids]; + }, [mapSceneDraft.changedIds, placedMapAssets, editorDrafts]); + const editorOnlyDraftCount = useMemo(() => { + const changedIds = new Set(mapSceneDraft.changedIds); + const coveredDescriptors = new Set( + placedMapAssets.flatMap((asset) => + changedIds.has(asset.id) && asset.selection.kind === 'project' + ? [asset.selection.descriptorPath] + : [], + ), + ); + return [...editorDrafts.keys()].filter((path) => !coveredDescriptors.has(path)).length; + }, [mapSceneDraft.changedIds, placedMapAssets, editorDrafts]); + const sceneDraftChangeCount = mapSceneDraft.changeCount + editorOnlyDraftCount, + mapSceneDirty = sceneDraftChangeCount > 0; const viewerSettings = useRef({ mode: state.mode, forceScale, @@ -307,7 +396,17 @@ export function App() { .then(({ MuJoCoViewer: Viewer }) => { if (!active) return null; const next = new Viewer(host, { - onSelection: state.setSelection, + onSelection: (selection) => { + if (selection && compiledMapBodyInteraction.current(selection.bodyName)) { + state.setSelection(null); + return; + } + state.setSelection(selection); + if (selection) { + setRightOpen(true); + setModelControlsTab('properties'); + } + }, onFrame: (frame, fps, snapshot) => { const memory = (performance as Performance & { memory?: { usedJSHeapSize: number } }) .memory?.usedJSHeapSize; @@ -333,7 +432,18 @@ export function App() { diagnostic(error.message.includes('控制器') ? '仿真' : '渲染', error), ); }, - onMapEditorSelect: (id) => editorInteraction.current?.onSelect(id), + onMapEditorSelect: (id) => { + editorInteraction.current?.onSelect(id); + if (id) { + setRightOpen(true); + setModelControlsTab('map'); + } + }, + onMapEditorPreviewSelect: (mapAssetId, objectId) => + mapEditorPreviewInteraction.current(mapAssetId, objectId), + onParametricMapSelect: (id) => parametricMapInteraction.current?.onSelect(id), + onParametricMapTransform: (id, position, yawDeg) => + parametricMapInteraction.current?.onTransform(id, position, yawDeg), onMapEditorTransform: (id, position, quaternion, scale) => editorInteraction.current?.onTransform({ id, position, quaternion, scale }), }); @@ -349,6 +459,11 @@ export function App() { next.setMapDisplay(settings.showVisualMap, settings.showMapCollision); next.setShowSensorCamera(settings.showSensorCamera); next.setTheme(settings.theme); + next.setParametricMapAssets( + placedMapAssetsRef.current, + summarizeMapSceneDraft(placedMapAssetsRef.current, appliedMapAssetsRef.current) + .changedIds, + ); return next; }) .catch((error) => { @@ -386,6 +501,9 @@ export function App() { useEffect(() => { viewer.current?.setMapDisplay(showVisualMap, showMapCollision); }, [showVisualMap, showMapCollision]); + useEffect(() => { + viewer.current?.setParametricMapAssets(placedMapAssets, mapSceneDraft.changedIds); + }, [placedMapAssets, mapSceneDraft.changedIds]); useEffect(() => { if (window.innerWidth < 900) return; try { @@ -414,129 +532,170 @@ export function App() { document.addEventListener('fullscreenchange', change); return () => document.removeEventListener('fullscreenchange', change); }, []); - const loadEntry = useCallback(async (path: string, requestedMode?: UrdfLoadMode) => { - if (!manifest.current || loadInFlight.current) return false; - const previousEntry = useAppStore.getState().selectedEntry; - loadInFlight.current = true; - setIgnoreJointLimits(false); - setControllerStatus(undefined); - setPolicyStatus(undefined); - state.setEntry(path); - state.setLoading(true); - setImportProgress({ - title: '正在准备仿真', - label: '初始化三维视口', - detail: path, - value: 0.4, - }); - state.setDiagnostic(undefined); - setGeneratedMjcf(undefined); - setGeneratedMjcfPath(undefined); - adapter.current.setPaused(true); - try { - const activeViewer = viewer.current ?? (await viewerReady.current); - if (!activeViewer) throw new Error('三维视口尚未就绪,请重试'); - const snapshot = await adapter.current.load(manifest.current, path, { - urdfMode: requestedMode ?? urdfModeRef.current, - baseMode: baseModeRef.current, - enhancements: urdfEnhancementsRef.current, - map: mapSelectionRef.current, - onProgress: ({ value, label }) => - setImportProgress({ - title: '正在准备仿真', - label, - detail: path, - value: 0.4 + value * 0.53, - }), - }); - const supportFiles = adapter.current.cachedSupportFiles(); + const loadEntry = useCallback( + async ( + path: string, + requestedMode?: UrdfLoadMode, + requestedSceneAssets?: readonly PlacedMapAsset[], + ) => { + if (!manifest.current || loadInFlight.current) return false; + // 普通模型重载始终使用上次成功应用的地图基线。只有场景提交入口可以显式 + // 传入草稿实例,防止切换模型/URDF 模式时把一半场景静默提前提交。 + const sceneAssets = resolveMapSceneLoadAssets( + appliedMapAssetsRef.current, + requestedSceneAssets, + ); + const previousState = useAppStore.getState(); + const previousEntry = previousState.selectedEntry; + const previousPaused = previousState.paused; + loadInFlight.current = true; + setIgnoreJointLimits(false); + setControllerStatus(undefined); + setPolicyStatus(undefined); + state.setEntry(path); + state.setLoading(true); setImportProgress({ title: '正在准备仿真', - label: '创建三维场景', + label: '初始化三维视口', detail: path, - value: 0.94, + value: 0.4, }); - adapter.current.setSpeed(useAppStore.getState().speed); - try { - activeViewer.attach(adapter.current.session); - } catch (error) { - adapter.current.rollbackRetired(); - activeViewer.attach(adapter.current.session); - throw error; - } - adapter.current.releaseRetired(); - if (supportFiles.length && manifest.current) { - manifest.current = mergeCachedFiles(manifest.current, supportFiles); - state.setProject( - manifest.current.name, - manifest.current.files.map((file) => ({ path: file.path, size: file.size })), - manifest.current.entries, - path, - ); - } - state.setSnapshot(snapshot); - state.setSelection(null); + state.setDiagnostic(undefined); + setGeneratedMjcf(undefined); + setGeneratedMjcfPath(undefined); + adapter.current.setPaused(true); state.setPaused(true); - setImportProgress({ - title: '正在准备仿真', - label: '加载视觉地图与材质', - detail: path, - value: 0.97, - }); - await activeViewer.setVisualMap(null); - let visualMapWarning: string | undefined; + let attachedViewer: MuJoCoViewer | null = null; + let sessionSwapped = false; try { - const asset = manifest.current - ? visualMapAsset(manifest.current, mapSelectionRef.current) - : null; - await activeViewer.setVisualMap(asset); + const activeViewer = viewer.current ?? (await viewerReady.current); + if (!activeViewer) throw new Error('三维视口尚未就绪,请重试'); + const snapshot = await adapter.current.load(manifest.current, path, { + urdfMode: requestedMode ?? urdfModeRef.current, + baseMode: baseModeRef.current, + enhancements: urdfEnhancementsRef.current, + mapAssets: sceneAssets, + onProgress: ({ value, label }) => + setImportProgress({ + title: '正在准备仿真', + label, + detail: path, + value: 0.4 + value * 0.53, + }), + }); + const supportFiles = adapter.current.cachedSupportFiles(); + setImportProgress({ + title: '正在准备仿真', + label: '创建三维场景', + detail: path, + value: 0.94, + }); + adapter.current.setSpeed(useAppStore.getState().speed); + try { + activeViewer.attach(adapter.current.session); + attachedViewer = activeViewer; + sessionSwapped = true; + } catch (error) { + adapter.current.rollbackRetired(); + activeViewer.attach(adapter.current.session); + throw error; + } + if (supportFiles.length && manifest.current) { + manifest.current = mergeCachedFiles(manifest.current, supportFiles); + state.setProject( + manifest.current.name, + manifest.current.files.map((file) => ({ path: file.path, size: file.size })), + manifest.current.entries, + path, + ); + } + state.setSnapshot(snapshot); + state.setSelection(null); + state.setPaused(true); + setImportProgress({ + title: '正在准备仿真', + label: '加载视觉地图与材质', + detail: path, + value: 0.97, + }); + await activeViewer.setVisualMaps([]); + let visualMapWarning: string | undefined; + try { + const assets = manifest.current + ? visualMapAssets(manifest.current, placedMapAssetsRef.current) + : []; + await activeViewer.setVisualMaps(assets); + } catch (error) { + visualMapWarning = `视觉地图加载失败:${error instanceof Error ? error.message : String(error)}`; + console.warn('[MuJoCo] 视觉地图加载失败', error); + } + try { + setGeneratedMjcf(new TextDecoder().decode(adapter.current.exportMjcf())); + setGeneratedMjcfPath(convertedCachePath(path)); + } catch (error) { + console.warn('[MuJoCo] 无法生成源码预览', error); + } + const notice: WorkbenchNotification = { + id: ++notificationId.current, + title: + snapshot.warnings.length || visualMapWarning + ? `模型已加载 · ${snapshot.warnings.length + (visualMapWarning ? 1 : 0)} 项兼容调整` + : '模型加载完成', + detail: + [...snapshot.warnings, ...(visualMapWarning ? [visualMapWarning] : [])].join('\n') || + path, + tone: snapshot.warnings.length || visualMapWarning ? 'warning' : 'success', + at: Date.now(), + }; + setNotifications((items) => [notice, ...items].slice(0, 20)); + setToast(notice); + if (requestedSceneAssets !== undefined) { + const committedMapAssets = clonePlacedMapAssets(sceneAssets); + appliedMapAssetsRef.current = committedMapAssets; + setAppliedMapAssets(committedMapAssets); + } + activeViewer.setParametricMapAssets( + placedMapAssetsRef.current, + summarizeMapSceneDraft(placedMapAssetsRef.current, appliedMapAssetsRef.current) + .changedIds, + ); + adapter.current.releaseRetired(); + sessionSwapped = false; + return true; } catch (error) { - visualMapWarning = `视觉地图加载失败:${error instanceof Error ? error.message : String(error)}`; - console.warn('[MuJoCo] 视觉地图加载失败', error); + if (sessionSwapped && attachedViewer) { + try { + adapter.current.rollbackRetired(); + attachedViewer.attach(adapter.current.session); + } catch (rollbackError) { + console.error('[MuJoCo] 无法恢复上一仿真会话', rollbackError); + } + } + state.setDiagnostic(diagnostic('模型编译', error, path)); + const notice: WorkbenchNotification = { + id: ++notificationId.current, + title: '模型编译失败', + detail: error instanceof Error ? error.message : String(error), + tone: 'danger', + at: Date.now(), + }; + setNotifications((items) => [notice, ...items].slice(0, 20)); + setToast(notice); + const retained = adapter.current.snapshot(); + if (retained && previousEntry) state.setEntry(previousEntry); + adapter.current.setPaused(previousPaused); + state.setPaused(previousPaused); + setControllerStatus(retained?.controller); + setPolicyStatus(retained?.rlPolicy); + return false; + } finally { + loadInFlight.current = false; + setImportProgress(undefined); + state.setLoading(false); } - try { - setGeneratedMjcf(new TextDecoder().decode(adapter.current.exportMjcf())); - setGeneratedMjcfPath(convertedCachePath(path)); - } catch (error) { - console.warn('[MuJoCo] 无法生成源码预览', error); - } - const notice: WorkbenchNotification = { - id: ++notificationId.current, - title: - snapshot.warnings.length || visualMapWarning - ? `模型已加载 · ${snapshot.warnings.length + (visualMapWarning ? 1 : 0)} 项兼容调整` - : '模型加载完成', - detail: - [...snapshot.warnings, ...(visualMapWarning ? [visualMapWarning] : [])].join('\n') || - path, - tone: snapshot.warnings.length || visualMapWarning ? 'warning' : 'success', - at: Date.now(), - }; - setNotifications((items) => [notice, ...items].slice(0, 20)); - setToast(notice); - return true; - } catch (error) { - state.setDiagnostic(diagnostic('模型编译', error, path)); - const notice: WorkbenchNotification = { - id: ++notificationId.current, - title: '模型编译失败', - detail: error instanceof Error ? error.message : String(error), - tone: 'danger', - at: Date.now(), - }; - setNotifications((items) => [notice, ...items].slice(0, 20)); - setToast(notice); - const retained = adapter.current.snapshot(); - if (retained && previousEntry) state.setEntry(previousEntry); - setControllerStatus(retained?.controller); - setPolicyStatus(retained?.rlPolicy); - return false; - } finally { - loadInFlight.current = false; - setImportProgress(undefined); - state.setLoading(false); - } - }, []); + }, + [], + ); const requestLoadEntry = useCallback( async (path: string) => { const entry = manifest.current?.entries.find((candidate) => candidate.path === path); @@ -599,9 +758,20 @@ export function App() { }); manifest.current = next; setProjectMaps(next.maps); + setCommittedEditorDocuments(manifestEditorDocuments(next)); setProjectSidebarTab('project'); setEditorDocument(null); + editorDraftsRef.current = new Map(); + setEditorDrafts(new Map()); + provisionalMapFilesRef.current.clear(); viewer.current?.setMapEditorDocument(null); + placedMapAssetsRef.current = []; + setPlacedMapAssets([]); + appliedMapAssetsRef.current = []; + setAppliedMapAssets([]); + viewer.current?.setParametricMapAssets([], []); + activeMapAssetIdRef.current = undefined; + setActiveMapAssetId(undefined); mapSelectionRef.current = DEFAULT_MAP_SELECTION; setMapSelection(DEFAULT_MAP_SELECTION); setSelectedControllerPath(next.files.find((file) => /\.py$/i.test(file.path))?.path); @@ -642,7 +812,7 @@ export function App() { if (state.projectName) setRemoveConfirmOpen(true); }; const confirmRemoveProject = () => { - void viewer.current?.setVisualMap(null); + void viewer.current?.setVisualMaps([]); viewer.current?.attach(null); adapter.current.dispose(); adapter.current = new MainThreadPhysicsAdapter(); @@ -656,9 +826,20 @@ export function App() { setSelectedPolicyPath(undefined); setPolicyStatus(undefined); setProjectMaps([]); + setCommittedEditorDocuments(new Map()); setProjectSidebarTab('project'); setEditorDocument(null); + editorDraftsRef.current = new Map(); + setEditorDrafts(new Map()); + provisionalMapFilesRef.current.clear(); viewer.current?.setMapEditorDocument(null); + placedMapAssetsRef.current = []; + setPlacedMapAssets([]); + appliedMapAssetsRef.current = []; + setAppliedMapAssets([]); + viewer.current?.setParametricMapAssets([], []); + activeMapAssetIdRef.current = undefined; + setActiveMapAssetId(undefined); mapSelectionRef.current = DEFAULT_MAP_SELECTION; setMapSelection(DEFAULT_MAP_SELECTION); state.clearProject(); @@ -681,19 +862,63 @@ export function App() { if (entry?.format === 'urdf' && urdfModeRef.current === 'mjcf') void loadEntry(entry.path, 'mjcf'); }; + const editorSurfaceHeight = useCallback( + (position: readonly [number, number, number]): number | null => { + const selection = mapSelectionRef.current; + const worldPosition = + selection.kind === 'project' + ? mapLocalPointToWorld(position, selection) + : ([...position] as [number, number, number]); + return mapSceneSurfaceHeightAt( + placedMapAssetsRef.current, + worldPosition[0], + worldPosition[1], + ); + }, + [], + ); const previewEditorDocument = useCallback((document: EditableMapDocument | null) => { - viewer.current?.setMapEditorDocument(document); + const selection = mapSelectionRef.current; + viewer.current?.setMapEditorDocument( + document, + selection.kind === 'project' ? mapSelectionTransform(selection) : undefined, + ); if (document) { adapter.current.setPaused(true); state.setPaused(true); } }, []); + const updateEditorDraft = useCallback( + (descriptorPath: string, document: EditableMapDocument, dirty: boolean) => { + if (loadInFlight.current) return; + const drafts = new Map(editorDraftsRef.current); + if (dirty) drafts.set(descriptorPath, structuredClone(document)); + else drafts.delete(descriptorPath); + editorDraftsRef.current = drafts; + setEditorDrafts(drafts); + }, + [], + ); + const clearEditorDrafts = useCallback(() => { + editorDraftsRef.current = new Map(); + setEditorDrafts(new Map()); + }, []); const bindEditorInteraction = useCallback((callbacks: MapEditorInteractionCallbacks | null) => { editorInteraction.current = callbacks; const pending = pendingMapAsset.current; if (callbacks && pending) { pendingMapAsset.current = null; - callbacks.onAddAsset(pending.type, pending.position, pending.placementMode); + callbacks.onAddAsset( + pending.type, + pending.position, + pending.placementMode, + pending.externalSupportTop, + ); + } + const objectId = pendingEditorObjectId.current; + if (callbacks && objectId) { + pendingEditorObjectId.current = undefined; + callbacks.onSelect(objectId); } }, []); const selectEditorObject = useCallback((id: string | null) => { @@ -708,7 +933,7 @@ export function App() { }, [], ); - const readEditorDocument = (selection: MapSelection): EditableMapDocument | null => { + const readEditorDocument = useCallback((selection: MapSelection): EditableMapDocument | null => { if (selection.kind !== 'project' || !manifest.current) return null; const resolved = resolveProjectMap(manifest.current, selection.descriptorPath); if (!resolved.authoringPath) return null; @@ -716,11 +941,218 @@ export function App() { (candidate) => candidate.path === resolved.authoringPath, ); return file ? decodeEditableMapDocument(file.data) : null; + }, []); + const sceneEditorDocuments = useMemo( + () => new Map([...committedEditorDocuments, ...editorDrafts]), + [committedEditorDocuments, editorDrafts], + ); + const mapEditorDraftPreviews = useMemo( + () => + mapEditorDraftPreviewInstances( + placedMapAssets, + pendingSceneIds, + sceneEditorDocuments, + activeMapAssetId, + ), + [placedMapAssets, pendingSceneIds, sceneEditorDocuments, activeMapAssetId], + ); + useEffect(() => { + let active = true; + const update = (activeViewer: MuJoCoViewer | null) => { + if (active) activeViewer?.setMapEditorPreviewInstances(mapEditorDraftPreviews); + }; + if (viewer.current) update(viewer.current); + else void viewerReady.current?.then(update); + return () => { + active = false; + }; + }, [mapEditorDraftPreviews]); + const selectionName = (selection: PlacedMapSelection): string => + selection.kind === 'builtin' + ? PHYSICAL_MAP_PRESET_LABELS[selection.config.preset] + : (projectMaps.find((map) => map.descriptorPath === selection.descriptorPath)?.name ?? + selection.descriptorPath.split('/').at(-2) ?? + '工程地图'); + const uniqueMapAssetName = ( + base: string, + assets: readonly PlacedMapAsset[] = placedMapAssetsRef.current, + excludedId?: string, + ): string => { + const names = new Set( + assets.filter((asset) => asset.id !== excludedId).map((asset) => asset.name), + ); + if (!names.has(base)) return base; + let index = 2; + while (names.has(`${base} ${index}`)) index += 1; + return `${base} ${index}`; }; + const setMapScene = useCallback( + (assets: PlacedMapAsset[], activeId: string | undefined, selection?: MapSelection) => { + const active = activeId ? assets.find((asset) => asset.id === activeId) : undefined; + const nextSelection = selection ?? active?.selection ?? DEFAULT_MAP_SELECTION; + if (activeMapAssetIdRef.current !== active?.id) { + viewer.current?.selectParametricMapAsset(null); + viewer.current?.selectMapEditorObject(null); + editorInteraction.current = null; + } + placedMapAssetsRef.current = assets; + setPlacedMapAssets(assets); + activeMapAssetIdRef.current = active?.id; + setActiveMapAssetId(active?.id); + mapSelectionRef.current = nextSelection; + setMapSelection(nextSelection); + }, + [], + ); + const focusMapProperties = useCallback(() => { + setRightOpen(true); + setModelControlsTab('map'); + }, []); + const previewVisualMapScene = useCallback( + (assets: readonly PlacedMapAsset[], reload: boolean) => { + const current = manifest.current; + const activeViewer = viewer.current; + if (!current || !activeViewer) return; + try { + const visuals = visualMapAssets(current, assets); + if (reload) + void activeViewer.setVisualMaps(visuals).catch((error) => { + console.warn('[MuJoCo] 视觉地图草稿预览失败', error); + }); + else activeViewer.setVisualMapTransforms(visuals); + } catch (error) { + console.warn('[MuJoCo] 无法解析视觉地图草稿', error); + } + }, + [], + ); + const stageMapSelectionDraft = useCallback( + (selection: PlacedMapSelection) => { + if (useAppStore.getState().loading || loadInFlight.current) return; + const activeId = activeMapAssetIdRef.current; + const active = activeId + ? placedMapAssetsRef.current.find((asset) => asset.id === activeId) + : undefined; + if (!active || active.selection.kind !== selection.kind) return; + if ( + active.selection.kind === 'project' && + selection.kind === 'project' && + active.selection.descriptorPath !== selection.descriptorPath + ) + return; + const assets = updatePlacedMapAsset(placedMapAssetsRef.current, active.id, selection); + setMapScene(assets, active.id, selection); + if (selection.kind === 'project') { + previewVisualMapScene(assets, false); + const committed = readEditorDocument(selection); + setEditorDocument(committed); + previewEditorDocument(editorDraftsRef.current.get(selection.descriptorPath) ?? committed); + } + adapter.current.setPaused(true); + useAppStore.getState().setPaused(true); + }, + [previewEditorDocument, previewVisualMapScene, readEditorDocument, setMapScene], + ); + const activateMapAsset = useCallback( + (id: string, objectId?: string) => { + if (useAppStore.getState().loading || loadInFlight.current) return; + const asset = placedMapAssetsRef.current.find((candidate) => candidate.id === id); + if (!asset) return; + pendingEditorObjectId.current = objectId; + viewer.current?.selectParametricMapAsset(null); + viewer.current?.selectMapEditorObject(null); + editorInteraction.current?.onSelect(null); + setMapScene(placedMapAssetsRef.current, id, asset.selection); + const committedEditorDocument = readEditorDocument(asset.selection); + const previewDocument = + asset.selection.kind === 'project' + ? (editorDraftsRef.current.get(asset.selection.descriptorPath) ?? committedEditorDocument) + : null; + setEditorDocument(committedEditorDocument); + previewEditorDocument(previewDocument); + if (objectId) viewer.current?.selectMapEditorObject(objectId); + focusMapProperties(); + }, + [focusMapProperties, previewEditorDocument, readEditorDocument, setMapScene], + ); + useEffect(() => { + const interaction = (mapAssetId: string, objectId: string) => + activateMapAsset(mapAssetId, objectId); + mapEditorPreviewInteraction.current = interaction; + return () => { + if (mapEditorPreviewInteraction.current === interaction) + mapEditorPreviewInteraction.current = () => {}; + }; + }, [activateMapAsset]); + const selectCompiledEditableMapObject = useCallback( + (bodyName: string): boolean => { + const current = manifest.current; + if (!current || useAppStore.getState().loading || loadInFlight.current) return false; + const target = findCompiledEditableMapPick(bodyName, current, placedMapAssetsRef.current); + if (!target) return false; + activateMapAsset(target.mapAssetId, target.objectId); + adapter.current.setPaused(true); + useAppStore.getState().setPaused(true); + return true; + }, + [activateMapAsset], + ); + useEffect(() => { + compiledMapBodyInteraction.current = selectCompiledEditableMapObject; + return () => { + if (compiledMapBodyInteraction.current === selectCompiledEditableMapObject) + compiledMapBodyInteraction.current = () => false; + }; + }, [selectCompiledEditableMapObject]); + const selectParametricMapInViewport = useCallback( + (id: string | null) => { + if (!id) return; + const asset = placedMapAssetsRef.current.find((candidate) => candidate.id === id); + if (!asset || asset.selection.kind !== 'builtin' || asset.selection.config.preset === 'none') + return; + activateMapAsset(id); + if (activeMapAssetIdRef.current !== id) return; + viewer.current?.selectParametricMapAsset(id); + adapter.current.setPaused(true); + useAppStore.getState().setPaused(true); + useAppStore.getState().setSelection(null); + setRightOpen(true); + setModelControlsTab('map'); + }, + [activateMapAsset], + ); + const updateParametricMapTransform = useCallback( + (id: string, position: [number, number], yawDeg: number) => { + if (useAppStore.getState().loading || loadInFlight.current) return; + const next = transformParametricMapAsset(placedMapAssetsRef.current, id, position, yawDeg); + if (next === placedMapAssetsRef.current) return; + const assets = [...next]; + const active = assets.find((asset) => asset.id === id); + if (!active) return; + setMapScene(assets, id, active.selection); + setEditorDocument(null); + adapter.current.setPaused(true); + useAppStore.getState().setPaused(true); + setRightOpen(true); + setModelControlsTab('map'); + }, + [setMapScene], + ); + useEffect(() => { + const interaction = { + onSelect: selectParametricMapInViewport, + onTransform: updateParametricMapTransform, + }; + parametricMapInteraction.current = interaction; + return () => { + if (parametricMapInteraction.current === interaction) parametricMapInteraction.current = null; + }; + }, [selectParametricMapInViewport, updateParametricMapTransform]); const createEditableScene = async ( type: EditableMapObjectType, position?: [number, number, number], placementMode: MapObjectPlacementMode = 'auto_ground', + externalSupportTop = 0, ): Promise => { const current = manifest.current; const entryPath = state.selectedEntry; @@ -802,13 +1234,25 @@ export function App() { try { const maps = discoverMapEntries(files); const candidate: ProjectManifest = { ...current, files, maps, totalBytes }; - const selection: MapSelection = { kind: 'project', descriptorPath }; - pendingMapAsset.current = { type, position, placementMode }; + const selection: PlacedMapSelection = { + kind: 'project', + descriptorPath, + positionX: 0, + positionY: 0, + yawDeg: 0, + }; + const placed = createPlacedMapAsset(selection, uniqueMapAssetName(definition.name)); + pendingMapAsset.current = { type, position, placementMode, externalSupportTop }; manifest.current = candidate; - mapSelectionRef.current = selection; + provisionalMapFilesRef.current.set(descriptorPath, [ + descriptorPath, + physicsPath, + authoringPath, + ]); setProjectMaps(maps); + setCommittedEditorDocuments(manifestEditorDocuments(candidate)); + setMapScene([...placedMapAssetsRef.current, placed], placed.id, selection); setEditorDocument(document); - setMapSelection(selection); previewEditorDocument(document); state.setProject( candidate.name, @@ -830,109 +1274,231 @@ export function App() { placementMode: MapObjectPlacementMode = 'auto_ground', ) => { if (state.loading || loadInFlight.current) return; + focusMapProperties(); + const worldPosition = position; + const externalSupportTop = + position && placementMode === 'gravity' + ? (mapSceneSurfaceHeightAt(placedMapAssetsRef.current, position[0], position[1]) ?? 0) + : 0; const interaction = editorInteraction.current; if (interaction) { - interaction.onAddAsset(type, position, placementMode); + let localPosition = worldPosition; + const selection = mapSelectionRef.current; + if (worldPosition && selection.kind === 'project') + localPosition = mapWorldPointToLocal(worldPosition, selection); + interaction.onAddAsset(type, localPosition, placementMode, externalSupportTop); return; } - await createEditableScene(type, position, placementMode); + await createEditableScene(type, worldPosition, placementMode, externalSupportTop); + }; + const appendMapSelection = (selection: PlacedMapSelection, requestedName?: string): boolean => { + const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry); + if (!entry || state.loading || loadInFlight.current) return false; + if (entry.format === 'urdf' && urdfModeRef.current === 'native') { + state.setDiagnostic( + diagnostic('模型编译', new Error('原生 URDF 不能注入地图,请切换为转换模式'), entry.path), + ); + return false; + } + const name = uniqueMapAssetName(requestedName ?? selectionName(selection)); + const placed = createPlacedMapAsset(selection, name); + const assets = [...placedMapAssetsRef.current, placed]; + setMapScene(assets, placed.id, selection); + if (selection.kind === 'project') previewVisualMapScene(assets, true); + setEditorDocument(readEditorDocument(selection)); + viewer.current?.setMapEditorDocument(null); + adapter.current.setPaused(true); + useAppStore.getState().setPaused(true); + return true; + }; + const stageMapAssetRemoval = (id: string) => { + const previousAssets = placedMapAssetsRef.current; + const previousActiveId = activeMapAssetIdRef.current; + const nextAssets = previousAssets.filter((asset) => asset.id !== id); + if (nextAssets.length === previousAssets.length) return false; + const nextActive = + previousActiveId === id + ? (nextAssets.at(-1) ?? undefined) + : nextAssets.find((asset) => asset.id === previousActiveId); + setMapScene(nextAssets, nextActive?.id, nextActive?.selection); + setEditorDocument(nextActive ? readEditorDocument(nextActive.selection) : null); + viewer.current?.setMapEditorDocument(null); + return true; + }; + const removePlacedMapAsset = (id: string) => { + if (state.loading || loadInFlight.current) return; + const target = placedMapAssetsRef.current.find((asset) => asset.id === id); + if (!target || !stageMapAssetRemoval(id)) return; + if (target.selection.kind === 'project') + previewVisualMapScene(placedMapAssetsRef.current, true); + adapter.current.setPaused(true); + useAppStore.getState().setPaused(true); + }; + const commitMapScene = async ( + requestedDrafts: ReadonlyMap = editorDraftsRef.current, + ): Promise => { + const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry); + const current = manifest.current; + if (!entry || !current || state.loading || loadInFlight.current) return false; + if (entry.format === 'urdf' && urdfModeRef.current === 'native') { + state.setDiagnostic( + diagnostic('模型编译', new Error('原生 URDF 不能注入地图,请切换为转换模式'), entry.path), + ); + return false; + } + + const submittedDrafts = new Map( + [...requestedDrafts].map(([path, document]) => [path, structuredClone(document)]), + ); + let candidate: ProjectManifest; + try { + candidate = materializeEditableMapDrafts(current, submittedDrafts); + const referencedDescriptors = new Set( + placedMapAssetsRef.current.flatMap((asset) => + asset.selection.kind === 'project' ? [asset.selection.descriptorPath] : [], + ), + ); + const omittedPaths = new Set(); + for (const [descriptorPath, paths] of provisionalMapFilesRef.current) + if (!referencedDescriptors.has(descriptorPath)) + for (const path of paths) omittedPaths.add(path); + candidate = omitManifestFiles(candidate, omittedPaths); + if (candidate.totalBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes) + throw new Error('应用场景草稿后工程总大小超过 512 MiB'); + } catch (error) { + state.setDiagnostic(diagnostic('模型编译', error, entry.path)); + return false; + } + + manifest.current = candidate; + const loaded = await loadEntry(entry.path, undefined, placedMapAssetsRef.current); + if (!loaded) { + manifest.current = current; + return false; + } + + const loadedManifest = manifest.current ?? candidate; + const maps = discoverMapEntries(loadedManifest.files); + const committed: ProjectManifest = { ...loadedManifest, maps }; + manifest.current = committed; + provisionalMapFilesRef.current.clear(); + const remainingDrafts = new Map(editorDraftsRef.current); + for (const [path, submitted] of submittedDrafts) { + const latest = remainingDrafts.get(path); + if (latest && JSON.stringify(latest) === JSON.stringify(submitted)) + remainingDrafts.delete(path); + } + editorDraftsRef.current = remainingDrafts; + setEditorDrafts(remainingDrafts); + setProjectMaps(maps); + setCommittedEditorDocuments(manifestEditorDocuments(committed)); + const committedEditorDocument = readEditorDocument(mapSelectionRef.current); + const remainingEditorDraft = + mapSelectionRef.current.kind === 'project' + ? remainingDrafts.get(mapSelectionRef.current.descriptorPath) + : undefined; + setEditorDocument(committedEditorDocument); + previewEditorDocument(remainingEditorDraft ?? committedEditorDocument); + state.setProject( + committed.name, + committed.files.map((file) => ({ path: file.path, size: file.size })), + committed.entries, + entry.path, + ); + state.setSnapshot(adapter.current.snapshot() ?? undefined); + return true; + }; + const discardMapSceneDraft = () => { + if (state.loading || loadInFlight.current) return; + clearEditorDrafts(); + const omittedPaths = new Set(); + for (const paths of provisionalMapFilesRef.current.values()) + for (const path of paths) omittedPaths.add(path); + if (manifest.current && omittedPaths.size) { + const restoredManifest = omitManifestFiles(manifest.current, omittedPaths); + manifest.current = restoredManifest; + provisionalMapFilesRef.current.clear(); + setProjectMaps(restoredManifest.maps); + setCommittedEditorDocuments(manifestEditorDocuments(restoredManifest)); + state.setProject( + restoredManifest.name, + restoredManifest.files.map((file) => ({ path: file.path, size: file.size })), + restoredManifest.entries, + state.selectedEntry, + ); + } + const assets = restoreAppliedMapScene(appliedMapAssetsRef.current); + const active = + assets.find((asset) => asset.id === activeMapAssetIdRef.current) ?? assets.at(-1); + setMapScene(assets, active?.id, active?.selection); + previewVisualMapScene(assets, true); + setEditorDocument(active ? readEditorDocument(active.selection) : null); + viewer.current?.setMapEditorDocument(null); }; const applyMapSelection = (value: MapSelection) => { - const previous = mapSelectionRef.current; const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry); if (!entry) return; - if (entry.format === 'urdf' && urdfModeRef.current === 'native' && value.kind !== 'none') { + const activeId = activeMapAssetIdRef.current; + const active = activeId + ? placedMapAssetsRef.current.find((asset) => asset.id === activeId) + : undefined; + if (value.kind === 'none') { + if (!active) return; + removePlacedMapAsset(active.id); + void commitMapScene(); + return; + } + if (entry.format === 'urdf' && urdfModeRef.current === 'native') { state.setDiagnostic( diagnostic('模型编译', new Error('原生 URDF 不能注入地图,请切换为转换模式'), entry.path), ); return; } - mapSelectionRef.current = value; - setMapSelection(value); - void loadEntry(entry.path).then((loaded) => { - if (loaded) { - setEditorDocument(readEditorDocument(value)); - viewer.current?.setMapEditorDocument(null); - return; - } - mapSelectionRef.current = previous; - setMapSelection(previous); - }); + if (!active) { + if (appendMapSelection(value)) void commitMapScene(); + return; + } + const nextName = uniqueMapAssetName( + selectionName(value), + placedMapAssetsRef.current, + active.id, + ); + const nextAssets = updatePlacedMapAsset(placedMapAssetsRef.current, active.id, value, nextName); + setMapScene(nextAssets, active.id, value); + setEditorDocument(readEditorDocument(value)); + viewer.current?.setMapEditorDocument(null); + void commitMapScene(); }; - const selectTerrainAsset = (preset: SystemTerrainPreset) => { - const current = mapSelectionRef.current; - applyMapSelection({ - kind: 'builtin', - config: { - ...(current.kind === 'builtin' ? current.config : DEFAULT_PHYSICAL_MAP_CONFIG), - preset, + const selectTerrainAsset = (preset: SystemTerrainPreset, position?: [number, number, number]) => { + const positionX = position ? Math.round(position[0] * 10) / 10 : 0; + const positionY = position ? Math.round(position[1] * 10) / 10 : 0; + void appendMapSelection( + { + kind: 'builtin', + config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset, positionX, positionY }, }, - }); + PHYSICAL_MAP_PRESET_LABELS[preset], + ); + }; + const addProjectMapAsset = (descriptorPath: string, position?: [number, number, number]) => { + const map = projectMaps.find((candidate) => candidate.descriptorPath === descriptorPath); + if (!map) return; + appendMapSelection( + { + kind: 'project', + descriptorPath, + positionX: position ? Math.round(position[0] * 10) / 10 : 0, + positionY: position ? Math.round(position[1] * 10) / 10 : 0, + yawDeg: 0, + }, + map.name, + ); }; const applyEditorDocument = async (document: EditableMapDocument): Promise => { const selection = mapSelectionRef.current; - const current = manifest.current; - const entryPath = state.selectedEntry; - if (selection.kind !== 'project' || !current || !entryPath) return false; - const resolved = resolveProjectMap(current, selection.descriptorPath); - if (!resolved.authoringPath || !resolved.physicsPath) { - state.setDiagnostic( - diagnostic( - '模型编译', - new Error('可编辑地图必须同时声明 authoring.source 和 physics.source'), - ), - ); - return false; - } - try { - const authoringData = encodeEditableMapDocument(document); - const physicsData = compileEditableMapDocument(document); - const definition = decodeMapDefinition( - current.files.find((file) => file.path === resolved.descriptorPath)!.data, - ); - definition.spawnPoints = document.spawnPoints; - const descriptorData = new TextEncoder().encode(`${JSON.stringify(definition, null, 2)}\n`); - const replacements = new Map([ - [resolved.authoringPath, authoringData], - [resolved.physicsPath, physicsData], - [resolved.descriptorPath, descriptorData], - ]); - const files = current.files.map((file) => { - const data = replacements.get(file.path); - return data ? { ...file, data, size: data.byteLength } : file; - }); - const candidate: ProjectManifest = { - ...current, - files, - maps: discoverMapEntries(files), - totalBytes: files.reduce((total, file) => total + file.size, 0), - }; - manifest.current = candidate; - const loaded = await loadEntry(entryPath); - if (!loaded) { - manifest.current = current; - return false; - } - const loadedManifest = manifest.current ?? candidate; - const maps = discoverMapEntries(loadedManifest.files); - const committed = { ...loadedManifest, maps }; - manifest.current = committed; - setProjectMaps(maps); - setEditorDocument(document); - viewer.current?.setMapEditorDocument(null); - state.setProject( - committed.name, - committed.files.map((file) => ({ path: file.path, size: file.size })), - committed.entries, - entryPath, - ); - state.setSnapshot(adapter.current.snapshot() ?? undefined); - return true; - } catch (error) { - manifest.current = current; - state.setDiagnostic(diagnostic('模型编译', error, resolved.authoringPath)); - return false; - } + if (selection.kind !== 'project' || !manifest.current || !state.selectedEntry) return false; + updateEditorDraft(selection.descriptorPath, document, true); + return commitMapScene(new Map(editorDraftsRef.current)); }; const convertSelectedMap = async (): Promise => { const selection = mapSelectionRef.current; @@ -1003,6 +1569,7 @@ export function App() { const committed = { ...loadedManifest, maps }; manifest.current = committed; setProjectMaps(maps); + setCommittedEditorDocuments(manifestEditorDocuments(committed)); setEditorDocument(document); state.setProject( committed.name, @@ -1044,44 +1611,46 @@ export function App() { }; const dragEnter = (event: DragEvent) => { if ( - event.dataTransfer.types.includes('Files') && - !event.dataTransfer.types.includes(MAP_ASSET_DRAG_MIME) + hasTransferType(event.dataTransfer, 'Files') && + !hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME) ) { dragDepth.current += 1; if (!state.loading) setDragActive(true); } }; const dragLeave = (event: DragEvent) => { - if (!event.dataTransfer.types.includes('Files')) return; + if (!hasTransferType(event.dataTransfer, 'Files')) return; dragDepth.current = Math.max(0, dragDepth.current - 1); if (dragDepth.current === 0) setDragActive(false); }; const dragOver = (event: DragEvent) => { event.preventDefault(); - if (event.dataTransfer.types.includes(MAP_ASSET_DRAG_MIME)) { + if (hasTransferType(event.dataTransfer, MAP_LIBRARY_DRAG_MIME)) { event.dataTransfer.dropEffect = viewer.current?.mapPlanePoint(event.clientX, event.clientY) ? 'copy' : 'none'; return; } - if (event.dataTransfer.types.includes('Files')) + if (hasTransferType(event.dataTransfer, 'Files')) event.dataTransfer.dropEffect = state.loading ? 'none' : 'copy'; }; const drop = (event: DragEvent) => { event.preventDefault(); resetDragState(); - const assetType = event.dataTransfer.getData(MAP_ASSET_DRAG_MIME), - requestedPlacement = event.dataTransfer.getData(MAP_ASSET_PLACEMENT_MIME), - placementMode = isMapObjectPlacementMode(requestedPlacement) - ? requestedPlacement - : 'auto_ground'; - if (isEditableMapObjectType(assetType)) { + const mapAsset = decodeMapLibraryDragPayload(event.dataTransfer.getData(MAP_LIBRARY_DRAG_MIME)); + if (mapAsset) { event.stopPropagation(); const position = viewer.current?.mapPlanePoint(event.clientX, event.clientY); - if (position) void addCertifiedMapAsset(assetType, position, placementMode); + if (!position) return; + if (mapAsset.kind === 'certified') + void addCertifiedMapAsset(mapAsset.type, position, mapAsset.placementMode); + else if (mapAsset.kind === 'terrain') selectTerrainAsset(mapAsset.preset, position); + else addProjectMapAsset(mapAsset.descriptorPath, position); return; } if (state.loading || importInFlight.current) return; + // 必须在 drop 用户手势仍有效时读取句柄;Chromium 随后会清空 DataTransfer。 + const filesPromise = filesFromDrop(event.dataTransfer.items, event.dataTransfer.files); importInFlight.current = true; state.setLoading(true); setImportProgress({ @@ -1091,7 +1660,7 @@ export function App() { }); void (async () => { try { - const files = await filesFromDrop(event.dataTransfer.items, event.dataTransfer.files); + const files = await filesPromise; await ingest(files, true); } catch (error) { importInFlight.current = false; @@ -1660,7 +2229,17 @@ export function App() { loading={state.loading} nativeUrdf={selectedFormat === 'urdf' && urdfMode === 'native'} mapSelection={mapSelection} - editorDocument={editorDocument} + maps={projectMaps} + placedMaps={placedMapAssets} + activeMapId={activeMapAssetId} + pendingSceneChangeCount={sceneDraftChangeCount} + pendingSceneIds={pendingSceneIds} + editorDocument={ + mapSelection.kind === 'project' + ? (editorDrafts.get(mapSelection.descriptorPath) ?? editorDocument) + : editorDocument + } + editorDocuments={sceneEditorDocuments} activeTab={projectSidebarTab} onActiveTabChange={setProjectSidebarTab} onRemove={removeProject} @@ -1669,11 +2248,13 @@ export function App() { onAddMapAsset={(type, placementMode) => addCertifiedMapAsset(type, undefined, placementMode) } + onAddProjectMap={addProjectMapAsset} onSelectTerrain={selectTerrainAsset} - onSelectMapObject={(id) => { - editorInteraction.current?.onSelect(id); - selectEditorObject(id); - }} + onApplyScene={() => void commitMapScene()} + onDiscardScene={discardMapSceneDraft} + onSelectMap={activateMapAsset} + onRemoveMap={removePlacedMapAsset} + onSelectMapObject={(mapId, objectId) => activateMapAsset(mapId, objectId)} />
@@ -1741,6 +2322,8 @@ export function App() {
{ setShowVisualMap(visual); setShowMapCollision(collision); diff --git a/web_platform/src/app/components/ModelControlsSidebar.tsx b/web_platform/src/app/components/ModelControlsSidebar.tsx index 8583c6ba..19d2acd9 100644 --- a/web_platform/src/app/components/ModelControlsSidebar.tsx +++ b/web_platform/src/app/components/ModelControlsSidebar.tsx @@ -16,7 +16,7 @@ import { Select, Tabs, } from '../../components/ui'; -import type { MapSelection } from '../../map/types'; +import type { MapSelection, PlacedMapSelection } from '../../map/types'; import type { EditableMapDocument, MapEditorInteractionCallbacks, @@ -50,10 +50,15 @@ interface ModelControlsProps { selectedPolicyPath?: string; policyStatus?: RLPolicyStatus; mapSelection: MapSelection; + activeMapAssetId?: 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; onUrdfMode: (value: UrdfLoadMode) => void; onBaseMode: (value: UrdfBaseMode) => void; onShowCollision: (value: boolean) => void; @@ -78,7 +83,13 @@ interface ModelControlsProps { onPolicyCommand: (command: RLCommand) => void; onRemovePolicy: () => void; onApplyMap: (value: MapSelection) => void; + onMapDraft: (value: PlacedMapSelection) => void; onEditorPreview: (document: EditableMapDocument | null) => void; + onEditorDraftChange: ( + descriptorPath: string, + document: EditableMapDocument, + dirty: boolean, + ) => void; onEditorApply: (document: EditableMapDocument) => Promise; onEditorExport: () => void; onEditorConvert: () => Promise; @@ -86,6 +97,7 @@ interface ModelControlsProps { onEditorSelect: (id: string | null) => void; onEditorTransformMode: (mode: MapEditorTransformMode) => void; onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void; + onEditorSurfaceHeight?: (position: readonly [number, number, number]) => number | null; onMapDisplay: (visual: boolean, collision: boolean) => void; onMapTabOpen?: () => void; onDataRecorderConfigure: (patch: Partial) => void; @@ -95,7 +107,10 @@ interface ModelControlsProps { onDataRecordingExport: (format: 'csv' | 'json') => void; } export function ModelControlsSidebar(props: ModelControlsProps) { - const [tab, setTab] = useState<'properties' | 'controls' | 'data' | 'map'>('properties'), + const [internalTab, setInternalTab] = useState<'properties' | 'controls' | 'data' | 'map'>( + 'properties', + ), + tab = props.activeTab ?? internalTab, s = props.snapshot; if (!s) return ( @@ -315,7 +330,8 @@ export function ModelControlsSidebar(props: ModelControlsProps) { label="模型控制侧栏" value={tab} onValueChange={(value) => { - setTab(value); + setInternalTab(value); + props.onActiveTabChange?.(value); if (value === 'map') props.onMapTabOpen?.(); }} items={[ @@ -353,7 +369,11 @@ export function ModelControlsSidebar(props: ModelControlsProps) { icon: , content: ( ), diff --git a/web_platform/src/app/components/ProjectSidebar.tsx b/web_platform/src/app/components/ProjectSidebar.tsx index cf3b3b66..2bab8f83 100644 --- a/web_platform/src/app/components/ProjectSidebar.tsx +++ b/web_platform/src/app/components/ProjectSidebar.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { Box, FolderTree, Map as MapIcon } from 'lucide-react'; -import type { ModelEntry } from '../../project/types'; +import type { MapEntry, ModelEntry } from '../../project/types'; import { countProjectSearchResults, ProjectTree, @@ -15,6 +15,7 @@ import { Button, Tabs } from '../../components/ui'; import { DEFAULT_PHYSICAL_MAP_CONFIG, type MapSelection, + type PlacedMapAsset, type SystemTerrainPreset, } from '../../map/types'; import type { @@ -37,14 +38,25 @@ export function ProjectSidebar({ visible = true, nativeUrdf, mapSelection, + maps, + placedMaps, + activeMapId, + pendingSceneChangeCount, + pendingSceneIds, editorDocument, + editorDocuments, activeTab, onActiveTabChange, onRemove, onSelectEntry, onJointHover, onAddMapAsset, + onAddProjectMap, onSelectTerrain, + onApplyScene, + onDiscardScene, + onSelectMap, + onRemoveMap, onSelectMapObject, }: { projectName?: string; @@ -56,7 +68,13 @@ export function ProjectSidebar({ 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; onRemove: () => void; @@ -66,8 +84,13 @@ export function ProjectSidebar({ type: EditableMapObjectType, placementMode: MapObjectPlacementMode, ) => void | Promise; + onAddProjectMap: (descriptorPath: string) => void; onSelectTerrain: (preset: SystemTerrainPreset) => void; - onSelectMapObject: (id: string) => 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(''), @@ -173,8 +196,19 @@ export function ProjectSidebar({ : DEFAULT_PHYSICAL_MAP_CONFIG.size } document={editorDocument} + documents={editorDocuments} + maps={maps} + placedMaps={placedMaps} + activeMapId={activeMapId} + pendingSceneChangeCount={pendingSceneChangeCount} + pendingSceneIds={pendingSceneIds} onAdd={onAddMapAsset} + onAddProjectMap={onAddProjectMap} onSelectTerrain={onSelectTerrain} + onApplyScene={onApplyScene} + onDiscardScene={onDiscardScene} + onSelectMap={onSelectMap} + onRemoveMap={onRemoveMap} onSelectObject={onSelectMapObject} /> ), diff --git a/web_platform/src/map/MapAssetLibrary.test.tsx b/web_platform/src/map/MapAssetLibrary.test.tsx index 9cdebb60..2ad26def 100644 --- a/web_platform/src/map/MapAssetLibrary.test.tsx +++ b/web_platform/src/map/MapAssetLibrary.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react'; -import { MAP_ASSET_DRAG_MIME, MAP_ASSET_PLACEMENT_MIME } from './editor/assetCatalog'; +import { decodeMapLibraryDragPayload, MAP_LIBRARY_DRAG_MIME } from './editor/assetCatalog'; import { MapAssetLibrary } from './MapAssetLibrary'; const renderLibrary = ( @@ -16,6 +16,162 @@ const renderLibrary = ( ); describe('MapAssetLibrary', () => { + it('把全部来源呈现在同一个放置与应用工作流中', () => { + expect(decodeMapLibraryDragPayload('not-json')).toBeNull(); + expect(decodeMapLibraryDragPayload('{"kind":"terrain","preset":"unknown"}')).toBeNull(); + renderLibrary(); + expect(screen.getByLabelText('地图资产库')).toHaveTextContent('统一地图资产库'); + expect(screen.getByLabelText('地图资产库')).toHaveTextContent('放置 → 预览 → 一次应用'); + }); + + it('显示已放置地图树并支持选择、删除和重复添加工程地图源', () => { + const onSelectMap = vi.fn(); + const onRemoveMap = vi.fn(); + const onAddProjectMap = vi.fn(); + render( + {}} + onAddProjectMap={onAddProjectMap} + onSelectTerrain={() => {}} + onSelectMap={onSelectMap} + onRemoveMap={onRemoveMap} + />, + ); + expect(screen.getByLabelText('场景资产树')).toHaveTextContent('2 个实例'); + fireEvent.click(screen.getByRole('button', { name: '选择地图实例 粗糙地形' })); + fireEvent.click(screen.getByRole('button', { name: '删除地图实例 仓库' })); + fireEvent.click(screen.getByRole('button', { name: '放置工程地图 仓库' })); + expect(onSelectMap).toHaveBeenCalledWith('map-b'); + expect(onRemoveMap).toHaveBeenCalledWith('map-a'); + expect(onAddProjectMap).toHaveBeenCalledWith('maps/warehouse/map.json'); + }); + + it('场景树为所有地图实例展示认证对象并可跨实例直接选择', () => { + const onSelectObject = vi.fn(); + const editable = { + schemaVersion: 1 as const, + mapId: 'warehouse', + revision: 0, + objects: [ + { + id: 'box-1', + name: '共享方盒', + type: 'box' as const, + pose: { + position: [0, 0, 0.5] as [number, number, number], + quaternion: [1, 0, 0, 0] as [number, number, number, number], + }, + parameters: { sizeX: 1, sizeY: 1, sizeZ: 1 }, + friction: [1, 0.005, 0.0001] as [number, number, number], + rgba: [0.5, 0.6, 0.7, 1] as [number, number, number, number], + placementMode: 'auto_ground' as const, + enabled: true, + }, + ], + spawnPoints: [], + }; + render( + {}} + onSelectTerrain={() => {}} + onSelectObject={onSelectObject} + />, + ); + expect(screen.getByLabelText('场景资产树')).toHaveTextContent('2 个实例 · 2 个对象'); + fireEvent.click(screen.getAllByRole('button', { name: /共享方盒/ })[1]); + expect(onSelectObject).toHaveBeenCalledWith('map-b', 'box-1'); + }); + + it('工程地图源使用与资产卡片一致的拖放入口', () => { + const values = new Map(); + const dataTransfer = { + effectAllowed: 'none', + setData: (type: string, value: string) => values.set(type, value), + } as unknown as DataTransfer; + render( + {}} + onSelectTerrain={() => {}} + />, + ); + const source = document.querySelector('[data-project-map="maps/warehouse/map.json"]')!; + expect(source).toHaveAttribute('draggable', 'true'); + fireEvent.dragStart(source, { dataTransfer }); + expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({ + kind: 'project', + descriptorPath: 'maps/warehouse/map.json', + }); + expect(values.get('text/plain')).toBe('仓库'); + }); + it('按所选放置方式添加认证资产', () => { const onAdd = vi.fn(); renderLibrary(onAdd); @@ -37,8 +193,11 @@ describe('MapAssetLibrary', () => { target: { value: 'locked' }, }); fireEvent.dragStart(document.querySelector('[data-map-asset="ramp"]')!, { dataTransfer }); - expect(values.get(MAP_ASSET_DRAG_MIME)).toBe('ramp'); - expect(values.get(MAP_ASSET_PLACEMENT_MIME)).toBe('locked'); + expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({ + kind: 'certified', + type: 'ramp', + placementMode: 'locked', + }); }); it('以示意图卡片选择系统参数化地形', () => { @@ -48,4 +207,69 @@ describe('MapAssetLibrary', () => { fireEvent.click(screen.getByRole('button', { name: '添加随机粗糙地形' })); expect(onSelectTerrain).toHaveBeenCalledWith('rough'); }); + + it('可把参数化地形拖到画布,并写入独立拖放类型', () => { + const values = new Map(); + const dataTransfer = { + effectAllowed: 'none', + setData: (type: string, value: string) => values.set(type, value), + } as unknown as DataTransfer; + renderLibrary(); + const terrain = document.querySelector('[data-system-terrain="rough"]')!; + expect(terrain).toHaveAttribute('draggable', 'true'); + fireEvent.dragStart(terrain, { dataTransfer }); + expect(decodeMapLibraryDragPayload(values.get(MAP_LIBRARY_DRAG_MIME) ?? '')).toEqual({ + kind: 'terrain', + preset: 'rough', + }); + expect(values.get('text/plain')).toBe('随机粗糙地形'); + }); + + it('集中显示认证资产与参数地形更改,并只触发一次场景编译', () => { + const onApplyScene = vi.fn(); + const onDiscardScene = vi.fn(); + render( + {}} + onSelectTerrain={() => {}} + onApplyScene={onApplyScene} + onDiscardScene={onDiscardScene} + />, + ); + expect(screen.getByRole('status')).toHaveTextContent('3 项场景更改待应用'); + expect(screen.getByRole('status')).toHaveTextContent('认证资产与参数地形共享同一草稿'); + expect(screen.getByLabelText('场景资产树')).toHaveTextContent('待应用'); + fireEvent.click(screen.getByRole('button', { name: '一次编译应用' })); + fireEvent.click(screen.getByRole('button', { name: '放弃场景更改' })); + expect(onApplyScene).toHaveBeenCalledTimes(1); + expect(onDiscardScene).toHaveBeenCalledTimes(1); + }); }); diff --git a/web_platform/src/map/MapAssetLibrary.tsx b/web_platform/src/map/MapAssetLibrary.tsx index 90abdf9f..e873b281 100644 --- a/web_platform/src/map/MapAssetLibrary.tsx +++ b/web_platform/src/map/MapAssetLibrary.tsx @@ -1,10 +1,20 @@ import { useState } from 'react'; -import { BadgeCheck, CheckCircle2, Mountain, Plus } from 'lucide-react'; +import { + BadgeCheck, + CheckCircle2, + Layers3, + MapPinned, + Mountain, + Plus, + RotateCcw, + Trash2, + Zap, +} from 'lucide-react'; import { Button, Select } from '../components/ui'; import { CERTIFIED_MAP_ASSETS, - MAP_ASSET_DRAG_MIME, - MAP_ASSET_PLACEMENT_MIME, + encodeMapLibraryDragPayload, + MAP_LIBRARY_DRAG_MIME, } from './editor/assetCatalog'; import { MAP_OBJECT_PLACEMENT_LABELS, @@ -12,9 +22,11 @@ import { type EditableMapObjectType, type MapObjectPlacementMode, } from './editor/types'; +import type { MapEntry } from '../project/types'; import { PHYSICAL_MAP_PRESET_LABELS, SYSTEM_TERRAIN_PRESETS, + type PlacedMapAsset, type SystemTerrainPreset, } from './types'; @@ -136,52 +148,224 @@ export function MapAssetLibrary({ disabled, terrainSize, document, + documents, + maps = [], + placedMaps = [], + activeMapId, + pendingSceneChangeCount = 0, + pendingSceneIds = [], onAdd, + onAddProjectMap, onSelectTerrain, + onApplyScene, + onDiscardScene, + onSelectMap, + onRemoveMap, onSelectObject, }: { disabled: boolean; terrainSize: number; document?: EditableMapDocument | null; + documents?: ReadonlyMap; + maps?: MapEntry[]; + placedMaps?: PlacedMapAsset[]; + activeMapId?: string; + pendingSceneChangeCount?: number; + pendingSceneIds?: string[]; onAdd: ( type: EditableMapObjectType, placementMode: MapObjectPlacementMode, ) => void | Promise; + onAddProjectMap?: (descriptorPath: string) => void; onSelectTerrain: (preset: SystemTerrainPreset) => void; - onSelectObject?: (id: string) => void; + onApplyScene?: () => void; + onDiscardScene?: () => void; + onSelectMap?: (id: string) => void; + onRemoveMap?: (id: string) => void; + onSelectObject?: (mapId: string, objectId: string) => void; }) { const [placementMode, setPlacementMode] = useState('auto_ground'); + const pendingSceneIdSet = new Set(pendingSceneIds); + const placedObjectCount = placedMaps.reduce( + (total, map) => + total + + (map.selection.kind === 'project' + ? (documents?.get(map.selection.descriptorPath)?.objects.length ?? 0) + : 0), + 0, + ); return ( -
-
+
+
+
+
+

+ 工程地图、认证资产和程序地形统一执行“放置 → 预览 → 一次应用”,不会因资产类型切换编译边界。 +

+
+
- 场景结构 + + - {document?.objects.length ?? 0} 个对象 + {placedMaps.length} 个实例 + {placedObjectCount ? ` · ${placedObjectCount} 个对象` : ''}
- {document?.objects.length ? ( -
- {document.objects.map((object) => ( - - ))} + {placedMaps.length ? ( +
+ {placedMaps.map((map) => { + const active = map.id === activeMapId; + const mapDocument = + map.selection.kind === 'project' + ? (documents?.get(map.selection.descriptorPath) ?? (active ? document : null)) + : null; + return ( +
+
+ + +
+ {mapDocument?.objects.length ? ( +
+ {mapDocument.objects.map((object) => ( + + ))} +
+ ) : null} +
+ ); + })}
) : (

- 选择工程地图后,这里显示场景对象层级;可从下方资产库创建场景。 + 尚未放置地图。从下方工程地图、认证资产或参数地形添加,实例会在这里统一管理。

)}
+ {pendingSceneChangeCount > 0 && ( +
+
+
+

+ 工程地图、认证资产与参数地形共享同一草稿;继续编辑不会触发 MuJoCo 编译。 +

+
+ + +
+
+ )} + + {maps.length > 0 && ( +
+
工程地图源
+

+ 点击添加或拖到画布定位;实例位姿独立,源内容由同源实例共享。 +

+
+ {maps.map((map) => ( +
{ + if (disabled) { + event.preventDefault(); + return; + } + event.dataTransfer.effectAllowed = 'copy'; + event.dataTransfer.setData( + MAP_LIBRARY_DRAG_MIME, + encodeMapLibraryDragPayload({ + kind: 'project', + descriptorPath: map.descriptorPath, + }), + ); + event.dataTransfer.setData('text/plain', map.name); + }} + > +
+ ))} +
+
+ )} +
@@ -222,8 +406,14 @@ export function MapAssetLibrary({ return; } event.dataTransfer.effectAllowed = 'copy'; - event.dataTransfer.setData(MAP_ASSET_DRAG_MIME, asset.type); - event.dataTransfer.setData(MAP_ASSET_PLACEMENT_MIME, placementMode); + event.dataTransfer.setData( + MAP_LIBRARY_DRAG_MIME, + encodeMapLibraryDragPayload({ + kind: 'certified', + type: asset.type, + placementMode, + }), + ); event.dataTransfer.setData('text/plain', asset.name); }} > @@ -257,15 +447,28 @@ export function MapAssetLibrary({ 系统参数化地形

- 本地确定性生成;点击“添加”后在右侧“地图”调整尺寸、难度和随机种子。 + 点击添加或拖到画布定位。新增先进入轻量预览,可连续放置后一次编译。

{SYSTEM_TERRAIN_PRESETS.map((preset) => (
{ + if (disabled) { + event.preventDefault(); + return; + } + event.dataTransfer.effectAllowed = 'copy'; + event.dataTransfer.setData( + MAP_LIBRARY_DRAG_MIME, + encodeMapLibraryDragPayload({ kind: 'terrain', preset }), + ); + event.dataTransfer.setData('text/plain', PHYSICAL_MAP_PRESET_LABELS[preset]); + }} >