From 74afd84240709eec0034d32e0a73bac9a8031057 Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Thu, 11 Jun 2026 22:16:08 +0800 Subject: [PATCH 01/11] =?UTF-8?q?docs(spec):=20v1.0=20sub-stage=20B2=20?= =?UTF-8?q?=E2=80=94=20=E6=8B=BE=E5=8F=96=20+=20=E9=80=89=E4=B8=AD?= =?UTF-8?q?=E6=8F=8F=E8=BE=B9=E8=B7=A8=E5=BC=95=E6=93=8E=20=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../2026-06-11-v1.0b2-pick-outline-design.md | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-11-v1.0b2-pick-outline-design.md diff --git a/docs/superpowers/specs/2026-06-11-v1.0b2-pick-outline-design.md b/docs/superpowers/specs/2026-06-11-v1.0b2-pick-outline-design.md new file mode 100644 index 0000000..d209b6c --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-v1.0b2-pick-outline-design.md @@ -0,0 +1,113 @@ +# v1.0 sub-stage B2 — 拾取 + 选中描边跨引擎 设计 + +日期:2026-06-11 +分支:`feat/v1.0b2-pick-outline` +前置:B1(PR #39,render host 实时切引擎「看+转相机」) + +--- + +## 1. 背景与目标 + +B1 之后 Babylon 视口只能看和转相机:视口点选无效(`BabylonAdapter.pickAt` 抛 `NotImplementedYet`)、选中无视觉反馈(层级选中只在面板可见)。 + +**B2 目标**:Babylon 模式视口可点选(含空白清选),选中节点有蓝色高亮;拾取语义跨引擎进 conformance;ThreeViewport 开始最小收敛(迁 `diffSceneNodes`)。 + +### 已与用户确认的决策 + +- **ThreeViewport 收敛幅度 = 最小(方案 A)**:只把内联 `diffAndApply` 的节点遍历迁到 B1 的 `diffSceneNodes` 纯函数;Three 的拾取/描边原样不动。`IRenderHost` 的 B2 扩展本期仍只有 Babylon 实现(B1 先例);抽 `ThreeRenderHost` 留到 B3 连 gizmo 一起(gizmo/snap 与拾取/描边在 ThreeViewport 内互相缠绕,分两次抽会切出别扭的中间态)。 +- **Babylon 选中视觉 = `HighlightLayer`(方案 A)**:蓝色描边光晕,最接近 Three OutlinePass 的"选中标记"语义;多网格(group/prefab)可整体高亮。边缘轻微 glow 与 Three 的无 glow 细边不逐像素一致——B 阶段已接受的跨引擎观感差异,真机 smoke 可调 blur 收细。已否决:`renderOutline`(凹面破面、多网格逐个开)、`EdgesRenderer`(线框感,偏离"选中"语义)。 + +--- + +## 2. 范围 + +### In + +- `BabylonAdapter.pickAt` 实现 + `metadata.nodeId` 标记约定 + 默认编辑器相机。 +- `IRenderHost.setSelection(node_id)` + `BabylonRenderHost` HighlightLayer 实现。 +- `BabylonViewport` 点选接线(click guard)+ 选中高亮订阅 + diff 后重放。 +- conformance 拾取对等断言。 +- ThreeViewport `diffAndApply` 迁 `diffSceneNodes`(仅遍历逻辑)。 + +### Out(延后) + +- gizmo / snap 跨引擎、抽 `ThreeRenderHost`(B3)。 +- socket 标记、资源拖放落点(`raycastGroundPoint`)、F focus、play 行为预览(B4)。 +- hover-highlight 行为的 Babylon 事件接线(独立于选中描边,后续)。 +- Babylon 底色 sRGB / 光强观感对齐(B4,roadmap 已记)。 + +--- + +## 3. `BabylonAdapter.pickAt` + +替换现 `notImplemented("pickAt")`(`src/runtime/babylon/adapter.ts:204`)。 + +- **语义镜像 Three**(`src/runtime/three/adapter.ts` pickAt):视口像素坐标 → 拾取 → 沿命中对象父链上溯找 nodeId → 空白 null。 +- **节点标记**:`syncNode("add")` 创建引擎对象时设 `metadata = { ...existing, nodeId }`(镜像 Three 的 `userData.nodeId`)。上溯函数 `findNodeId(babylonNode)`:查自身/父链 `metadata.nodeId`。 +- **实现**:`scene.pick(x, y)`(Babylon 用 `engine.getRenderWidth/Height` 反投影,坐标空间 = engine render size;真 Engine 即 canvas 像素,与 Three 的 viewport-pixel 契约一致)。命中取 `pickedMesh` 上溯;`hit.pickedMesh == null` 或无 nodeId → null。 +- **默认编辑器相机**:构造函数建 `UniversalCamera` 于 `[4,3,4]`、`setTarget(Vector3.Zero())`、fov 50°(弧度换算),设 `scene.activeCamera`——**镜像 ThreeAdapter 的 defaultCamera 约定**。`scene.pick` 依赖活动相机;这同时让 conformance 能对两引擎用同构相机断言拾取对等。`BabylonRenderHost.mount` 的 ArcRotateCamera 照旧接管 activeCamera(默认相机保留不删,无副作用;dispose 由 scene.dispose 级联)。 +- headless 测试:经 B1 注入口 `new BabylonAdapter({ engine: new NullEngine({ renderWidth, renderHeight }) })` 控制拾取坐标空间。 +- helper/未知 kind 的占位 `TransformNode` 不可拾取(TransformNode 本身无几何,天然不被 scene.pick 命中——与 Three 的 helper raycast-unpickable 约定等效;若发现占位对象意外可拾取,显式 `isPickable = false`)。 + +## 4. `IRenderHost.setSelection` + `BabylonRenderHost` 高亮 + +`src/runtime/render-host.ts` 接口追加: + +```ts + /** Mark the node as visually selected (engine-specific highlight), or + * clear the marker when null. Idempotent — callers may replay after + * scene diffs. */ + setSelection(node_id: string | null): void; +``` + +`BabylonRenderHost`: + +- `mount` 建 `HighlightLayer`(`visibleEdgeColor` 对齐 Three 的 `#3b82f6`;`Color3.FromHexString`)。 +- `setSelection(id)`:`removeAllMeshes()` → id 非 null 时取 `adapter.getRuntimeObject(id)`,对该节点及其后代中所有 `Mesh` 逐个 `addMesh(mesh, color)`(覆盖 group/prefab 多网格);id 为 null 或对象不存在 → 仅清层。**幂等**,调用方可在 diff 后重放。 +- `dispose`:高亮层 → 相机 → adapter(含 engine)。 +- **NullEngine 风险预案**:HighlightLayer 依赖 shader/render target,headless 可能不可用。若单测中创建抛错:按 B1 attachControl 同款守卫——`engine.getRenderingCanvas()` 非 null 才建层,`setSelection` 在无层时静默 no-op;单测覆盖 no-op 路径,真实高亮靠视觉 smoke 验收。spec 接受该降级(headless 不渲染,高亮本就不可观测)。 + +## 5. `BabylonViewport` 接线 + +- **点选**(镜像 ThreeViewport PR #8 约定):canvas `pointerdown` 记 downX/downY;`click` 中位移² > 25px² 则跳过(drag-after-click 防劫持);否则 `adapter.pickAt(clientX - rect.left, clientY - rect.top)` → `setSelectedNodeId(...)`(空白命中 = null 清选)。play 态检查不需要(Babylon 模式 play 被 B1 禁用)。 +- **高亮同步**:订阅 `useUIStore`,`selectedNodeId` 变化 → `host.setSelection(id)`。 +- **挂载初始**:种场景完成后同步一次当前选中(**B1 竞态教训**:`seeded.then(...)` 模式,带 unmounted 守卫——挂载同步段 adapter 还没对象)。 +- **diff 后重放**:每次 `applyDiff` 后 `host.setSelection(当前选中)`——节点删除/重建后高亮不悬空(setSelection 幂等 + 对象缺失时清层,删除选中节点自然落到清层分支)。 + +## 6. ThreeViewport 最小收敛(方案 A) + +`diffAndApply`(`src/ui/viewport/ThreeViewport.tsx`)内联 BFS 遍历替换为 `diffSceneNodes(old.scene, next.scene)`,按 added → updated → removed 应用: + +- **保留**:新资产预载(diff 前 `syncAndPublishPreview`)、removed 分支中"被删节点是 gizmo 附着对象 → detach、在 outline 选中集 → 清空"的现有处理(对 removed 数组逐个做,语义不变)。 +- **不动**:拾取、描边、gizmo、snap、socket 标记、focus——B3 抽 `ThreeRenderHost` 时再收敛。 +- 两视口自此对同一 store 变更共享同一 diff 语义实现(B1 设计意图兑现)。 + +## 7. 禁用面变化 + +- B1 的「Babylon 模式点视口不改选中」翻转为可点选:BabylonViewport 自带 click handler,**不经** `isEngineEditingCapable`(该门继续管 play / gizmo pill / Space / F / 资源拖拽,本期一律不动)。 +- gizmo pill 在 Babylon 模式仍禁用(有选中也禁,B3 放开)。 +- B1 spec §7 的"视口无描边反馈"自此失效(由本 spec 取代)。 + +## 8. 测试策略 + +- **conformance 扩拾取对等**(`conformance-suite.ts` 追加):双适配器同构场景——box mesh 于原点中心(transform position `[0,0,0]`,两引擎几何均以中心建箱,相机视线穿过箱体内部、不擦边)、默认编辑器相机、视口 800×600(Three `setViewportSize`;Babylon 注入 `NullEngine({renderWidth:800, renderHeight:600})`): + - `pickAt(400, 300)`(中心)→ box 的 node id; + - `pickAt(10, 10)`(角落天空)→ null; + - remove 后再 pick → null。 +- **BabylonAdapter 单测**:metadata.nodeId 标记存在;group 子 mesh 命中上溯到子 mesh 自身 id(与 Three 一致:最近注册节点);helper 占位不可拾取。 +- **BabylonRenderHost 单测**(NullEngine):setSelection 幂等、null 清层、未知 id 清层、dispose 不抛;HighlightLayer 不可用时降级 no-op 路径。 +- **ThreeViewport 迁移回归**:无组件测试(jsdom 无 WebGL),靠既有全量 vitest(diffSceneNodes 单测已在)+ 视觉 smoke。 +- **视觉 smoke(必跑)**:Babylon 点 cube → 蓝高亮 + 面板/层级同步;点空白清选;层级选中 → 视口高亮跟随;切引擎来回高亮状态正确(B1 的 seed 后重放保证);Three 模式拾取/描边/gizmo/撤销/拖放零回归。 + +## 9. 成功标准 + +1. conformance 拾取对等断言双引擎全绿,既有套件零改动全绿。 +2. Babylon 模式:视口点选/清选可用,选中(视口点选或层级面板)有蓝色高亮。 +3. Three 模式零回归(diff 迁移后编辑路径行为不变)。 +4. `pnpm lint && pnpm typecheck && pnpm test` 全绿。 +5. 视觉 smoke(§8)通过。 + +## 10. 后续路径 + +- **B3**:gizmo + snap 跨引擎;抽 `ThreeRenderHost`(连拾取/描边一起进 IRenderHost 双实现)。 +- **B4**:socket 标记 / 拖放落点 / F focus / play 行为预览 / 底色与光强观感对齐。 From 6b229ea238affe11eaae3cfd0dd164d944241d87 Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Thu, 11 Jun 2026 22:50:30 +0800 Subject: [PATCH 02/11] =?UTF-8?q?docs(plan):=20v1.0=20sub-stage=20B2=20?= =?UTF-8?q?=E2=80=94=20=E6=8B=BE=E5=8F=96+=E9=80=89=E4=B8=AD=E6=8F=8F?= =?UTF-8?q?=E8=BE=B9=20=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../plans/2026-06-11-v1.0b2-pick-outline.md | 757 ++++++++++++++++++ 1 file changed, 757 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-v1.0b2-pick-outline.md diff --git a/docs/superpowers/plans/2026-06-11-v1.0b2-pick-outline.md b/docs/superpowers/plans/2026-06-11-v1.0b2-pick-outline.md new file mode 100644 index 0000000..5a30b16 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-v1.0b2-pick-outline.md @@ -0,0 +1,757 @@ +# v1.0 B2 — 拾取 + 选中描边跨引擎 实现计划 + +> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 + +**目标:** Babylon 模式视口可点选(空白清选)+ 选中蓝色高亮;拾取语义进 conformance 跨引擎对等;ThreeViewport 的 diff 遍历迁到 `diffSceneNodes`。 + +**架构:** `BabylonAdapter.pickAt` 用 `scene.pick` + `metadata.nodeId` 父链上溯(A1 已埋标记,无需新增)+ 构造时建默认编辑器相机(镜像 ThreeAdapter defaultCamera,[4,3,4]/fov50°)。`IRenderHost` 加 `setSelection(node_id)`,`BabylonRenderHost` 用 `HighlightLayer`(#3b82f6)实现。`BabylonViewport` 镜像 ThreeViewport 的 click-vs-drag guard 接线点选 + 订阅选中。ThreeViewport 仅迁 diff 遍历(方案 A 最小收敛)。规格:`docs/superpowers/specs/2026-06-11-v1.0b2-pick-outline-design.md`。 + +**技术栈:** `@babylonjs/core` 9.11(`scene.pick` / `HighlightLayer` / `UniversalCamera`)+ vitest(headless 全经 `NullEngine({renderWidth, renderHeight})` 注入)。 + +**计划期已实证的事实(不要再怀疑,直接依赖):** + +- 定尺寸 NullEngine 下 `scene.pick(400,300)` 命中原点 box、`(10,10)` 落空——**无需** `scene.render()` 或手动矩阵刷新(Babylon 惰性重算)。 +- `HighlightLayer` 在 NullEngine 下 new/addMesh/removeAllMeshes/dispose 全部正常——spec §4 的降级预案**不需要**,直接建层。 +- `BabylonAdapter` 的引擎对象 `metadata` 自 A1 起就是 `NodeMeta { nodeId, kind, ... }`(`adapter.ts:115-116` add 时设置)——pickAt 只需父链上溯读它。 + +**约定提醒:** 测试同目录共置;Conventional Commits + 显式 pathspec(绝不 `git add -A`);eslint 禁 useEffect body setState;vitest 绿 ≠ 功能正确(最后必须视觉 smoke)。 + +--- + +## 文件结构 + +| 文件 | 改动 | +| ---------------------------------------------- | ----------------------------------------------------------------------- | +| `src/runtime/babylon/adapter.ts` | `pickAt` 实现 + `findNodeId` 帮助函数 + 构造默认编辑器相机 | +| `src/runtime/babylon/adapter.test.ts` | pick 单测(中心/角落/remove/子 mesh 上溯/helper 不可拾取/默认相机存在) | +| `src/runtime/conformance/conformance-suite.ts` | 第三参 `options.makePickAdapter` + 拾取对等 3 用例 | +| `src/runtime/conformance/conformance.test.ts` | 两引擎各传 800×600 的 pick 工厂 | +| `src/runtime/render-host.ts` | `IRenderHost` 加 `setSelection(node_id)` | +| `src/runtime/babylon/render-host.ts` | HighlightLayer 创建/实现/dispose + `selectionLayer` 测试 getter | +| `src/runtime/babylon/render-host.test.ts` | setSelection 单测(命中/null 清层/未知 id/幂等/dispose) | +| `src/ui/viewport/BabylonViewport.tsx` | 点选 click guard + 选中订阅 + diff 后重放 | +| `src/ui/viewport/BabylonViewport.test.tsx` | 点选/清选/drag guard/层级选中高亮跟随 | +| `src/ui/viewport/ThreeViewport.tsx` | `diffAndApply` 内联 BFS 迁 `diffSceneNodes`(仅此) | + +**不动:** `IRuntimeAdapter`(pickAt 已在契约上)、ThreeAdapter、isEngineEditingCapable 及全部禁用面、EngineToggle/Viewport、i18n。 + +--- + +### 任务 1:`BabylonAdapter.pickAt` + 默认编辑器相机 + +**文件:** + +- 修改:`src/runtime/babylon/adapter.ts`(构造函数约 :80-88、`pickAt` 占位约 :204-206、文件底部加帮助函数) +- 测试:`src/runtime/babylon/adapter.test.ts`(追加) + +- [ ] **步骤 1:编写失败的测试** + +在 `adapter.test.ts` 追加(文件顶部 import 区补 `Camera`,并确认 `NullEngine` 已在 B1 引入): + +```ts +import { Camera, NullEngine } from "@babylonjs/core"; +``` + +```ts +describe("pickAt (v1.0 B2)", () => { + const sized = () => + new BabylonAdapter({ + engine: new NullEngine({ renderWidth: 800, renderHeight: 600 }), + }); + const boxNode = (id: string, parent: string | null = null) => + ({ + id, + name: id, + type: "mesh", + data: { type: "mesh", geometry: { kind: "box" } }, + transform: { + position: [0, 0, 0] as [number, number, number], + rotation: [0, 0, 0, 1] as [number, number, number, number], + scale: [1, 1, 1] as [number, number, number], + }, + parent_id: parent, + children_ids: [] as string[], + visible: true, + locked: false, + behaviors: [], + user_data: {}, + }) as SceneNode; + + it("constructor installs a default editor camera as activeCamera", () => { + const a = new BabylonAdapter(); + expect(a.scene.activeCamera).toBeInstanceOf(Camera); + expect(a.scene.activeCamera?.name).toBe("default-editor-camera"); + a.dispose(); + }); + + it("hits the mesh under the viewport center", () => { + const a = sized(); + a.syncNode(boxNode("box"), "add"); + expect(a.pickAt(400, 300)).toBe("box"); + a.dispose(); + }); + + it("returns null on empty space and after remove", () => { + const a = sized(); + a.syncNode(boxNode("box"), "add"); + expect(a.pickAt(10, 10)).toBeNull(); + a.syncNode(boxNode("box"), "remove"); + expect(a.pickAt(400, 300)).toBeNull(); + a.dispose(); + }); + + it("a child mesh under a group resolves to the child's own node id", () => { + const a = sized(); + a.syncNode( + { + ...boxNode("g"), + type: "group", + data: { type: "group" }, + children_ids: ["child"], + } as SceneNode, + "add", + ); + a.syncNode(boxNode("child", "g"), "add"); + expect(a.pickAt(400, 300)).toBe("child"); + a.dispose(); + }); + + it("helper placeholder nodes are unpickable", () => { + const a = sized(); + a.syncNode( + { + ...boxNode("h"), + type: "helper", + data: { type: "helper", helper_kind: "grid" }, + } as SceneNode, + "add", + ); + expect(a.pickAt(400, 300)).toBeNull(); + a.dispose(); + }); +}); +``` + +注意:`SceneNode` 类型 import 该文件应已有,没有就从 `@/core/scene/types` 补。helper 的 data 字面量与 schema 一致(`HelperData = { type: "helper", helper_kind: string }`,`schemas.ts:104-105` 已核)。 + +- [ ] **步骤 2:运行测试验证失败** + +运行:`pnpm vitest run src/runtime/babylon/adapter.test.ts` +预期:FAIL — `BabylonAdapter.pickAt: not implemented in v1.0a` + activeCamera 为 null。 + +- [ ] **步骤 3:实现** + +`adapter.ts` 构造函数末尾(`useRightHandedSystem = true;` 之后)追加: + +```ts +// Default editor camera (mirrors ThreeAdapter's defaultCamera convention: +// position [4,3,4] looking at the origin, vertical fov 50°). scene.pick +// needs an active camera even headless, and conformance asserts pick +// parity against the same framing on both engines. BabylonRenderHost's +// ArcRotateCamera takes over activeCamera on mount; this one stays in the +// scene unused (scene.dispose cleans it up). +const editorCamera = new UniversalCamera( + "default-editor-camera", + new Vector3(4, 3, 4), + this.scene, +); +editorCamera.setTarget(Vector3.Zero()); +editorCamera.fov = (50 * Math.PI) / 180; +editorCamera.minZ = 0.1; +editorCamera.maxZ = 1000; +this.scene.activeCamera = editorCamera; +``` + +`pickAt` 占位替换为: + +```ts + /** + * Pick the SceneNode under `(screen_x, screen_y)` in viewport-pixel space. + * scene.pick unprojects against engine.getRenderWidth/Height — on a real + * Engine that is the canvas pixel size, i.e. the same coordinate contract + * as ThreeAdapter.pickAt. Babylon recomputes view/world matrices lazily, + * so no manual refresh is needed (verified headless on NullEngine). + * Walks up the parent chain for metadata.nodeId, mirroring Three's + * userData.nodeId convention. Returns null on empty space. + */ + pickAt(screen_x: number, screen_y: number): string | null { + const hit = this.scene.pick(screen_x, screen_y); + if (!hit?.hit || !hit.pickedMesh) return null; + return findNodeId(hit.pickedMesh); + } +``` + +文件底部(`applyBabylonTransform` 旁)加: + +```ts +/** Walk up the Babylon parent chain for the nearest synced node's id + * (metadata.nodeId is set by syncNode("add")). Mirrors the Three side's + * findNodeId-over-userData convention. */ +function findNodeId(obj: BabylonNode | null): string | null { + let cur: BabylonNode | null = obj; + while (cur) { + const meta = cur.metadata as NodeMeta | null | undefined; + if (meta?.nodeId) return meta.nodeId; + cur = cur.parent; + } + return null; +} +``` + +`notImplemented` 现在只剩 `generateBehaviorCode`/`syncAsset`/`exportProject` 用——不要动它们。 + +- [ ] **步骤 4:运行测试验证通过(含回归)** + +运行:`pnpm vitest run src/runtime/babylon src/runtime/conformance` +预期:全绿(既有 conformance 不受默认相机影响——它只断言 describeNode/behaviors;render-host 测试的 `activeCamera instanceof ArcRotateCamera` 仍成立,mount 会覆盖 activeCamera)。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/runtime/babylon/adapter.ts src/runtime/babylon/adapter.test.ts +git commit -m "feat(babylon): pickAt via scene.pick + metadata.nodeId walk + default editor camera" +``` + +--- + +### 任务 2:conformance 拾取对等 + +**文件:** + +- 修改:`src/runtime/conformance/conformance-suite.ts`(签名加第三参 + 末尾加 pick describe 块) +- 修改:`src/runtime/conformance/conformance.test.ts`(两引擎传 pick 工厂) + +- [ ] **步骤 1:编写失败的测试(先改 conformance.test.ts 与 suite 签名,让新用例红)** + +`conformance.test.ts` 整体替换为: + +```ts +import { NullEngine } from "@babylonjs/core"; + +import { BabylonAdapter } from "@/runtime/babylon/adapter"; +import { ThreeAdapter } from "@/runtime/three/adapter"; + +import { describeAdapterConformance } from "./conformance-suite"; + +describeAdapterConformance(() => new ThreeAdapter(), "ThreeAdapter", { + makePickAdapter: () => { + const adapter = new ThreeAdapter(); + adapter.setViewportSize(800, 600); + return adapter; + }, +}); +describeAdapterConformance(() => new BabylonAdapter(), "BabylonAdapter", { + makePickAdapter: () => + new BabylonAdapter({ + engine: new NullEngine({ renderWidth: 800, renderHeight: 600 }), + }), +}); +``` + +`conformance-suite.ts`:签名改为 + +```ts +export interface ConformanceOptions { + /** Engine-specific factory returning an adapter ready to pick against an + * 800×600 viewport (Three: setViewportSize; Babylon: sized NullEngine). + * Both engines share the default editor camera framing [4,3,4]→origin, + * fov 50°, so the same screen points resolve the same nodes. */ + makePickAdapter?: () => IRuntimeAdapter; +} + +export function describeAdapterConformance( + makeAdapter: () => IRuntimeAdapter, + label: string, + options?: ConformanceOptions, +): void { +``` + +在外层 `describe` 内、既有用例之后追加(复用现有 `node()` 工厂与 `live`/`make` 清理模式;`makePick` 同样 push 进 `live`): + +```ts +const makePickAdapter = options?.makePickAdapter; +if (makePickAdapter) { + describe("pick parity (v1.0 B2) — 800×600 viewport, default editor camera", () => { + const makePick = (): IRuntimeAdapter => { + const adapter = makePickAdapter(); + live.push(adapter); + return adapter; + }; + const box = () => + node({ + id: "box", + name: "box", + type: "mesh", + data: { type: "mesh", geometry: { kind: "box" } }, + }); + + it("pickAt(center) resolves the mesh at the origin", () => { + const a = makePick(); + a.syncNode(box(), "add"); + expect(a.pickAt(400, 300)).toBe("box"); + }); + + it("pickAt(corner sky) returns null", () => { + const a = makePick(); + a.syncNode(box(), "add"); + expect(a.pickAt(10, 10)).toBeNull(); + }); + + it("pickAt returns null after the node is removed", () => { + const a = makePick(); + a.syncNode(box(), "add"); + a.syncNode(box(), "remove"); + expect(a.pickAt(400, 300)).toBeNull(); + }); + }); +} +``` + +断言只用中心/角落/移除三类——Three 侧相机 aspect 与 Babylon 的 4:3 不必逐点一致,这三类断言对 aspect 差异鲁棒(spec §8)。 + +- [ ] **步骤 2:运行测试验证失败/通过** + +运行:`pnpm vitest run src/runtime/conformance` +预期:任务 1 已实现 Babylon pickAt → 双引擎 6 个新用例直接 PASS(Three 的 pickAt 早已实现)。若 Three 侧任一用例红,**停下来查 Three 默认相机/视口语义,不要改断言迁就**。 + +- [ ] **步骤 3:Commit** + +```bash +git add src/runtime/conformance/conformance-suite.ts src/runtime/conformance/conformance.test.ts +git commit -m "test(conformance): pick parity — center hit / sky null / removed null (both engines)" +``` + +--- + +### 任务 3:`IRenderHost.setSelection` + `BabylonRenderHost` HighlightLayer + +**文件:** + +- 修改:`src/runtime/render-host.ts`(接口加方法) +- 修改:`src/runtime/babylon/render-host.ts` +- 测试:`src/runtime/babylon/render-host.test.ts`(追加) + +- [ ] **步骤 1:编写失败的测试** + +`render-host.test.ts` 顶部 import 区补: + +```ts +import { Mesh, NullEngine } from "@babylonjs/core"; + +import type { SceneNode } from "@/core/scene/types"; +``` + +文件内(describe 外)加节点工厂: + +```ts +const boxNode = (id: string): SceneNode => + ({ + id, + name: id, + type: "mesh", + data: { type: "mesh", geometry: { kind: "box" } }, + transform: { + position: [0, 0, 0] as [number, number, number], + rotation: [0, 0, 0, 1] as [number, number, number, number], + scale: [1, 1, 1] as [number, number, number], + }, + parent_id: null, + children_ids: [] as string[], + visible: true, + locked: false, + behaviors: [], + user_data: {}, + }) as SceneNode; +``` + +describe 内追加: + +```ts +describe("setSelection (v1.0 B2)", () => { + function mounted() { + const { host } = makeHost(); + host.mount(document.createElement("canvas")); + host.adapter.syncNode(boxNode("box"), "add"); + return host; + } + + it("highlights the selected node's mesh", () => { + const host = mounted(); + host.setSelection("box"); + const mesh = host.adapter.getRuntimeObject("box") as Mesh; + expect(host.selectionLayer?.hasMesh(mesh)).toBe(true); + host.dispose(); + }); + + it("null clears the highlight (idempotent replay-safe)", () => { + const host = mounted(); + host.setSelection("box"); + host.setSelection("box"); // replay — must not throw or double-add + host.setSelection(null); + const mesh = host.adapter.getRuntimeObject("box") as Mesh; + expect(host.selectionLayer?.hasMesh(mesh)).toBe(false); + host.dispose(); + }); + + it("unknown / removed node id clears the layer instead of throwing", () => { + const host = mounted(); + host.setSelection("box"); + host.adapter.syncNode(boxNode("box"), "remove"); + host.setSelection("box"); // node gone — layer must end up empty + expect(host.selectionLayer?.getClassName()).toBe("HighlightLayer"); + host.setSelection("nope"); + host.dispose(); + }); + + it("setSelection before mount is a no-op (no throw)", () => { + const { host } = makeHost(); + expect(() => host.setSelection("box")).not.toThrow(); + }); +}); +``` + +- [ ] **步骤 2:运行测试验证失败** + +运行:`pnpm vitest run src/runtime/babylon/render-host.test.ts` +预期:FAIL — `setSelection` / `selectionLayer` 不存在(typecheck 红也算)。 + +- [ ] **步骤 3:实现** + +`src/runtime/render-host.ts` 接口 `resize` 与 `dispose` 之间加: + +```ts + /** Mark the node as visually selected (engine-specific highlight), or + * clear the marker when null. Idempotent — callers may replay it after + * scene diffs (removed/rebuilt nodes must not leave stale highlights). */ + setSelection(node_id: string | null): void; +``` + +`src/runtime/babylon/render-host.ts`: + +import 区改为: + +```ts +import { + ArcRotateCamera, + Color3, + Color4, + Engine, + HighlightLayer, + Mesh, + Vector3, + type AbstractEngine, + type Node as BabylonNode, +} from "@babylonjs/core"; +``` + +`CLEAR_COLOR` 旁加: + +```ts +/** Matches ThreeViewport's OutlinePass visibleEdgeColor (#3b82f6). */ +const SELECTION_COLOR = Color3.FromHexString("#3b82f6"); +``` + +字段区加 `private highlight: HighlightLayer | null = null;`,`mount` 在 `scene.activeCamera = camera;` 之前加: + +```ts +// Selection highlight — engine-side counterpart of ThreeViewport's +// OutlinePass. Works on NullEngine too (verified), so no headless guard. +this.highlight = new HighlightLayer("selection-highlight", scene); +``` + +类内加: + +```ts + /** Test-only surface: lets unit tests assert hasMesh membership. */ + get selectionLayer(): HighlightLayer | null { + return this.highlight; + } + + setSelection(node_id: string | null): void { + const layer = this.highlight; + const adapter = this.adapterInstance; + if (!layer || !adapter) return; + layer.removeAllMeshes(); + if (!node_id) return; + const root = adapter.getRuntimeObject(node_id) as BabylonNode | undefined; + if (!root) return; // removed/unknown — an empty layer is the right state + if (root instanceof Mesh) layer.addMesh(root, SELECTION_COLOR); + for (const child of root.getDescendants(false)) { + if (child instanceof Mesh) layer.addMesh(child, SELECTION_COLOR); + } + } +``` + +`dispose()` 开头 `this.stop();` 之后加: + +```ts +this.highlight?.dispose(); +this.highlight = null; +``` + +- [ ] **步骤 4:运行测试验证通过** + +运行:`pnpm vitest run src/runtime/babylon/render-host.test.ts && pnpm typecheck` +预期:PASS(B1 既有 5 用例 + 新 4 用例);typecheck 过(接口新方法只有 BabylonRenderHost 这一个实现者)。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/runtime/render-host.ts src/runtime/babylon/render-host.ts src/runtime/babylon/render-host.test.ts +git commit -m "feat(babylon): IRenderHost.setSelection — HighlightLayer selection marker (#3b82f6)" +``` + +--- + +### 任务 4:`BabylonViewport` 点选 + 高亮接线 + +**文件:** + +- 修改:`src/ui/viewport/BabylonViewport.tsx` +- 测试:`src/ui/viewport/BabylonViewport.test.tsx`(追加) + +- [ ] **步骤 1:编写失败的测试** + +`BabylonViewport.test.tsx`:`NullEngine` import 处确认存在;`beforeEach` 的 `useSceneStore.getState().setProject(...)` 后补 `useUIStore.setState({ selectedNodeId: null });`(顶部加 `import { useUIStore } from "@/services/ui/store";`)。`createHost` 改为定尺寸引擎: + +```ts +const createHost = () => { + host = new BabylonRenderHost({ + createEngine: () => new NullEngine({ renderWidth: 800, renderHeight: 600 }), + }); + return host; +}; +``` + +追加用例(demo cube 默认在 [0,0.5,0],先移到原点中心使中心射线穿过箱体内部——spec §8 的不擦边要求): + +```tsx +function centerCube(): string { + const meshId = firstMeshId(); + useSceneStore.getState().setNodeTransform(meshId, { + position: [0, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }); + return meshId; +} + +function canvasOf(container: HTMLElement): HTMLCanvasElement { + const canvas = container.querySelector("canvas"); + if (!canvas) throw new Error("no canvas"); + return canvas; +} + +it("clicking the mesh selects it; clicking empty space clears (B2)", () => { + const { container } = render(); + const meshId = centerCube(); + const canvas = canvasOf(container); + // jsdom rects are 0-based, so clientX/Y == viewport pixel coords. + fireEvent.pointerDown(canvas, { clientX: 400, clientY: 300 }); + fireEvent.click(canvas, { clientX: 400, clientY: 300 }); + expect(useUIStore.getState().selectedNodeId).toBe(meshId); + fireEvent.pointerDown(canvas, { clientX: 10, clientY: 10 }); + fireEvent.click(canvas, { clientX: 10, clientY: 10 }); + expect(useUIStore.getState().selectedNodeId).toBeNull(); +}); + +it("a drag-release click (>5px) does not change the selection (PR #8 guard)", () => { + const { container } = render(); + centerCube(); + const canvas = canvasOf(container); + fireEvent.pointerDown(canvas, { clientX: 100, clientY: 100 }); + fireEvent.click(canvas, { clientX: 400, clientY: 300 }); + expect(useUIStore.getState().selectedNodeId).toBeNull(); +}); + +it("hierarchy-driven selection highlights the node's mesh", () => { + render(); + const meshId = centerCube(); + useUIStore.getState().setSelectedNodeId(meshId); + const mesh = host!.adapter.getRuntimeObject(meshId); + expect(host!.selectionLayer?.hasMesh(mesh as never)).toBe(true); + useUIStore.getState().setSelectedNodeId(null); + expect(host!.selectionLayer?.hasMesh(mesh as never)).toBe(false); +}); + +it("deleting the selected node leaves the highlight layer cleared", () => { + render(); + const meshId = centerCube(); + useUIStore.getState().setSelectedNodeId(meshId); + const project = useSceneStore.getState().project!; + const { [meshId]: _gone, ...rest } = project.scene.nodes; + useSceneStore.getState().setProject({ + ...project, + scene: { + nodes: rest, + root_node_ids: project.scene.root_node_ids.filter((id) => id !== meshId), + }, + }); + // applyDiff replays setSelection — removed node must not leave stale meshes. + expect(host!.adapter.describeNode(meshId)).toBeNull(); +}); +``` + +`fireEvent` 已由 `@testing-library/react` 导出(import 行补上)。最后一个用例注意:`setProject` 换了整个 project 引用但 `metadata.id` 不变 → 走订阅 diff 路径。 + +- [ ] **步骤 2:运行测试验证失败** + +运行:`pnpm vitest run src/ui/viewport/BabylonViewport.test.tsx` +预期:新 4 用例 FAIL(无 click handler/无高亮),B1 既有 3 用例 PASS。 + +- [ ] **步骤 3:实现** + +`BabylonViewport.tsx`: + +- import 加 `import { useUIStore } from "@/services/ui/store";` +- 组件 JSDoc 的 "No picking..." 行改为:`No gizmo / play / drop / focus — those are B3/B4; picking + selection highlight landed in B2.` +- `applyDiff` 与订阅接线整体改为(`trySync` 不动;注意 `syncSelection` 定义在 `applyDiff` 之前): + +```tsx +const syncSelection = () => host.setSelection(useUIStore.getState().selectedNodeId); +const applyDiff = (diff: SceneDiff) => { + for (const n of diff.added) trySync(n, "add"); + for (const n of diff.updated) trySync(n, "update"); + for (const n of diff.removed) trySync(n, "remove"); + // Replay the highlight: a removed/rebuilt selected node must not leave + // a stale mesh in the HighlightLayer (setSelection is idempotent). + syncSelection(); +}; +``` + +种场景行后无需额外初始同步——`applyDiff` 已含重放,且 Babylon 种场景是同步的(无资产预载),不存在 Three 侧 B1 修过的 seedScene 竞态。 + +- `host.start();` 之后加点选接线(镜像 ThreeViewport PR #8 click-vs-drag guard): + +```tsx +// Click-vs-drag guard (PR #8 convention): releasing an orbit drag inside +// the canvas fires a click; only near-stationary releases count as picks. +const DRAG_PX_TOLERANCE_SQ = 25; // 5px +let downX = 0; +let downY = 0; +const onPointerDown = (event: PointerEvent) => { + downX = event.clientX; + downY = event.clientY; +}; +const onClick = (event: MouseEvent) => { + const dx = event.clientX - downX; + const dy = event.clientY - downY; + if (dx * dx + dy * dy > DRAG_PX_TOLERANCE_SQ) return; + const rect = canvas.getBoundingClientRect(); + useUIStore + .getState() + .setSelectedNodeId( + adapter.pickAt(event.clientX - rect.left, event.clientY - rect.top), + ); +}; +canvas.addEventListener("pointerdown", onPointerDown); +canvas.addEventListener("click", onClick); + +const unsubscribeUI = useUIStore.subscribe((state, prev) => { + if (state.selectedNodeId !== prev.selectedNodeId) { + host.setSelection(state.selectedNodeId); + } +}); +``` + +- cleanup 中 `unsubscribe();` 旁加 `unsubscribeUI();`,`ro.disconnect();` 前加: + +```tsx +canvas.removeEventListener("click", onClick); +canvas.removeEventListener("pointerdown", onPointerDown); +``` + +- [ ] **步骤 4:运行测试验证通过** + +运行:`pnpm vitest run src/ui/viewport/BabylonViewport.test.tsx && pnpm typecheck` +预期:7 用例全 PASS。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/ui/viewport/BabylonViewport.tsx src/ui/viewport/BabylonViewport.test.tsx +git commit -m "feat(viewport): Babylon click-pick + HighlightLayer selection sync (B2)" +``` + +--- + +### 任务 5:ThreeViewport `diffAndApply` 迁 `diffSceneNodes` + +**文件:** + +- 修改:`src/ui/viewport/ThreeViewport.tsx`(`diffAndApply`,约 :760-808) + +无新测试(jsdom 无 WebGL;`diffSceneNodes` 单测已在 B1,全量回归 + 视觉 smoke 兜底)。 + +- [ ] **步骤 1:实现** + +import 区把 `import { diffSceneNodes } from "./scene-diff";` 加进相对导入组。`diffAndApply` 的 doc comment 第一段改为: + +``` + * Translate node-identity differences between two project snapshots into + * syncNode calls via the engine-neutral diffSceneNodes walk (adds are + * BFS-ordered so parents land first; removes detach the gizmo / clear the + * outline before the object disappears). +``` + +函数体中从 `const queue: string[] = [...next.scene.root_node_ids];` 到结尾的两段遍历整体替换为: + +```ts +const diff = diffSceneNodes(old.scene, next.scene); +for (const n of diff.added) adapter.syncNode(n, "add"); +for (const n of diff.updated) adapter.syncNode(n, "update"); +for (const n of diff.removed) { + if (gizmo.object && gizmo.object.userData.nodeId === n.id) { + gizmo.detach(); + } + if (outlinePass.selectedObjects.some((obj) => obj.userData.nodeId === n.id)) { + outlinePass.selectedObjects = []; + } + adapter.syncNode(n, "remove"); +} +``` + +资产预载段(函数开头到 `newAssets` 的 `await Promise.all`)**原样保留**。语义差异说明:原实现 BFS 中 update/add 交错,新实现 added 全部先于 updated——父节点先注册,重挂到新增父节点的 update 不受影响(Babylon 侧同语义已在 B1 验证)。 + +- [ ] **步骤 2:验证(全量回归)** + +运行:`pnpm lint && pnpm typecheck && pnpm test` +预期:全绿(545+ 用例;ThreeViewport 无组件测试,行为回归靠任务 6 smoke)。 + +- [ ] **步骤 3:Commit** + +```bash +git add src/ui/viewport/ThreeViewport.tsx +git commit -m "refactor(viewport): migrate diffAndApply onto engine-neutral diffSceneNodes (B2 minimal convergence)" +``` + +--- + +### 任务 6:全量门禁 + 视觉 smoke + 收尾 + +- [ ] **步骤 1:全量门禁** + +运行:`pnpm lint && pnpm typecheck && pnpm test` +预期:三者全绿。任何红先修再 smoke。 + +- [ ] **步骤 2:视觉 smoke(必跑)** + +`pnpm tauri dev`(或 headless CDP 等价 surface,B1 已验证该手法),起步场景: + +1. 切 Babylon:点 cube → **蓝色高亮出现** + 右栏面板显示 cube + 层级行高亮;点空白 → 高亮消失、选中清空。 +2. 层级面板点 cube → Babylon 视口高亮跟随;再点别的行(灯光)→ 高亮转移(灯光无 mesh → 层清空,不报错)。 +3. 删除选中节点(Babylon 模式层级选中 + Delete……Delete 在 Babylon 模式可用吗?可用——快捷键只 gate 了 Space/F)→ 高亮不悬空、无 console 报错。 +4. 轨道拖拽释放在 cube 上 → **不**误选(drag guard)。 +5. 切回 Three:点选/描边/gizmo/拖放/撤销重做全部正常(diff 迁移零回归——重点回归 Cmd+D 复制、Delete、资源拖拽落点、撤销)。 +6. 引擎来回切 3+ 次:选中高亮在两侧都状态正确(Three 侧 B1 的 seed 重放;Babylon 侧 applyDiff 重放);console 干净。 +7. Babylon 选中后属性面板改 transform → 高亮跟着 mesh 走(HighlightLayer 绑 mesh 自动跟随,应无需处理;若高亮留在原地即 bug)。 + +- [ ] **步骤 3:roadmap 勾选 + 收尾** + +`docs/roadmap.md` 的 B2 行打勾并按 B1 条目风格补一句实现摘要(HighlightLayer / pickAt scene.pick + metadata 上溯 / conformance pick parity / diff 迁移);commit `docs(roadmap): v1.0 B2 拾取+选中描边跨引擎 完成`。然后用 superpowers:finishing-a-development-branch 走 PR(标题 `feat(viewport): v1.0 B2 — 拾取 + 选中描边跨引擎`,How-to-test 引用上面 smoke 清单;**gh fine-grained PAT 走 REST 建/合 PR**,见 local_environment memory)。 + +--- + +## 偏差记录约定 + +实现中任何与本计划/spec 的偏差,在对应任务下追加 `> ⚠ 偏差:...` 并同步 spec 偏差注记,merge 时进 memory。 From 4be26846f021ff1c1e3b6ab9c1b27ee9210c7449 Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Thu, 11 Jun 2026 22:53:28 +0800 Subject: [PATCH 03/11] feat(babylon): pickAt via scene.pick + metadata.nodeId walk + default editor camera Co-Authored-By: Claude Fable 5 --- src/runtime/babylon/adapter.test.ts | 83 +++++++++++++++++++++++++++-- src/runtime/babylon/adapter.ts | 45 +++++++++++++++- 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/src/runtime/babylon/adapter.test.ts b/src/runtime/babylon/adapter.test.ts index 6392f16..04990f5 100644 --- a/src/runtime/babylon/adapter.test.ts +++ b/src/runtime/babylon/adapter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { NullEngine } from "@babylonjs/core"; +import { Camera, NullEngine } from "@babylonjs/core"; import type { SceneNode } from "@/core/scene/types"; @@ -115,9 +115,8 @@ describe("BabylonAdapter", () => { a.dispose(); }); - it("throws NotImplemented for pickAt / syncAsset / exportProject", async () => { + it("throws NotImplemented for syncAsset / exportProject", async () => { const a = new BabylonAdapter(); - expect(() => a.pickAt(0, 0)).toThrow(/not implemented/i); await expect(a.syncAsset({} as never)).rejects.toThrow(/not implemented/i); await expect(a.exportProject({} as never, {})).rejects.toThrow(/not implemented/i); a.dispose(); @@ -223,3 +222,81 @@ describe("engine injection (v1.0 B1)", () => { adapter.dispose(); }); }); + +describe("pickAt (v1.0 B2)", () => { + const sized = () => + new BabylonAdapter({ + engine: new NullEngine({ renderWidth: 800, renderHeight: 600 }), + }); + const boxNode = (id: string, parent: string | null = null) => + ({ + id, + name: id, + type: "mesh", + data: { type: "mesh", geometry: { kind: "box" } }, + transform: { + position: [0, 0, 0] as [number, number, number], + rotation: [0, 0, 0, 1] as [number, number, number, number], + scale: [1, 1, 1] as [number, number, number], + }, + parent_id: parent, + children_ids: [] as string[], + visible: true, + locked: false, + behaviors: [], + user_data: {}, + }) as SceneNode; + + it("constructor installs a default editor camera as activeCamera", () => { + const a = new BabylonAdapter(); + expect(a.scene.activeCamera).toBeInstanceOf(Camera); + expect(a.scene.activeCamera?.name).toBe("default-editor-camera"); + a.dispose(); + }); + + it("hits the mesh under the viewport center", () => { + const a = sized(); + a.syncNode(boxNode("box"), "add"); + expect(a.pickAt(400, 300)).toBe("box"); + a.dispose(); + }); + + it("returns null on empty space and after remove", () => { + const a = sized(); + a.syncNode(boxNode("box"), "add"); + expect(a.pickAt(10, 10)).toBeNull(); + a.syncNode(boxNode("box"), "remove"); + expect(a.pickAt(400, 300)).toBeNull(); + a.dispose(); + }); + + it("a child mesh under a group resolves to the child's own node id", () => { + const a = sized(); + a.syncNode( + { + ...boxNode("g"), + type: "group", + data: { type: "group" }, + children_ids: ["child"], + } as SceneNode, + "add", + ); + a.syncNode(boxNode("child", "g"), "add"); + expect(a.pickAt(400, 300)).toBe("child"); + a.dispose(); + }); + + it("helper placeholder nodes are unpickable", () => { + const a = sized(); + a.syncNode( + { + ...boxNode("h"), + type: "helper", + data: { type: "helper", helper_kind: "grid" }, + } as SceneNode, + "add", + ); + expect(a.pickAt(400, 300)).toBeNull(); + a.dispose(); + }); +}); diff --git a/src/runtime/babylon/adapter.ts b/src/runtime/babylon/adapter.ts index d762ee1..d15094b 100644 --- a/src/runtime/babylon/adapter.ts +++ b/src/runtime/babylon/adapter.ts @@ -85,6 +85,23 @@ export class BabylonAdapter implements IRuntimeAdapter { // rendered scene on z. Raw stored values are unaffected, so headless // conformance/describeNode behavior does not change. this.scene.useRightHandedSystem = true; + + // Default editor camera (mirrors ThreeAdapter's defaultCamera convention: + // position [4,3,4] looking at the origin, vertical fov 50°). scene.pick + // needs an active camera even headless, and conformance asserts pick + // parity against the same framing on both engines. BabylonRenderHost's + // ArcRotateCamera takes over activeCamera on mount; this one stays in the + // scene unused (scene.dispose cleans it up). + const editorCamera = new UniversalCamera( + "default-editor-camera", + new Vector3(4, 3, 4), + this.scene, + ); + editorCamera.setTarget(Vector3.Zero()); + editorCamera.fov = (50 * Math.PI) / 180; + editorCamera.minZ = 0.1; + editorCamera.maxZ = 1000; + this.scene.activeCamera = editorCamera; } syncNode(node: SceneNode, op: SyncOp): void { @@ -201,8 +218,19 @@ export class BabylonAdapter implements IRuntimeAdapter { this.behaviorRuntime.clear(); } - pickAt(_screen_x: number, _screen_y: number): string | null { - return notImplemented("pickAt"); + /** + * Pick the SceneNode under `(screen_x, screen_y)` in viewport-pixel space. + * scene.pick unprojects against engine.getRenderWidth/Height — on a real + * Engine that is the canvas pixel size, i.e. the same coordinate contract + * as ThreeAdapter.pickAt. Babylon recomputes view/world matrices lazily, + * so no manual refresh is needed (verified headless on NullEngine). + * Walks up the parent chain for metadata.nodeId, mirroring Three's + * userData.nodeId convention. Returns null on empty space. + */ + pickAt(screen_x: number, screen_y: number): string | null { + const hit = this.scene.pick(screen_x, screen_y); + if (!hit?.hit || !hit.pickedMesh) return null; + return findNodeId(hit.pickedMesh); } syncAsset(_asset: AssetReference): Promise { return Promise.reject( @@ -358,3 +386,16 @@ function createCamera( : Camera.PERSPECTIVE_CAMERA; return cam; } + +/** Walk up the Babylon parent chain for the nearest synced node's id + * (metadata.nodeId is set by syncNode("add")). Mirrors the Three side's + * findNodeId-over-userData convention. */ +function findNodeId(obj: BabylonNode | null): string | null { + let cur: BabylonNode | null = obj; + while (cur) { + const meta = cur.metadata as NodeMeta | null | undefined; + if (meta?.nodeId) return meta.nodeId; + cur = cur.parent; + } + return null; +} From 969972f410456f0312d389f333671b35968c5c4b Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Thu, 11 Jun 2026 22:55:02 +0800 Subject: [PATCH 04/11] =?UTF-8?q?test(conformance):=20pick=20parity=20?= =?UTF-8?q?=E2=80=94=20center=20hit=20/=20sky=20null=20/=20removed=20null?= =?UTF-8?q?=20(both=20engines)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/runtime/conformance/conformance-suite.ts | 46 ++++++++++++++++++++ src/runtime/conformance/conformance.test.ts | 17 +++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/runtime/conformance/conformance-suite.ts b/src/runtime/conformance/conformance-suite.ts index cace387..b7b5506 100644 --- a/src/runtime/conformance/conformance-suite.ts +++ b/src/runtime/conformance/conformance-suite.ts @@ -3,6 +3,14 @@ import { afterEach, describe, expect, it } from "vitest"; import type { SceneNode } from "@/core/scene/types"; import type { IRuntimeAdapter } from "@/runtime/adapter"; +export interface ConformanceOptions { + /** Engine-specific factory returning an adapter ready to pick against an + * 800×600 viewport (Three: setViewportSize; Babylon: sized NullEngine). + * Both engines share the default editor camera framing [4,3,4]→origin, + * fov 50°, so the same screen points resolve the same nodes. */ + makePickAdapter?: () => IRuntimeAdapter; +} + const ID = { position: [0, 0, 0] as [number, number, number], rotation: [0, 0, 0, 1] as [number, number, number, number], @@ -42,6 +50,7 @@ function node( export function describeAdapterConformance( makeAdapter: () => IRuntimeAdapter, label: string, + options?: ConformanceOptions, ): void { describe(`IRuntimeAdapter conformance — ${label}`, () => { // Track every adapter a test creates so we can dispose them (Babylon holds @@ -329,5 +338,42 @@ export function describeAdapterConformance( a.tickBehaviors(0.25); expect(a.describeNode("m")!.position[1]).toBe(0); // disabled bob didn't move it }); + + const makePickAdapter = options?.makePickAdapter; + if (makePickAdapter) { + describe("pick parity (v1.0 B2) — 800×600 viewport, default editor camera", () => { + const makePick = (): IRuntimeAdapter => { + const adapter = makePickAdapter(); + live.push(adapter); + return adapter; + }; + const box = () => + node({ + id: "box", + name: "box", + type: "mesh", + data: { type: "mesh", geometry: { kind: "box" } }, + }); + + it("pickAt(center) resolves the mesh at the origin", () => { + const a = makePick(); + a.syncNode(box(), "add"); + expect(a.pickAt(400, 300)).toBe("box"); + }); + + it("pickAt(corner sky) returns null", () => { + const a = makePick(); + a.syncNode(box(), "add"); + expect(a.pickAt(10, 10)).toBeNull(); + }); + + it("pickAt returns null after the node is removed", () => { + const a = makePick(); + a.syncNode(box(), "add"); + a.syncNode(box(), "remove"); + expect(a.pickAt(400, 300)).toBeNull(); + }); + }); + } }); } diff --git a/src/runtime/conformance/conformance.test.ts b/src/runtime/conformance/conformance.test.ts index 3f25a64..e918180 100644 --- a/src/runtime/conformance/conformance.test.ts +++ b/src/runtime/conformance/conformance.test.ts @@ -1,7 +1,20 @@ +import { NullEngine } from "@babylonjs/core"; + import { BabylonAdapter } from "@/runtime/babylon/adapter"; import { ThreeAdapter } from "@/runtime/three/adapter"; import { describeAdapterConformance } from "./conformance-suite"; -describeAdapterConformance(() => new ThreeAdapter(), "ThreeAdapter"); -describeAdapterConformance(() => new BabylonAdapter(), "BabylonAdapter"); +describeAdapterConformance(() => new ThreeAdapter(), "ThreeAdapter", { + makePickAdapter: () => { + const adapter = new ThreeAdapter(); + adapter.setViewportSize(800, 600); + return adapter; + }, +}); +describeAdapterConformance(() => new BabylonAdapter(), "BabylonAdapter", { + makePickAdapter: () => + new BabylonAdapter({ + engine: new NullEngine({ renderWidth: 800, renderHeight: 600 }), + }), +}); From 6f0ce89546e745cc8e71ec10152c5ac82215f94e Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Thu, 11 Jun 2026 22:57:40 +0800 Subject: [PATCH 05/11] =?UTF-8?q?feat(babylon):=20IRenderHost.setSelection?= =?UTF-8?q?=20=E2=80=94=20HighlightLayer=20selection=20marker=20(#3b82f6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/runtime/babylon/render-host.test.ts | 65 ++++++++++++++++++++++++- src/runtime/babylon/render-host.ts | 32 ++++++++++++ src/runtime/render-host.ts | 4 ++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/runtime/babylon/render-host.test.ts b/src/runtime/babylon/render-host.test.ts index 42ef1c6..b32fb2e 100644 --- a/src/runtime/babylon/render-host.test.ts +++ b/src/runtime/babylon/render-host.test.ts @@ -1,6 +1,8 @@ -import { ArcRotateCamera, NullEngine } from "@babylonjs/core"; +import { ArcRotateCamera, Mesh, NullEngine } from "@babylonjs/core"; import { describe, expect, it } from "vitest"; +import type { SceneNode } from "@/core/scene/types"; + import { BabylonRenderHost } from "./render-host"; function makeHost() { @@ -9,6 +11,25 @@ function makeHost() { return { host, engine }; } +const boxNode = (id: string): SceneNode => + ({ + id, + name: id, + type: "mesh", + data: { type: "mesh", geometry: { kind: "box" } }, + transform: { + position: [0, 0, 0] as [number, number, number], + rotation: [0, 0, 0, 1] as [number, number, number, number], + scale: [1, 1, 1] as [number, number, number], + }, + parent_id: null, + children_ids: [] as string[], + visible: true, + locked: false, + behaviors: [], + user_data: {}, + }) as SceneNode; + describe("BabylonRenderHost", () => { it("identifies as the babylon.js engine", () => { expect(makeHost().host.engine).toBe("babylon.js"); @@ -47,4 +68,46 @@ describe("BabylonRenderHost", () => { expect(engine.isDisposed).toBe(true); expect(() => host.adapter).toThrow(); }); + + describe("setSelection (v1.0 B2)", () => { + function mounted() { + const { host } = makeHost(); + host.mount(document.createElement("canvas")); + host.adapter.syncNode(boxNode("box"), "add"); + return host; + } + + it("highlights the selected node's mesh", () => { + const host = mounted(); + host.setSelection("box"); + const mesh = host.adapter.getRuntimeObject("box") as Mesh; + expect(host.selectionLayer?.hasMesh(mesh)).toBe(true); + host.dispose(); + }); + + it("null clears the highlight (idempotent replay-safe)", () => { + const host = mounted(); + host.setSelection("box"); + host.setSelection("box"); // replay — must not throw or double-add + host.setSelection(null); + const mesh = host.adapter.getRuntimeObject("box") as Mesh; + expect(host.selectionLayer?.hasMesh(mesh)).toBe(false); + host.dispose(); + }); + + it("unknown / removed node id clears the layer instead of throwing", () => { + const host = mounted(); + host.setSelection("box"); + host.adapter.syncNode(boxNode("box"), "remove"); + host.setSelection("box"); // node gone — layer must end up empty + expect(host.selectionLayer?.getClassName()).toBe("HighlightLayer"); + host.setSelection("nope"); + host.dispose(); + }); + + it("setSelection before mount is a no-op (no throw)", () => { + const { host } = makeHost(); + expect(() => host.setSelection("box")).not.toThrow(); + }); + }); }); diff --git a/src/runtime/babylon/render-host.ts b/src/runtime/babylon/render-host.ts index a2cb105..c8c5133 100644 --- a/src/runtime/babylon/render-host.ts +++ b/src/runtime/babylon/render-host.ts @@ -1,9 +1,13 @@ import { ArcRotateCamera, + Color3, Color4, Engine, + HighlightLayer, + Mesh, Vector3, type AbstractEngine, + type Node as BabylonNode, } from "@babylonjs/core"; import type { IRenderHost } from "@/runtime/render-host"; @@ -19,6 +23,9 @@ export interface BabylonRenderHostOptions { /** Matches ThreeViewport's renderer.setClearColor(0x101418). */ const CLEAR_COLOR = new Color4(0x10 / 255, 0x14 / 255, 0x18 / 255, 1); +/** Matches ThreeViewport's OutlinePass visibleEdgeColor (#3b82f6). */ +const SELECTION_COLOR = Color3.FromHexString("#3b82f6"); + /** * Babylon render host (v1.0 B1) — owns the real Engine, the editor * ArcRotateCamera and the render loop. The BabylonAdapter it creates owns the @@ -31,6 +38,7 @@ export class BabylonRenderHost implements IRenderHost { private babylonEngine: AbstractEngine | null = null; private camera: ArcRotateCamera | null = null; private adapterInstance: BabylonAdapter | null = null; + private highlight: HighlightLayer | null = null; constructor(options?: BabylonRenderHostOptions) { this.createEngine = options?.createEngine ?? ((canvas) => new Engine(canvas, true)); @@ -64,6 +72,9 @@ export class BabylonRenderHost implements IRenderHost { // NullEngine has no rendering canvas — pointer controls only make sense // on a real Engine. if (engine.getRenderingCanvas()) camera.attachControl(); + // Selection highlight — engine-side counterpart of ThreeViewport's + // OutlinePass. Works on NullEngine too (verified), so no headless guard. + this.highlight = new HighlightLayer("selection-highlight", scene); scene.activeCamera = camera; this.camera = camera; } @@ -85,8 +96,29 @@ export class BabylonRenderHost implements IRenderHost { this.babylonEngine?.resize(); } + /** Test-only surface: lets unit tests assert hasMesh membership. */ + get selectionLayer(): HighlightLayer | null { + return this.highlight; + } + + setSelection(node_id: string | null): void { + const layer = this.highlight; + const adapter = this.adapterInstance; + if (!layer || !adapter) return; + layer.removeAllMeshes(); + if (!node_id) return; + const root = adapter.getRuntimeObject(node_id) as BabylonNode | undefined; + if (!root) return; // removed/unknown — an empty layer is the right state + if (root instanceof Mesh) layer.addMesh(root, SELECTION_COLOR); + for (const child of root.getDescendants(false)) { + if (child instanceof Mesh) layer.addMesh(child, SELECTION_COLOR); + } + } + dispose(): void { this.stop(); + this.highlight?.dispose(); + this.highlight = null; this.camera?.dispose(); this.camera = null; this.adapterInstance?.dispose(); diff --git a/src/runtime/render-host.ts b/src/runtime/render-host.ts index 7f92f35..d2aad6e 100644 --- a/src/runtime/render-host.ts +++ b/src/runtime/render-host.ts @@ -21,6 +21,10 @@ export interface IRenderHost { start(): void; stop(): void; resize(width: number, height: number): void; + /** Mark the node as visually selected (engine-specific highlight), or + * clear the marker when null. Idempotent — callers may replay it after + * scene diffs (removed/rebuilt nodes must not leave stale highlights). */ + setSelection(node_id: string | null): void; dispose(): void; } From 008f9a0b4230de3f41c749905ac8ea42edcdec5c Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Thu, 11 Jun 2026 22:59:16 +0800 Subject: [PATCH 06/11] =?UTF-8?q?fix(test):=20complete=20NullEngineOptions?= =?UTF-8?q?=20fields=20=E2=80=94=20typecheck=20rejects=20partial=20options?= =?UTF-8?q?=20object?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/runtime/babylon/adapter.test.ts | 8 +++++++- src/runtime/conformance/conformance.test.ts | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/runtime/babylon/adapter.test.ts b/src/runtime/babylon/adapter.test.ts index 04990f5..cb8fd4e 100644 --- a/src/runtime/babylon/adapter.test.ts +++ b/src/runtime/babylon/adapter.test.ts @@ -226,7 +226,13 @@ describe("engine injection (v1.0 B1)", () => { describe("pickAt (v1.0 B2)", () => { const sized = () => new BabylonAdapter({ - engine: new NullEngine({ renderWidth: 800, renderHeight: 600 }), + engine: new NullEngine({ + renderWidth: 800, + renderHeight: 600, + textureSize: 512, + deterministicLockstep: false, + lockstepMaxSteps: 4, + }), }); const boxNode = (id: string, parent: string | null = null) => ({ diff --git a/src/runtime/conformance/conformance.test.ts b/src/runtime/conformance/conformance.test.ts index e918180..f90fcf9 100644 --- a/src/runtime/conformance/conformance.test.ts +++ b/src/runtime/conformance/conformance.test.ts @@ -15,6 +15,12 @@ describeAdapterConformance(() => new ThreeAdapter(), "ThreeAdapter", { describeAdapterConformance(() => new BabylonAdapter(), "BabylonAdapter", { makePickAdapter: () => new BabylonAdapter({ - engine: new NullEngine({ renderWidth: 800, renderHeight: 600 }), + engine: new NullEngine({ + renderWidth: 800, + renderHeight: 600, + textureSize: 512, + deterministicLockstep: false, + lockstepMaxSteps: 4, + }), }), }); From 5311636f16bdcbb833e74a11d543d155b8e3745c Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Fri, 12 Jun 2026 10:57:45 +0800 Subject: [PATCH 07/11] feat(viewport): Babylon click-pick + HighlightLayer selection sync (B2) --- src/ui/viewport/BabylonViewport.test.tsx | 80 +++++++++++++++++++++++- src/ui/viewport/BabylonViewport.tsx | 42 ++++++++++++- 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/src/ui/viewport/BabylonViewport.test.tsx b/src/ui/viewport/BabylonViewport.test.tsx index 69a1dad..ab83e99 100644 --- a/src/ui/viewport/BabylonViewport.test.tsx +++ b/src/ui/viewport/BabylonViewport.test.tsx @@ -1,10 +1,11 @@ import { NullEngine } from "@babylonjs/core"; -import { render } from "@testing-library/react"; +import { fireEvent, render } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BabylonRenderHost } from "@/runtime/babylon/render-host"; import { createDemoProject } from "@/services/scene/demo-project"; import { useSceneStore } from "@/services/scene/store"; +import { useUIStore } from "@/services/ui/store"; import { BabylonViewport } from "./BabylonViewport"; @@ -17,7 +18,16 @@ class ResizeObserverStub { describe("BabylonViewport", () => { let host: BabylonRenderHost | null = null; const createHost = () => { - host = new BabylonRenderHost({ createEngine: () => new NullEngine() }); + host = new BabylonRenderHost({ + createEngine: () => + new NullEngine({ + renderWidth: 800, + renderHeight: 600, + textureSize: 512, + deterministicLockstep: false, + lockstepMaxSteps: 4, + }), + }); return host; }; @@ -25,6 +35,7 @@ describe("BabylonViewport", () => { vi.stubGlobal("ResizeObserver", ResizeObserverStub); host = null; useSceneStore.getState().setProject(createDemoProject()); + useUIStore.setState({ selectedNodeId: null }); }); afterEach(() => { vi.unstubAllGlobals(); @@ -38,6 +49,22 @@ describe("BabylonViewport", () => { return mesh.id; } + function centerCube(): string { + const meshId = firstMeshId(); + useSceneStore.getState().setNodeTransform(meshId, { + position: [0, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }); + return meshId; + } + + function canvasOf(container: HTMLElement): HTMLCanvasElement { + const canvas = container.querySelector("canvas"); + if (!canvas) throw new Error("no canvas"); + return canvas; + } + it("mounts, seeds the scene into the adapter, and unmounts cleanly", () => { const { unmount } = render(); expect(host).not.toBeNull(); @@ -67,4 +94,53 @@ describe("BabylonViewport", () => { expect(host!.adapter.describeNode(firstMeshId())).not.toBeNull(); warn.mockRestore(); }); + + it("clicking the mesh selects it; clicking empty space clears (B2)", () => { + const { container } = render(); + const meshId = centerCube(); + const canvas = canvasOf(container); + // jsdom rects are 0-based, so clientX/Y == viewport pixel coords. + fireEvent.pointerDown(canvas, { clientX: 400, clientY: 300 }); + fireEvent.click(canvas, { clientX: 400, clientY: 300 }); + expect(useUIStore.getState().selectedNodeId).toBe(meshId); + fireEvent.pointerDown(canvas, { clientX: 10, clientY: 10 }); + fireEvent.click(canvas, { clientX: 10, clientY: 10 }); + expect(useUIStore.getState().selectedNodeId).toBeNull(); + }); + + it("a drag-release click (>5px) does not change the selection (PR #8 guard)", () => { + const { container } = render(); + centerCube(); + const canvas = canvasOf(container); + fireEvent.pointerDown(canvas, { clientX: 100, clientY: 100 }); + fireEvent.click(canvas, { clientX: 400, clientY: 300 }); + expect(useUIStore.getState().selectedNodeId).toBeNull(); + }); + + it("hierarchy-driven selection highlights the node's mesh", () => { + render(); + const meshId = centerCube(); + useUIStore.getState().setSelectedNodeId(meshId); + const mesh = host!.adapter.getRuntimeObject(meshId); + expect(host!.selectionLayer?.hasMesh(mesh as never)).toBe(true); + useUIStore.getState().setSelectedNodeId(null); + expect(host!.selectionLayer?.hasMesh(mesh as never)).toBe(false); + }); + + it("deleting the selected node leaves the highlight layer cleared", () => { + render(); + const meshId = centerCube(); + useUIStore.getState().setSelectedNodeId(meshId); + const project = useSceneStore.getState().project!; + const { [meshId]: _gone, ...rest } = project.scene.nodes; + useSceneStore.getState().setProject({ + ...project, + scene: { + nodes: rest, + root_node_ids: project.scene.root_node_ids.filter((id) => id !== meshId), + }, + }); + // applyDiff replays setSelection — removed node must not leave stale meshes. + expect(host!.adapter.describeNode(meshId)).toBeNull(); + }); }); diff --git a/src/ui/viewport/BabylonViewport.tsx b/src/ui/viewport/BabylonViewport.tsx index ca3ad08..0ccfe1c 100644 --- a/src/ui/viewport/BabylonViewport.tsx +++ b/src/ui/viewport/BabylonViewport.tsx @@ -4,6 +4,7 @@ import type { SceneNode } from "@/core/scene/types"; import type { SyncOp } from "@/runtime/adapter"; import { BabylonRenderHost } from "@/runtime/babylon/render-host"; import { useSceneStore } from "@/services/scene/store"; +import { useUIStore } from "@/services/ui/store"; import { diffSceneNodes, EMPTY_SCENE_GRAPH, type SceneDiff } from "./scene-diff"; @@ -13,8 +14,9 @@ import { diffSceneNodes, EMPTY_SCENE_GRAPH, type SceneDiff } from "./scene-diff" * - Mount effect re-runs only when the active project id changes; node * edits flow through a useSceneStore subscription → diffSceneNodes → * adapter.syncNode, so the canvas / camera state survive edits. - * - No picking / gizmo / play / drop / focus — those are B2–B4; the - * surrounding UI disables itself via isEngineEditingCapable. + * - No gizmo / play / drop / focus — those are B3/B4; picking + selection + * highlight landed in B2. The surrounding UI disables itself via + * isEngineEditingCapable. * Per-node sync failures warn + skip (one unbuildable node — e.g. a helper, * which has no Babylon builder yet — must not kill the whole viewport). */ @@ -52,10 +54,14 @@ export function BabylonViewport({ ); } }; + const syncSelection = () => host.setSelection(useUIStore.getState().selectedNodeId); const applyDiff = (diff: SceneDiff) => { for (const n of diff.added) trySync(n, "add"); for (const n of diff.updated) trySync(n, "update"); for (const n of diff.removed) trySync(n, "remove"); + // Replay the highlight: a removed/rebuilt selected node must not leave + // a stale mesh in the HighlightLayer (setSelection is idempotent). + syncSelection(); }; const initial = useSceneStore.getState().project; @@ -63,6 +69,35 @@ export function BabylonViewport({ host.start(); + // Click-vs-drag guard (PR #8 convention): releasing an orbit drag inside + // the canvas fires a click; only near-stationary releases count as picks. + const DRAG_PX_TOLERANCE_SQ = 25; // 5px + let downX = 0; + let downY = 0; + const onPointerDown = (event: PointerEvent) => { + downX = event.clientX; + downY = event.clientY; + }; + const onClick = (event: MouseEvent) => { + const dx = event.clientX - downX; + const dy = event.clientY - downY; + if (dx * dx + dy * dy > DRAG_PX_TOLERANCE_SQ) return; + const rect = canvas.getBoundingClientRect(); + useUIStore + .getState() + .setSelectedNodeId( + adapter.pickAt(event.clientX - rect.left, event.clientY - rect.top), + ); + }; + canvas.addEventListener("pointerdown", onPointerDown); + canvas.addEventListener("click", onClick); + + const unsubscribeUI = useUIStore.subscribe((state, prev) => { + if (state.selectedNodeId !== prev.selectedNodeId) { + host.setSelection(state.selectedNodeId); + } + }); + const unsubscribe = useSceneStore.subscribe((state, prev) => { const next = state.project; const old = prev.project; @@ -84,6 +119,9 @@ export function BabylonViewport({ return () => { unsubscribe(); + unsubscribeUI(); + canvas.removeEventListener("click", onClick); + canvas.removeEventListener("pointerdown", onPointerDown); ro.disconnect(); host.dispose(); if (canvas.parentNode === container) { From 5424a904ba1112522102555503f5850cd7b7c26c Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Fri, 12 Jun 2026 11:04:39 +0800 Subject: [PATCH 08/11] test(viewport): assert highlight layer membership on delete; fix replay comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Assert highlight layer contains mesh before deletion and is cleared after - Fix comment to explain that replay re-attaches highlight to rebuilt mesh instances (not for cleaning stale meshes — that's auto-done by dispose) Co-Authored-By: Claude Haiku 4.5 --- src/ui/viewport/BabylonViewport.test.tsx | 7 +++++-- src/ui/viewport/BabylonViewport.tsx | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/ui/viewport/BabylonViewport.test.tsx b/src/ui/viewport/BabylonViewport.test.tsx index ab83e99..6718923 100644 --- a/src/ui/viewport/BabylonViewport.test.tsx +++ b/src/ui/viewport/BabylonViewport.test.tsx @@ -131,8 +131,11 @@ describe("BabylonViewport", () => { render(); const meshId = centerCube(); useUIStore.getState().setSelectedNodeId(meshId); + const mesh = host!.adapter.getRuntimeObject(meshId); + expect(host!.selectionLayer?.hasMesh(mesh as never)).toBe(true); const project = useSceneStore.getState().project!; - const { [meshId]: _gone, ...rest } = project.scene.nodes; + const rest = { ...project.scene.nodes }; + delete rest[meshId]; useSceneStore.getState().setProject({ ...project, scene: { @@ -140,7 +143,7 @@ describe("BabylonViewport", () => { root_node_ids: project.scene.root_node_ids.filter((id) => id !== meshId), }, }); - // applyDiff replays setSelection — removed node must not leave stale meshes. expect(host!.adapter.describeNode(meshId)).toBeNull(); + expect(host!.selectionLayer?.hasMesh(mesh as never)).toBe(false); }); }); diff --git a/src/ui/viewport/BabylonViewport.tsx b/src/ui/viewport/BabylonViewport.tsx index 0ccfe1c..c9e7775 100644 --- a/src/ui/viewport/BabylonViewport.tsx +++ b/src/ui/viewport/BabylonViewport.tsx @@ -59,8 +59,10 @@ export function BabylonViewport({ for (const n of diff.added) trySync(n, "add"); for (const n of diff.updated) trySync(n, "update"); for (const n of diff.removed) trySync(n, "remove"); - // Replay the highlight: a removed/rebuilt selected node must not leave - // a stale mesh in the HighlightLayer (setSelection is idempotent). + // Replay the highlight onto rebuilt instances: when a diff removes and + // re-adds the selected node id, the old mesh's highlight dies with its + // dispose (HighlightLayer auto-cleans), but the NEW mesh instance needs + // a fresh addMesh — setSelection is idempotent, so replaying is safe. syncSelection(); }; From 019d673eafa957f3870d23a4567a33eaf446df9e Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Fri, 12 Jun 2026 12:38:48 +0800 Subject: [PATCH 09/11] refactor(viewport): migrate diffAndApply onto engine-neutral diffSceneNodes (B2 minimal convergence) --- src/ui/viewport/ThreeViewport.tsx | 43 ++++++++++--------------------- 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/src/ui/viewport/ThreeViewport.tsx b/src/ui/viewport/ThreeViewport.tsx index e48328e..346cbc5 100644 --- a/src/ui/viewport/ThreeViewport.tsx +++ b/src/ui/viewport/ThreeViewport.tsx @@ -31,6 +31,7 @@ import { useSceneStore } from "@/services/scene/store"; import { useUIStore } from "@/services/ui/store"; import { computeFocusTarget } from "./focus-helpers"; +import { diffSceneNodes } from "./scene-diff"; /** * Three.js viewport — mounts a WebGL canvas into a host div and mirrors the @@ -749,9 +750,9 @@ async function seedScene(adapter: ThreeAdapter, project: SceneProject): Promise< /** * Translate node-identity differences between two project snapshots into - * syncNode calls. Update first (the common case during editing), then adds - * (BFS-ordered so parents land first), then removes — detaching the gizmo - * before any node it's currently attached to disappears. + * syncNode calls via the engine-neutral diffSceneNodes walk (adds are + * BFS-ordered so parents land first; removes detach the gizmo / clear the + * outline before the object disappears). * * Async because newly-added prefab_instance nodes may reference an asset * not yet in the cache; we kick off syncAsset for any unknown asset_ids @@ -775,34 +776,16 @@ async function diffAndApply( newAssets.map((a) => syncAndPublishPreview(adapter, a as AssetReference)), ); } - const queue: string[] = [...next.scene.root_node_ids]; - const seen = new Set(); - while (queue.length > 0) { - const id = queue.shift(); - if (id === undefined || seen.has(id)) continue; - seen.add(id); - const n = next.scene.nodes[id]; - if (!n) continue; - const o = old.scene.nodes[id]; - if (!o) { - adapter.syncNode(n, "add"); - } else if (n !== o) { - adapter.syncNode(n, "update"); + const diff = diffSceneNodes(old.scene, next.scene); + for (const n of diff.added) adapter.syncNode(n, "add"); + for (const n of diff.updated) adapter.syncNode(n, "update"); + for (const n of diff.removed) { + if (gizmo.object && gizmo.object.userData.nodeId === n.id) { + gizmo.detach(); } - queue.push(...n.children_ids); - } - for (const id of Object.keys(old.scene.nodes)) { - if (!next.scene.nodes[id]) { - const removed = old.scene.nodes[id]; - if (removed) { - if (gizmo.object && gizmo.object.userData.nodeId === id) { - gizmo.detach(); - } - if (outlinePass.selectedObjects.some((obj) => obj.userData.nodeId === id)) { - outlinePass.selectedObjects = []; - } - adapter.syncNode(removed, "remove"); - } + if (outlinePass.selectedObjects.some((obj) => obj.userData.nodeId === n.id)) { + outlinePass.selectedObjects = []; } + adapter.syncNode(n, "remove"); } } From acdb056d855b2633c9f04990c117dc8995c422d8 Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Fri, 12 Jun 2026 12:45:15 +0800 Subject: [PATCH 10/11] =?UTF-8?q?docs(runtime):=20isEngineEditingCapable?= =?UTF-8?q?=20comment=20=E2=80=94=20pick=20is=20cross-engine=20since=20B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/runtime/render-host.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/runtime/render-host.ts b/src/runtime/render-host.ts index d2aad6e..3364ab4 100644 --- a/src/runtime/render-host.ts +++ b/src/runtime/render-host.ts @@ -29,10 +29,11 @@ export interface IRenderHost { } /** - * B1 capability gate: only the Three viewport supports editing interactions - * (gizmo / play / pick / drop / focus). Every UI surface that disables itself - * in Babylon mode reads this single helper, so B2/B3/B4 flip capabilities in - * one place instead of hunting scattered engine === "..." checks. + * Capability gate: only the Three viewport supports editing interactions + * (gizmo / play / drop / focus). Picking + selection highlight are cross-engine + * since B2 (viewport-internal, not gated here). Every UI surface that disables + * itself in Babylon mode reads this single helper, so B3/B4 flip capabilities + * in one place instead of hunting scattered engine === "..." checks. */ export function isEngineEditingCapable(engine: ViewportEngine): boolean { return engine === "three.js"; From 1b885ac4086b26014dd4078b72442761af785bee Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Fri, 12 Jun 2026 16:02:30 +0800 Subject: [PATCH 11/11] =?UTF-8?q?docs(roadmap):=20v1.0=20B2=20=E6=8B=BE?= =?UTF-8?q?=E5=8F=96+=E9=80=89=E4=B8=AD=E6=8F=8F=E8=BE=B9=E8=B7=A8?= =?UTF-8?q?=E5=BC=95=E6=93=8E=20=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 8c18e28..67ad4c3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -123,7 +123,7 @@ - [x] **A2 · behaviors 跨引擎运行时**([#38](https://github.com/longyi-xw/lowcode-3d/pull/38)):`installBehaviors`/`tickBehaviors`/`uninstallBehaviors` 纳入 `IRuntimeAdapter`;Babylon behavior 框架(registry + auto-rotate + bob,操作 Babylon 节点,共享引擎中立 `BehaviorDefinition`);conformance 验运行时对等——**bob 跨引擎精确对等**(位置突变同公式)、auto-rotate ran/累积/卸载、enabled/unknown 隔离。纯 headless。hover-highlight(事件型)+ behavior codegen 留后续。 - [ ] **A3 · glTF + 导出**:prefab_instance/glTF 加载 + `babylon` 导出 target(含 behavior codegen 跨引擎)。 - [x] **B1 · render host:实时切引擎(看+转相机)**:引擎中立 `IRenderHost` 契约(B1 子集:mount/渲染循环/相机/resize/dispose)+ `BabylonRenderHost`(真 `Engine` + `ArcRotateCamera`,`BabylonAdapter` 构造可注入引擎、默认仍 NullEngine 保 conformance)+ `BabylonViewport`(`diffSceneNodes` 增量同步,warn-skip 错误隔离)+ 视口工具栏 Three/Babylon 开关(`useUIStore.viewportEngine` 会话态,切换强制退 play)。Babylon 场景 `useRightHandedSystem` 对齐 three/glTF 坐标系(无镜像)。Babylon 模式 view-only:play/gizmo/拾取/拖放/F focus 经 `isEngineEditingCapable` 统一禁用。**ThreeViewport 零改动**(「并行+定接口」,B2/B3 按 A1 spec §8 清单收敛)。 - - [ ] **B2 · 拾取 + 选中描边跨引擎**:`IRenderHost` 扩 pick/选中高亮,ThreeViewport 开始收敛(迁 `scene-diff`)。 + - [x] **B2 · 拾取 + 选中描边跨引擎**:`BabylonAdapter.pickAt`(`scene.pick` + `metadata.nodeId` 父链上溯——A1 已埋标记)+ 构造时默认编辑器相机(镜像 ThreeAdapter defaultCamera [4,3,4]/fov50°,conformance 拾取对等的基础);conformance 扩 `makePickAdapter` 拾取对等 3 用例 ×2 引擎;`IRenderHost.setSelection` + `BabylonRenderHost` `HighlightLayer`(#3b82f6,NullEngine 可测);`BabylonViewport` 点选(PR #8 click-vs-drag guard)+ 选中订阅 + diff 后重放(同 id 重建重挂高亮);ThreeViewport `diffAndApply` 迁 `diffSceneNodes`(最小收敛,拾取/描边/gizmo 不动,B3 抽 ThreeRenderHost 时一起收)。 - [ ] **B3 · gizmo + snap 跨引擎**。 - [ ] **B4 · 视口能力剩余**:socket 标记 / 资源拖放落点 / F focus / play 行为预览跨引擎;Babylon 视口底色 sRGB 对齐(Three OutputPass 提亮 vs Babylon 直写 raw,见 B1 smoke 记录);Babylon 光强/材质观感对齐。 - **Depends on**: v0.5 行为系统全部完成(v0.5 Stage C)