diff --git a/design/screenshots/img_1.png b/design/screenshots/img_1.png new file mode 100644 index 0000000..13c5024 Binary files /dev/null and b/design/screenshots/img_1.png differ diff --git a/docs/roadmap.md b/docs/roadmap.md index c4c38fc..7cbcf0b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -124,9 +124,9 @@ - [ ] **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 清单收敛)。 - [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 跨引擎**(拆为 B3a/B3b): - - [ ] **B3a · 抽 ThreeRenderHost**:render loop/camera/pick/outline/gizmo/snap 从 `ThreeViewport` 抽进 `ThreeRenderHost implements IRenderHost`(行为保持重构,借成熟 Three 实现定义 gizmo+snap 契约);`IRenderHost` 加 `setGizmoMode`/`setGizmoTarget`/`onTransformCommit`(store/command 无关)/`setSnapProvider`;snap 特征提取下沉 `snap-features.ts`(纯函数 `computeSnapOffset`);`ThreeViewport` 改薄壳,socket markers/asset drop/play/focus 经引擎专有面(`adapter`/`focusCamera`/`setFrameCallback`)留守(B4 抽);`BabylonRenderHost` 暂 no-op stub。 - - [ ] **B3b · Babylon gizmo + snap**:`BabylonRenderHost` 实现同契约(`GizmoManager` + Babylon 特征提取),conformance / smoke 验跨引擎对等。 + - [x] **B3 · gizmo + snap 跨引擎**(拆为 B3a/B3b): + - [x] **B3a · 抽 ThreeRenderHost**(本地合并 main sha 56c0b05):render loop/camera/pick/outline/gizmo/snap 从 `ThreeViewport` 抽进 `ThreeRenderHost implements IRenderHost`(行为保持重构,借成熟 Three 实现定义 gizmo+snap 契约);`IRenderHost` 加 `setGizmoMode`/`setGizmoTarget`/`onTransformCommit`(store/command 无关)/`setSnapProvider`;snap 特征提取下沉 `three/snap-features.ts`;`ThreeViewport` 改薄壳,socket markers/asset drop/play/focus 经引擎专有面(`adapter`/`focusCamera`/`setFrameCallback`/`onGizmoChange`)留守(B4 抽);`BabylonRenderHost` 暂 no-op stub。 + - [x] **B3b · Babylon gizmo + snap**:`BabylonRenderHost` 用 `GizmoManager`(`usePointerToAttachGizmos=false`,onDragStart/Drag/End 映射拖拽生命周期,WeakSet 幂等挂 observer)实现同契约 + `babylon/snap-features.ts`(OBB + `Vector3.Project`)+ `babylon/transform-util.ts`(四元数归一);`computeSnapOffset`/`transformsEqual` 中立化(`core/snap/offset.ts`/`runtime/transform-util.ts`)两引擎共用;能力门细分 `engineCapabilities{gizmo,play,focus,assetDrop}`(gizmo 两引擎放开,play/focus/assetDrop 仍 B4);`BabylonViewport` 接 gizmo 四方法。socket snap 已实现但 socket markers 视觉仍 B4。 - [ ] **B4 · 视口能力剩余**:socket 标记 / 资源拖放落点 / F focus / play 行为预览跨引擎;Babylon 视口底色 sRGB 对齐(Three OutputPass 提亮 vs Babylon 直写 raw,见 B1 smoke 记录);Babylon 光强/材质观感对齐。 - **Depends on**: v0.5 行为系统全部完成(v0.5 Stage C) diff --git a/docs/superpowers/plans/2026-06-16-v1.0b3b-babylon-gizmo.md b/docs/superpowers/plans/2026-06-16-v1.0b3b-babylon-gizmo.md new file mode 100644 index 0000000..9d1837b --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-v1.0b3b-babylon-gizmo.md @@ -0,0 +1,997 @@ +# B3b Babylon gizmo + snap 实现计划 + +> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 + +**目标:** 填实 `BabylonRenderHost` 的 gizmo+snap(B3a 定义的 IRenderHost 契约),让 Babylon 模式可拖动(T/R/S)+ 三层吸附,与 Three 跨引擎对等。 + +**架构:** Babylon 用 `GizmoManager`(`usePointerToAttachGizmos=false`),拖拽生命周期映射到契约(onDragStart=缓存 snap 目标、onDrag=应用 snap、onDragEnd=commit)。snap 喂引擎中立 `computeSnapOffset`(移到 `core/snap/offset.ts`),Babylon 自写特征提取(OBB+投影)。能力门从单一布尔细分为 `engineCapabilities{gizmo,play,focus,assetDrop}`,B3b 放开 gizmo(两引擎)。 + +**技术栈:** TypeScript / `@babylonjs/core` 9.11(`GizmoManager`/`PositionGizmo`/`Vector3.Project`/`getBoundingInfo`)/ React / Zustand / Vitest(NullEngine headless)。 + +**规格:** `docs/superpowers/specs/2026-06-16-v1.0b3b-babylon-gizmo-design.md` + +**已 de-risk(计划期 NullEngine 实验):** GizmoManager headless 实例化/attach/dispose OK;`positionGizmo.onDragStart/onDrag/onDragEnd` observable 齐全;OBB(`getBoundingInfo().boundingBox`)+ `Vector3.Project`(先 `cam.getViewMatrix()`+`scene.updateTransformMatrix()`)headless 可用。 + +**测试约束:** GizmoManager 接线(mode→enabled / target→attached / dispose)+ snap-features + transform-util + engineCapabilities **都 headless 可测**;拖拽→snap→commit 整链 + 描边隐藏/恢复 = **visual smoke**(任务 9)。 + +--- + +## 文件结构 + +| 文件 | 职责 | 操作 | +| ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------ | +| `src/core/snap/offset.ts` | `computeSnapOffset`(引擎中立,从 three 移来) | 创建(移动) | +| `src/runtime/three/snap-features.ts` | 删 computeSnapOffset,改从 core 引(如内部还需) | 修改 | +| `src/runtime/transform-util.ts` | `transformsEqual`(引擎中立,从 three 移来) | 创建(移动) | +| `src/runtime/three/transform-util.ts` | 删 transformsEqual,保留 captureTransform | 修改 | +| `src/runtime/three/render-host.ts` | transformsEqual 改从 runtime 引 | 修改 | +| `src/runtime/render-host.ts` | `engineCapabilities` 替换 `isEngineEditingCapable` | 修改 | +| `src/ui/viewport/{PlayButton,use-editor-shortcuts}.tsx/.ts` + `EditorView.tsx` + `services/library/asset-drag.ts` | 改用 engineCapabilities | 修改 | +| `src/runtime/babylon/snap-features.ts` | Babylon OBB 特征提取 + 投影 | 创建 | +| `src/runtime/babylon/transform-util.ts` | Babylon captureTransform(四元数归一) | 创建 | +| `src/runtime/babylon/render-host.ts` | 4 个 gizmo 方法实现 + GizmoManager | 修改 | +| `src/ui/viewport/BabylonViewport.tsx` | gizmo 接线 | 修改 | +| `docs/roadmap.md` | B3b 勾选 + B3 收口 | 修改 | + +--- + +## 任务 1:computeSnapOffset 移到 core/snap/offset.ts + +**文件:** + +- 创建:`src/core/snap/offset.ts` +- 修改:`src/runtime/three/snap-features.ts`(删 computeSnapOffset) +- 修改:`src/runtime/three/snap-features.test.ts`(import 改路径) +- 修改:`src/runtime/three/render-host.ts`(import computeSnapOffset 改路径) + +`computeSnapOffset` 引擎中立(只用 `@/core/snap/*`),住 three/ 会让 babylon→three 依赖。移到 core。 + +- [ ] **步骤 1:建 offset.ts(逐字搬 + 改 import 为同目录)** + +```ts +// src/core/snap/offset.ts +import { snapTranslation } from "./grid"; +import { SNAP_PIXELS, snapToNodes, type SnapPoint } from "./nodes"; +import { snapToSockets, type SocketPoint } from "./sockets"; + +type Vec3 = [number, number, number]; + +/** + * Pure snap priority chain: socket-align → node-align (only when the dragged + * node has no tagged socket) → grid fallback. Returns the world-space offset to + * apply to the dragged object's position. Engine-neutral — both ThreeRenderHost + * and BabylonRenderHost feed it engine-specific SnapPoint[]/SocketPoint[]. The + * caller gates on translate-mode + modifier before calling. + */ +export function computeSnapOffset(args: { + currentPos: Vec3; + draggedFeatures: SnapPoint[]; + draggedSockets: SocketPoint[]; + hasSockets: boolean; + targetFeatures: SnapPoint[]; + targetSockets: SocketPoint[]; +}): Vec3 | null { + const { currentPos, draggedFeatures, draggedSockets, hasSockets } = args; + const socketOffset = snapToSockets(draggedSockets, args.targetSockets, SNAP_PIXELS); + if (socketOffset) return socketOffset; + if (!hasSockets) { + const offset = snapToNodes(draggedFeatures, args.targetFeatures, SNAP_PIXELS); + if (offset) return offset; + } + const [gx, gy, gz] = snapTranslation(currentPos); + return [gx - currentPos[0], gy - currentPos[1], gz - currentPos[2]]; +} +``` + +- [ ] **步骤 2:从 three/snap-features.ts 删 computeSnapOffset** + +删掉 `three/snap-features.ts` 末尾的 `type Vec3` + `computeSnapOffset`(约 106-134 行)及其不再用的 import(`snapTranslation`/`snapToNodes`/`snapToSockets`/`SNAP_PIXELS` 若仅被 computeSnapOffset 用——核对:`SnapPoint`/`SocketPoint` 仍被 featureSnapPoints/socketPoints 用,保留它们的 type import;`snapTranslation`/`snapToNodes`/`snapToSockets`/`SNAP_PIXELS` 删)。 + +- [ ] **步骤 3:改引用** + +`three/snap-features.test.ts`:`import { computeSnapOffset } from "@/core/snap/offset";`(与 bboxFeatures 等分开 import)。 +`three/render-host.ts`:把 `computeSnapOffset` 从 `./snap-features` 的 import 拆出,改 `import { computeSnapOffset } from "@/core/snap/offset";`(`featureSnapPoints`/`socketPoints` 仍从 `./snap-features`)。 + +- [ ] **步骤 4:验证** + +运行:`pnpm typecheck && pnpm test` +预期:typecheck PASS;snap-features.test + 全量绿。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/core/snap/offset.ts src/runtime/three/snap-features.ts src/runtime/three/snap-features.test.ts src/runtime/three/render-host.ts +git commit -m "refactor(snap): move computeSnapOffset to core/snap (engine-neutral home)" +``` + +--- + +## 任务 2:transformsEqual 移到 runtime/transform-util.ts + +**文件:** + +- 创建:`src/runtime/transform-util.ts` +- 创建:`src/runtime/transform-util.test.ts` +- 修改:`src/runtime/three/transform-util.ts`(删 transformsEqual,留 captureTransform) +- 修改:`src/runtime/three/transform-util.test.ts`(删 transformsEqual 测试) +- 修改:`src/runtime/three/render-host.ts`(transformsEqual 改 import 路径) + +- [ ] **步骤 1:建 runtime/transform-util.ts + 测试** + +```ts +// src/runtime/transform-util.ts +import type { Transform } from "@/core/scene/types"; + +/** Engine-neutral exact transform equality (component-wise). Used by both + * render hosts to skip a no-op gizmo drag commit. */ +export function transformsEqual(a: Transform, b: Transform): boolean { + return ( + a.position[0] === b.position[0] && + a.position[1] === b.position[1] && + a.position[2] === b.position[2] && + a.rotation[0] === b.rotation[0] && + a.rotation[1] === b.rotation[1] && + a.rotation[2] === b.rotation[2] && + a.rotation[3] === b.rotation[3] && + a.scale[0] === b.scale[0] && + a.scale[1] === b.scale[1] && + a.scale[2] === b.scale[2] + ); +} +``` + +```ts +// src/runtime/transform-util.test.ts +import { describe, expect, it } from "vitest"; +import { transformsEqual } from "./transform-util"; +import type { Transform } from "@/core/scene/types"; + +describe("transformsEqual", () => { + it("true for identical, false for any differing component", () => { + const a: Transform = { + position: [0, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }; + expect(transformsEqual(a, { ...a })).toBe(true); + expect(transformsEqual(a, { ...a, position: [0, 1, 0] })).toBe(false); + expect(transformsEqual(a, { ...a, rotation: [0, 0, 1, 0] })).toBe(false); + }); +}); +``` + +- [ ] **步骤 2:从 three/transform-util.ts 删 transformsEqual** + +只留 `captureTransform`(Three 专有,读 Object3D)。删 transformsEqual。`three/transform-util.test.ts` 删掉 transformsEqual 的 describe 块(captureTransform 测试保留)。 + +- [ ] **步骤 3:改 three/render-host.ts 引用** + +`three/render-host.ts` 现在 `import { captureTransform, transformsEqual } from "./transform-util";` → 改成 `import { captureTransform } from "./transform-util";` + `import { transformsEqual } from "@/runtime/transform-util";`。 + +- [ ] **步骤 4:验证** + +运行:`pnpm typecheck && pnpm test` +预期:全绿。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/runtime/transform-util.ts src/runtime/transform-util.test.ts src/runtime/three/transform-util.ts src/runtime/three/transform-util.test.ts src/runtime/three/render-host.ts +git commit -m "refactor(transform): move transformsEqual to runtime (engine-neutral, shared by hosts)" +``` + +--- + +## 任务 3:engineCapabilities 替换 isEngineEditingCapable + +**文件:** + +- 修改:`src/runtime/render-host.ts` +- 修改:`src/ui/views/EditorView.tsx`、`src/ui/viewport/PlayButton.tsx`、`src/ui/viewport/use-editor-shortcuts.ts`、`src/services/library/asset-drag.ts` +- 修改:`src/runtime/render-host.test.ts` + +- [ ] **步骤 1:render-host.ts 加 engineCapabilities,删 isEngineEditingCapable** + +替换 `isEngineEditingCapable` 函数(及其 doc comment)为: + +```ts +/** + * Per-engine editing capability flags. B1/B2 gated everything behind a single + * "three-only" boolean; B3b splits it so Babylon can enable the gizmo while + * play / focus / asset-drop stay Three-only until B4 implements them. B4 flips + * the remaining flags here in one place. + */ +export interface EngineCapabilities { + /** Transform gizmo + snap (B3b: both engines). */ + gizmo: boolean; + /** Play/pause behavior preview (B4: Three-only). */ + play: boolean; + /** F-to-focus camera (B4: Three-only). */ + focus: boolean; + /** Drag library assets into the viewport (B4: Three-only). */ + assetDrop: boolean; +} + +export function engineCapabilities(engine: ViewportEngine): EngineCapabilities { + const three = engine === "three.js"; + return { gizmo: true, play: three, focus: three, assetDrop: three }; +} +``` + +- [ ] **步骤 2:改 5 处调用点** + +`src/ui/views/EditorView.tsx`(GizmoModeToolbar disabled,约 :121): + +```tsx +disabled={selectedNodeId === null || !engineCapabilities(viewportEngine).gizmo} +``` + +import 改 `import { engineCapabilities } from "@/runtime/render-host";`。 + +`src/ui/viewport/PlayButton.tsx`(:12): + +```ts +const editingCapable = engineCapabilities(viewportEngine).play; +``` + +(变量名可保留 editingCapable 或改 canPlay;import 换 engineCapabilities。) + +`src/ui/viewport/use-editor-shortcuts.ts`::115(F focus)→ `if (!engineCapabilities(ui.viewportEngine).focus) return;`;:125(Space/play)→ `if (!engineCapabilities(ui.viewportEngine).play) return;`。import 换。 + +`src/services/library/asset-drag.ts`(:22)→ `if (!engineCapabilities(useUIStore.getState().viewportEngine).assetDrop) return;`。import 换。 + +- [ ] **步骤 3:改 render-host.test.ts** + +把 `isEngineEditingCapable` 的 describe 换成: + +```ts +import { engineCapabilities } from "./render-host"; + +describe("engineCapabilities", () => { + it("enables gizmo on both engines (B3b)", () => { + expect(engineCapabilities("three.js").gizmo).toBe(true); + expect(engineCapabilities("babylon.js").gizmo).toBe(true); + }); + it("keeps play/focus/assetDrop Three-only (B4)", () => { + const b = engineCapabilities("babylon.js"); + expect(b.play).toBe(false); + expect(b.focus).toBe(false); + expect(b.assetDrop).toBe(false); + const t = engineCapabilities("three.js"); + expect(t.play && t.focus && t.assetDrop).toBe(true); + }); +}); +``` + +- [ ] **步骤 4:验证(含无遗留引用)** + +运行:`grep -rn "isEngineEditingCapable" src/`(应为空)→ `pnpm typecheck && pnpm lint && pnpm test` +预期:grep 空;全绿。 + +- [ ] **步骤 5:Commit** + +```bash +git add -A && git commit -m "feat(render-host): split editing capability into per-feature flags (enable Babylon gizmo)" +``` + +--- + +## 任务 4:Babylon snap-features(OBB + 投影)— TDD + +**文件:** + +- 创建:`src/runtime/babylon/snap-features.ts` +- 测试:`src/runtime/babylon/snap-features.test.ts` + +镜像 `src/runtime/three/snap-features.ts` 的语义(15 点顺序必须一致),喂同一 `computeSnapOffset`。Babylon 投影走 `scene`。 + +- [ ] **步骤 1:编写失败的测试** + +```ts +// src/runtime/babylon/snap-features.test.ts +import { describe, expect, it } from "vitest"; +import { + NullEngine, + Scene, + ArcRotateCamera, + Vector3, + MeshBuilder, + TransformNode, +} from "@babylonjs/core"; +import { bboxFeatures, toScreen, socketPoints } from "./snap-features"; + +function makeScene() { + const engine = new NullEngine(); + const scene = new Scene(engine); + const cam = new ArcRotateCamera("c", 0, 1, 8, Vector3.Zero(), scene); + cam.setPosition(new Vector3(4, 3, 4)); + scene.activeCamera = cam; + cam.getViewMatrix(); + scene.updateTransformMatrix(); + return { engine, scene }; +} + +describe("babylon bboxFeatures (OBB)", () => { + it("returns 15 world features for a unit box, center first at origin", () => { + const { scene, engine } = makeScene(); + const box = MeshBuilder.CreateBox("b", { size: 2 }, scene); + box.computeWorldMatrix(true); + const pts = bboxFeatures(box); + expect(pts).toHaveLength(15); + expect(pts[0]!.x).toBeCloseTo(0); + expect(pts[0]!.y).toBeCloseTo(0); + expect(pts[0]!.z).toBeCloseTo(0); + engine.dispose(); + }); + + it("rotates +X face center with the object (OBB not AABB)", () => { + const { scene, engine } = makeScene(); + const box = MeshBuilder.CreateBox("b", { size: 2 }, scene); + box.rotation.y = Math.PI / 4; + box.computeWorldMatrix(true); + const pts = bboxFeatures(box); + const fc = pts[9]!; // +X face center (same index as Three) + expect(Math.hypot(fc.x, fc.z)).toBeCloseTo(1, 4); + engine.dispose(); + }); + + it("returns [] for a transform node with no mesh", () => { + const { scene, engine } = makeScene(); + const tn = new TransformNode("t", scene); + expect(bboxFeatures(tn)).toEqual([]); + engine.dispose(); + }); +}); + +describe("babylon toScreen", () => { + it("projects world origin to viewport center", () => { + const { scene, engine } = makeScene(); + const [x, y] = toScreen(Vector3.Zero(), scene, 800, 600); + expect(x).toBeCloseTo(400, 0); + expect(y).toBeCloseTo(300, 0); + engine.dispose(); + }); +}); + +describe("babylon socketPoints", () => { + it("maps socket local position through the node world matrix", () => { + const { scene, engine } = makeScene(); + const box = MeshBuilder.CreateBox("b", { size: 2 }, scene); + box.position.set(5, 0, 0); + box.computeWorldMatrix(true); + const pts = socketPoints( + box, + [{ id: "s", name: "s", position: [0, 1, 0] }], + scene, + 800, + 600, + ); + expect(pts).toHaveLength(1); + expect(pts[0]!.world[0]).toBeCloseTo(5); + expect(pts[0]!.world[1]).toBeCloseTo(1); + engine.dispose(); + }); +}); +``` + +- [ ] **步骤 2:运行验证失败** + +运行:`pnpm test src/runtime/babylon/snap-features.test.ts` +预期:FAIL,模块不存在。 + +- [ ] **步骤 3:实现 snap-features.ts** + +```ts +// src/runtime/babylon/snap-features.ts +import { + Matrix, + Vector3, + type AbstractMesh, + type Node as BabylonNode, + type Scene, +} from "@babylonjs/core"; + +import type { SnapPoint } from "@/core/snap/nodes"; +import type { SocketPoint } from "@/core/snap/sockets"; +import type { Socket } from "@/core/scene/types"; + +/** Descendant meshes of a node, including the node itself when it is a mesh. */ +function meshesOf(node: BabylonNode): AbstractMesh[] { + const children = node.getChildMeshes(false); + const self = node as Partial; + // A Mesh has getBoundingInfo + is not already in getChildMeshes(false) of itself. + if (typeof self.getBoundingInfo === "function") { + return [self as AbstractMesh, ...children.filter((m) => m !== self)]; + } + return children; +} + +/** 15 OBB features (center + 8 corners + 6 face centers) in WORLD space; [] + * when the node has no mesh geometry. Mirrors three/snap-features.bboxFeatures: + * accumulate each descendant mesh's local box into the dragged node's local + * space, then transform the 15 local points by the node world matrix so they + * follow rotation (OBB, not world AABB). Index order MUST match the Three + * version for cross-engine node-align parity. */ +export function bboxFeatures(node: BabylonNode): Vector3[] { + const tn = node as BabylonNode & { computeWorldMatrix?: (force: boolean) => Matrix }; + if (typeof tn.computeWorldMatrix !== "function") return []; + tn.computeWorldMatrix(true); + const world = tn.getWorldMatrix(); + const invWorld = Matrix.Invert(world); + let min: Vector3 | null = null; + let max: Vector3 | null = null; + const expand = (p: Vector3) => { + if (!min || !max) { + min = p.clone(); + max = p.clone(); + } else { + min = Vector3.Minimize(min, p); + max = Vector3.Maximize(max, p); + } + }; + for (const mesh of meshesOf(node)) { + const bb = mesh.getBoundingInfo().boundingBox; + const lo = bb.minimum; + const hi = bb.maximum; + mesh.computeWorldMatrix(true); + // mesh-local → dragged-node-local = meshWorld · invNodeWorld (row-vector) + const rel = mesh.getWorldMatrix().multiply(invWorld); + for (const x of [lo.x, hi.x]) + for (const y of [lo.y, hi.y]) + for (const z of [lo.z, hi.z]) + expand(Vector3.TransformCoordinates(new Vector3(x, y, z), rel)); + } + if (!min || !max) return []; + const lmin: Vector3 = min; + const lmax: Vector3 = max; + const c = Vector3.Center(lmin, lmax); + const local = [ + c, + new Vector3(lmin.x, lmin.y, lmin.z), + new Vector3(lmin.x, lmin.y, lmax.z), + new Vector3(lmin.x, lmax.y, lmin.z), + new Vector3(lmin.x, lmax.y, lmax.z), + new Vector3(lmax.x, lmin.y, lmin.z), + new Vector3(lmax.x, lmin.y, lmax.z), + new Vector3(lmax.x, lmax.y, lmin.z), + new Vector3(lmax.x, lmax.y, lmax.z), + new Vector3(lmax.x, c.y, c.z), + new Vector3(lmin.x, c.y, c.z), + new Vector3(c.x, lmax.y, c.z), + new Vector3(c.x, lmin.y, c.z), + new Vector3(c.x, c.y, lmax.z), + new Vector3(c.x, c.y, lmin.z), + ]; + return local.map((p) => Vector3.TransformCoordinates(p, world)); +} + +/** World point → screen pixels. Caller ensures scene transform matrix is + * current (render loop updates it; tests call scene.updateTransformMatrix). */ +export function toScreen( + v: Vector3, + scene: Scene, + w: number, + h: number, +): [number, number] { + const p = Vector3.Project(v, Matrix.IdentityReadOnly, scene.getTransformMatrix(), { + x: 0, + y: 0, + width: w, + height: h, + }); + return [p.x, p.y]; +} + +export function featureSnapPoints( + node: BabylonNode, + scene: Scene, + w: number, + h: number, +): SnapPoint[] { + return bboxFeatures(node).map((v) => ({ + screen: toScreen(v, scene, w, h), + world: [v.x, v.y, v.z] as [number, number, number], + })); +} + +export function socketPoints( + node: BabylonNode, + sockets: readonly Socket[], + scene: Scene, + w: number, + h: number, +): SocketPoint[] { + if (sockets.length === 0) return []; + const tn = node as BabylonNode & { computeWorldMatrix?: (force: boolean) => Matrix }; + if (typeof tn.computeWorldMatrix === "function") tn.computeWorldMatrix(true); + const world = tn.getWorldMatrix(); + return sockets.map((s) => { + const wp = Vector3.TransformCoordinates( + new Vector3(s.position[0], s.position[1], s.position[2]), + world, + ); + return { + screen: toScreen(wp, scene, w, h), + world: [wp.x, wp.y, wp.z] as [number, number, number], + tag: s.tag, + }; + }); +} +``` + +> 实现后若 OBB 旋转测试不过(面心距离 ≠ 1),多半是 Babylon 矩阵乘序:改用 `invWorld.multiply(...)` 的顺序前先确认 `mesh.getWorldMatrix().multiply(invWorld)` 是「先 meshWorld 后 invNodeWorld」(Babylon 行向量约定)。测试是安全网。 + +- [ ] **步骤 4:运行验证通过** + +运行:`pnpm test src/runtime/babylon/snap-features.test.ts` +预期:PASS(4 个 describe 全过)。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/runtime/babylon/snap-features.ts src/runtime/babylon/snap-features.test.ts +git commit -m "feat(babylon): snap feature projection (OBB + Vector3.Project)" +``` + +--- + +## 任务 5:Babylon transform-util(四元数归一)— TDD + +**文件:** + +- 创建:`src/runtime/babylon/transform-util.ts` +- 测试:`src/runtime/babylon/transform-util.test.ts` + +- [ ] **步骤 1:编写失败的测试** + +```ts +// src/runtime/babylon/transform-util.test.ts +import { describe, expect, it } from "vitest"; +import { NullEngine, Scene, TransformNode, Vector3, Quaternion } from "@babylonjs/core"; +import { captureTransform } from "./transform-util"; + +function scene() { + return new Scene(new NullEngine()); +} + +describe("babylon captureTransform", () => { + it("reads position/scaling and rotationQuaternion when present", () => { + const s = scene(); + const n = new TransformNode("n", s); + n.position.set(1, 2, 3); + n.scaling.set(2, 2, 2); + n.rotationQuaternion = new Quaternion(0, 0, 0, 1); + const t = captureTransform(n); + expect(t.position).toEqual([1, 2, 3]); + expect(t.scale).toEqual([2, 2, 2]); + expect(t.rotation).toEqual([0, 0, 0, 1]); + s.getEngine().dispose(); + }); + + it("falls back to Euler rotation when rotationQuaternion is null", () => { + const s = scene(); + const n = new TransformNode("n", s); + n.rotationQuaternion = null; + n.rotation.set(0, Math.PI / 2, 0); + const t = captureTransform(n); + const expected = Quaternion.FromEulerVector(new Vector3(0, Math.PI / 2, 0)); + expect(t.rotation[1]).toBeCloseTo(expected.y, 5); + expect(t.rotation[3]).toBeCloseTo(expected.w, 5); + s.getEngine().dispose(); + }); +}); +``` + +- [ ] **步骤 2:运行验证失败** + +运行:`pnpm test src/runtime/babylon/transform-util.test.ts` +预期:FAIL,模块不存在。 + +- [ ] **步骤 3:实现 transform-util.ts** + +```ts +// src/runtime/babylon/transform-util.ts +import { Quaternion, Vector3, type Node as BabylonNode } from "@babylonjs/core"; + +import type { Transform } from "@/core/scene/types"; + +/** Snapshot a Babylon node's transform. Gizmo rotation writes + * rotationQuaternion, but a node that has never been rotated may still carry a + * null rotationQuaternion + Euler rotation — fall back to converting that so a + * rotate-drag commit captures the real start/end. */ +export function captureTransform(node: BabylonNode): Transform { + const t = node as BabylonNode & { + position?: Vector3; + rotationQuaternion?: Quaternion | null; + rotation?: Vector3; + scaling?: Vector3; + }; + const pos = t.position ?? Vector3.Zero(); + const q = + t.rotationQuaternion ?? + (t.rotation ? Quaternion.FromEulerVector(t.rotation) : null); + const scl = t.scaling ?? new Vector3(1, 1, 1); + return { + position: [pos.x, pos.y, pos.z], + rotation: q ? [q.x, q.y, q.z, q.w] : [0, 0, 0, 1], + scale: [scl.x, scl.y, scl.z], + }; +} +``` + +- [ ] **步骤 4:运行验证通过** + +运行:`pnpm test src/runtime/babylon/transform-util.test.ts` +预期:PASS。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/runtime/babylon/transform-util.ts src/runtime/babylon/transform-util.test.ts +git commit -m "feat(babylon): captureTransform with quaternion normalization" +``` + +--- + +## 任务 6:BabylonRenderHost 实现 gizmo + snap + +**文件:** + +- 修改:`src/runtime/babylon/render-host.ts`(替换 4 个 no-op stub + 加 GizmoManager 接线) +- 测试:`src/runtime/babylon/render-host.test.ts`(加 gizmo 接线断言) + +**关键 Babylon 行为**:`GizmoManager` 的 `positionGizmoEnabled=false` 会**销毁**该 gizmo(连同其 observable)。故每次 `setGizmoMode` 切模式后要**重新挂**活动 gizmo 的 observable(重建后 observable 为空,挂一次不会重复累积)。`attachedNode` 由 GizmoManager 跨模式切换保留。 + +- [ ] **步骤 1:加 import + 字段** + +`render-host.ts` 顶部 import 增补: + +```ts +import { + GizmoManager, + Quaternion, // (若 transform-util 已封装则不需要) + // …已有 ArcRotateCamera/Color3/Color4/Engine/HighlightLayer/Mesh/Vector3/AbstractEngine/Node +} from "@babylonjs/core"; +import { computeSnapOffset } from "@/core/snap/offset"; +import { transformsEqual } from "@/runtime/transform-util"; +import { captureTransform } from "./transform-util"; +import { featureSnapPoints, socketPoints } from "./snap-features"; +import type { GizmoMode } from "@/core/editor-types"; +import type { SnapNode, Transform } from ...; // (已有 GizmoMode/Transform/SnapNode import) +``` + +类字段(加在 highlight 附近): + +```ts + private gizmoManager: GizmoManager | null = null; + private gizmoMode: GizmoMode = "translate"; + private attachedNodeId: string | null = null; + private commitCb: ((id: string, prev: Transform, next: Transform) => void) | null = null; + private snapProvider: (() => SnapNode[]) | null = null; + private dragStart: Transform | null = null; + private cachedTargets: ReturnType = []; + private cachedSocketTargets: ReturnType = []; + private snapModifierDown = false; + private readonly onSnapPointer = (e: PointerEvent) => { + this.snapModifierDown = e.ctrlKey || e.metaKey; + }; +``` + +- [ ] **步骤 2:mount 里建 GizmoManager** + +在 `mount()` 末尾(highlight 之后)加: + +```ts +const gm = new GizmoManager(scene); +gm.usePointerToAttachGizmos = false; // selection drives attach, not clicks +gm.positionGizmoEnabled = false; +gm.rotationGizmoEnabled = false; +gm.scaleGizmoEnabled = false; +this.gizmoManager = gm; +window.addEventListener("pointermove", this.onSnapPointer, true); +window.addEventListener("pointerdown", this.onSnapPointer, true); +``` + +- [ ] **步骤 3:替换 4 个 no-op stub** + +```ts + setGizmoMode(mode: GizmoMode): void { + this.gizmoMode = mode; + const gm = this.gizmoManager; + if (!gm) return; + gm.positionGizmoEnabled = mode === "translate"; + gm.rotationGizmoEnabled = mode === "rotate"; + gm.scaleGizmoEnabled = mode === "scale"; + this.wireActiveGizmo(); + } + + setGizmoTarget(node_id: string | null, locked: boolean): void { + this.attachedNodeId = node_id && !locked ? node_id : null; + const gm = this.gizmoManager; + const adapter = this.adapterInstance; + if (!gm || !adapter) return; + const obj = this.attachedNodeId + ? (adapter.getRuntimeObject(this.attachedNodeId) as BabylonNode | undefined) + : undefined; + gm.attachToNode(obj ?? null); + } + + onTransformCommit(cb: (id: string, prev: Transform, next: Transform) => void): void { + this.commitCb = cb; + } + + setSnapProvider(provider: () => SnapNode[]): void { + this.snapProvider = provider; + } +``` + +- [ ] **步骤 4:加私有 helper(wireActiveGizmo + 三个 drag handler)** + +```ts + /** Re-attach drag observers to the currently-enabled gizmo. Called after + * setGizmoMode because GizmoManager disposes a gizmo (and its observers) + * when its *Enabled flag goes false; the freshly-recreated gizmo starts + * with empty observables, so adding once here never double-fires. */ + private wireActiveGizmo(): void { + const gm = this.gizmoManager; + if (!gm) return; + const g = + this.gizmoMode === "translate" + ? gm.gizmos.positionGizmo + : this.gizmoMode === "rotate" + ? gm.gizmos.rotationGizmo + : gm.gizmos.scaleGizmo; + if (!g) return; + g.onDragStartObservable.add(() => this.onGizmoDragStart()); + g.onDragEndObservable.add(() => this.onGizmoDragEnd()); + if (this.gizmoMode === "translate") { + gm.gizmos.positionGizmo?.onDragObservable.add(() => this.onGizmoDrag()); + } + } + + private draggedNode(): BabylonNode | undefined { + if (!this.attachedNodeId) return undefined; + return this.adapterInstance?.getRuntimeObject(this.attachedNodeId) as + | BabylonNode + | undefined; + } + + private viewportSize(): [number, number] { + const eng = this.babylonEngine; + return [eng?.getRenderWidth() ?? 0, eng?.getRenderHeight() ?? 0]; + } + + private onGizmoDragStart(): void { + const node = this.draggedNode(); + if (!node) return; + this.dragStart = captureTransform(node); + this.highlight?.removeAllMeshes(); // hide selection outline during drag + this.cachedTargets = []; + this.cachedSocketTargets = []; + if (this.gizmoMode !== "translate") return; // only translate snaps + const scene = this.adapterInstance?.scene; + const adapter = this.adapterInstance; + if (!scene || !adapter) return; + const [w, h] = this.viewportSize(); + for (const n of this.snapProvider?.() ?? []) { + if (n.id === this.attachedNodeId || !n.visible || n.type === "helper") continue; + const tobj = adapter.getRuntimeObject(n.id) as BabylonNode | undefined; + if (!tobj) continue; + this.cachedTargets.push(...featureSnapPoints(tobj, scene, w, h)); + this.cachedSocketTargets.push(...socketPoints(tobj, n.sockets, scene, w, h)); + } + } + + private onGizmoDrag(): void { + const node = this.draggedNode() as (BabylonNode & { position: Vector3 }) | undefined; + if (!node || this.gizmoMode !== "translate" || !this.snapModifierDown) return; + const scene = this.adapterInstance?.scene; + if (!scene) return; + const [w, h] = this.viewportSize(); + const draggedNode = this.snapProvider?.().find((n) => n.id === this.attachedNodeId); + const hasSockets = (draggedNode?.sockets ?? []).some((s) => s.tag); + const offset = computeSnapOffset({ + currentPos: [node.position.x, node.position.y, node.position.z], + draggedFeatures: featureSnapPoints(node, scene, w, h), + draggedSockets: socketPoints(node, draggedNode?.sockets ?? [], scene, w, h), + hasSockets, + targetFeatures: this.cachedTargets, + targetSockets: this.cachedSocketTargets, + }); + if (offset) { + node.position.set( + node.position.x + offset[0], + node.position.y + offset[1], + node.position.z + offset[2], + ); + } + } + + private onGizmoDragEnd(): void { + const node = this.draggedNode(); + const start = this.dragStart; + this.dragStart = null; + // Restore the selection outline that onGizmoDragStart hid. + this.setSelection(this.attachedNodeId); + if (!node || !start) return; + const nodeId = this.attachedNodeId; + if (!nodeId) return; + const end = captureTransform(node); + if (transformsEqual(start, end)) return; + this.commitCb?.(nodeId, start, end); + } +``` + +- [ ] **步骤 5:dispose 释放 gizmo + 监听** + +`dispose()` 里(stop 之后、highlight 之前或附近)加: + +```ts +window.removeEventListener("pointermove", this.onSnapPointer, true); +window.removeEventListener("pointerdown", this.onSnapPointer, true); +this.gizmoManager?.dispose(); +this.gizmoManager = null; +this.commitCb = null; +this.snapProvider = null; +``` + +- [ ] **步骤 6:加接线测试(headless)** + +在 `src/runtime/babylon/render-host.test.ts` 加(用现有 NullEngine host fixture 范式): + +```ts +it("setGizmoMode enables exactly the matching gizmo", () => { + // mount host with NullEngine (see existing tests for the createEngine seam) + host.setGizmoMode("rotate"); + // access gizmoManager via a test getter (add one) or assert via behavior +}); +it("setGizmoTarget attaches the node; locked/null detaches", () => { + /* … */ +}); +``` + +> 实现注意:若 render-host.test.ts 现有 fixture 没暴露 gizmoManager,加一个 test-only getter `get gizmoManagerForTest()`(仿现有 `selectionLayer` test getter),断言 `positionGizmoEnabled`/`attachedNode`。保持与既有测试风格一致。 + +- [ ] **步骤 7:验证** + +运行:`pnpm typecheck && pnpm lint && pnpm test src/runtime/babylon/` +预期:typecheck/lint 净;babylon 测试(adapter/render-host/snap-features/transform-util/headless)全绿。 + +- [ ] **步骤 8:Commit** + +```bash +git add src/runtime/babylon/render-host.ts src/runtime/babylon/render-host.test.ts +git commit -m "feat(babylon): GizmoManager gizmo + snap (translate/rotate/scale, 3-layer snap)" +``` + +--- + +## 任务 7:BabylonViewport gizmo 接线 + +**文件:** + +- 修改:`src/ui/viewport/BabylonViewport.tsx` + +镜像 ThreeViewport 薄壳的 gizmo 部分。socket markers / play / focus / asset drop **不接**(B4)。 + +- [ ] **步骤 1:加 import** + +```ts +import { SetNodeTransformCommand } from "@/core/command/commands/set-node-transform"; +import { isEffectivelyLocked } from "@/core/scene/policy"; +import { executeCommand } from "@/services/command-history"; +import type { SnapNode } from "@/runtime/render-host"; +``` + +(已有:useSceneStore / useUIStore / BabylonRenderHost / diffSceneNodes 等。) + +- [ ] **步骤 2:mount effect 内接线(host.mount 之后、host.start 之前)** + +```ts +host.onTransformCommit((id, prev, next) => + executeCommand( + new SetNodeTransformCommand({ node_id: id, transform: next, prev_transform: prev }), + ), +); +host.setSnapProvider(() => { + const nodes = useSceneStore.getState().project?.scene.nodes ?? {}; + return Object.values(nodes).map((n) => ({ + id: n.id, + sockets: n.sockets ?? [], + visible: n.visible, + type: n.type, + })) satisfies SnapNode[]; +}); +const syncGizmoTarget = (id: string | null) => { + const node = id ? useSceneStore.getState().project?.scene.nodes[id] : undefined; + host.setGizmoTarget(id, node ? isEffectivelyLocked(node) : false); +}; +// initial gizmo state +host.setGizmoMode(useUIStore.getState().gizmoMode); +syncGizmoTarget(useUIStore.getState().selectedNodeId); +``` + +- [ ] **步骤 3:扩 UI 订阅** + +现有 BabylonViewport 的 `useUIStore.subscribe` 里 selectedNodeId 变化已调 `host.setSelection`;加: + +```ts +const unsubscribeUI = useUIStore.subscribe((state, prev) => { + if (state.selectedNodeId !== prev.selectedNodeId) { + host.setSelection(state.selectedNodeId); + syncGizmoTarget(state.selectedNodeId); + } + if (state.gizmoMode !== prev.gizmoMode) { + host.setGizmoMode(state.gizmoMode); + } +}); +``` + +(若原来是只订 selectedNodeId 的写法,替换成上面这个含 gizmoMode 的。) + +- [ ] **步骤 4:seedScene/applyDiff 后的 gizmo 重放(与 B2 高亮重放一致)** + +B2 在 applyDiff 后重放 `syncSelection`(host.setSelection)。同理,diff 重建后 gizmo target 也要重挂——在 applyDiff 的 `syncSelection()` 调用处旁补 `syncGizmoTarget(useUIStore.getState().selectedNodeId)`(同 id 重建后重新 attach gizmo 到新节点实例)。 + +- [ ] **步骤 5:验证** + +运行:`pnpm typecheck && pnpm lint && pnpm test` +预期:全绿(含既有 BabylonViewport 测试;若测试断言旧订阅结构,最小适配并说明)。注意 eslint react-hooks(勿在 effect body setState)。 + +- [ ] **步骤 6:Commit** + +```bash +git add src/ui/viewport/BabylonViewport.tsx +git commit -m "feat(viewport): wire Babylon gizmo (target/mode/commit/snap-provider)" +``` + +--- + +## 任务 8:更新 roadmap + +**文件:** + +- 修改:`docs/roadmap.md` + +- [ ] **步骤 1:勾选 B3a/B3b + B3 收口** + +把 B3a 行 `- [ ]` 改 `- [x]` 并补 sha;B3b 行 `- [ ]` 改 `- [x]`;B3 父行标完成。(B3a 已本地合并 sha 56c0b05;B3b 完成后补。) + +- [ ] **步骤 2:Commit** + +```bash +git add docs/roadmap.md +git commit -m "docs(roadmap): B3 gizmo+snap cross-engine complete (B3a + B3b)" +``` + +--- + +## 任务 9:全量验证 + visual smoke + +- [ ] **步骤 1:自动化全绿** + +运行:`pnpm typecheck && pnpm lint && pnpm test` +预期:全 PASS。`grep -rn "isEngineEditingCapable" src/` 为空。 + +- [ ] **步骤 2:visual smoke(`pnpm tauri dev`,切到 Babylon 模式)** + +1. 选中节点 → gizmo 出现(之前 Babylon 拖不动,现在能拖) +2. T/R/S 三模式切换 + 拖动改 transform +3. 按 Ctrl/Cmd 拖动:grid + node-align + socket 三层吸附(socket 无视觉标记是预期,B4 补) +4. 一次连续拖拽 = 一条 undo(Cmd+Z 一步回退) +5. locked 节点:只描边、gizmo 不挂 +6. 拖拽中描边消失、松手恢复 +7. gizmo pill 在 Babylon 模式**不再灰**;play / F focus / 资源拖入在 Babylon **仍灰/无效**(B4) +8. 切回 Three 模式:gizmo/snap/描边/play/focus/drop 全部零回归 +9. 来回切引擎选中态正确;全程 console 零 error/warn + +- [ ] **步骤 3:偏差则修复重跑**;全过后进入收尾(finishing-a-development-branch)。 + +--- + +## 自检记录 + +- **规格覆盖**:§2 能力门→任务 3;§3 同;§4 GizmoManager→任务 6;§5 snap-features→任务 4;§6 transform-util→任务 5;§7 viewport→任务 7;§8 测试→任务 4/5/6 单测 + 任务 9 smoke;§10 交付物全覆盖。**新增**:computeSnapOffset 中立化(任务 1)——规格 §5 隐含「喂同一 computeSnapOffset」但它原住 three/,移到 core/snap 才能让 babylon 不依赖 three;transformsEqual 中立化(任务 2)规格 §6 已列。 +- **占位符**:snap-features/render-host 给完整代码;Babylon 矩阵乘序有不确定性,靠任务 4 的 OBB 旋转单测兜底(计划已标注)。 +- **类型一致**:`computeSnapOffset` 参数对象形状(core/snap/offset 定义)在任务 4/6 一致;`SnapNode` 在任务 7 produce、任务 6 consume 一致;`captureTransform(node)`/`transformsEqual` 在任务 5/2 定义、任务 6 用一致;`engineCapabilities` 返回字段在任务 3 定义、5 处调用一致。 +- **顺序依赖**:1(offset)→4/6;2(transformsEqual)→6;3 独立→7 需要;4/5→6→7。任务编号即执行序。 diff --git a/docs/superpowers/specs/2026-06-16-v1.0b3b-babylon-gizmo-design.md b/docs/superpowers/specs/2026-06-16-v1.0b3b-babylon-gizmo-design.md new file mode 100644 index 0000000..1a6de9a --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-v1.0b3b-babylon-gizmo-design.md @@ -0,0 +1,137 @@ +# v1.0 sub-stage B3b — Babylon gizmo + snap 设计 + +日期:2026-06-16 +分支:`feat/v1.0b3b-babylon-gizmo` +前置:B3a(抽 ThreeRenderHost,定义 IRenderHost 的 gizmo+snap 契约,本地合并 main sha 56c0b05) + +--- + +## 1. 背景与目标 + +B3a 借成熟的 Three 实现把 `IRenderHost` 的 gizmo+snap 契约形式化(`setGizmoMode`/`setGizmoTarget`/`onTransformCommit`/`setSnapProvider`),`BabylonRenderHost` 当前是 4 个 no-op stub、Babylon 模式仍 view-only(能看/转相机/点选高亮,不能拖)。 + +**B3b 目标**:填实 `BabylonRenderHost` 的 gizmo+snap,让 Babylon 模式可拖动(translate/rotate/scale)+ 三层吸附,与 Three 跨引擎对等。**不改契约**(B3a 定义好的)。 + +### 已与用户确认的决策 + +- **能力门细分(方案 A)**:单一布尔 `isEngineEditingCapable` 太粗(conflate gizmo/play/focus/assetDrop),换成 `engineCapabilities(engine): { gizmo, play, focus, assetDrop }`。B3b 设 `gizmo: true`(两引擎),其余 three-only;B4 在同一处翻 play/focus/assetDrop。否决:加 `isGizmoCapable` 并存(概念重叠、长期碎片化)、直接翻 `isEngineEditingCapable(babylon)=true`(连带放开未实现的 play/focus/drop = 回归)。 +- **snap 全三层(方案 B)**:grid + node-align + socket snap 都做,与 Three 完全对等。`computeSnapOffset`(B3a 引擎中立纯函数)已编排三层链,Babylon 只需写特征提取。**socket markers 视觉仍是 B4**——本期 Babylon 会「吸到无视觉标记的 socket」(功能对等,观感半成品,用户接受)。否决:grid+node(socket 留 B4)、仅 grid。 +- **gizmo 实现 = `GizmoManager`**(计划期 NullEngine 实验证 headless 可跑),`usePointerToAttachGizmos = false`(选中由 store 驱动)。 +- **API 风险已 de-risk(计划期 node 实验)**:GizmoManager headless 实例化/attach/dispose OK;`positionGizmo.onDragStart/onDrag/onDragEnd` observable 齐全;OBB(`getBoundingInfo().boundingBox`)+ 屏幕投影(`Vector3.Project` + `scene.updateTransformMatrix()`)headless 可用。 + +--- + +## 2. 范围 + +### In + +- `engineCapabilities(engine)` 替换 `isEngineEditingCapable`(`src/runtime/render-host.ts`)+ 改 5 处调用点。 +- `BabylonRenderHost` 填实 4 个 gizmo 方法(GizmoManager + 修饰键追踪 + snap + 描边隐藏/恢复)。 +- 新 `src/runtime/babylon/snap-features.ts`(OBB 特征提取 + 投影 + socketPoints)+ 单测。 +- 新 `src/runtime/babylon/transform-util.ts`(Babylon 节点 transform 捕获,四元数归一)+ 单测。 +- `BabylonViewport` gizmo 接线(setGizmoMode/setGizmoTarget/onTransformCommit/setSnapProvider)。 +- roadmap B3b 勾选 + B3 收口。 + +### Out(B4 及以后) + +- Babylon socket markers 视觉、asset drop 落点、F focus、play 行为预览、底色 sRGB/光强对齐。 +- Three vs Babylon 完整 conformance gizmo 套件(snap-features 对等断言是 nice-to-have,非必须)。 + +--- + +## 3. 能力门细分 + +`src/runtime/render-host.ts`: + +```ts +export interface EngineCapabilities { + gizmo: boolean; // B3b: 两引擎 true + play: boolean; // B4: three-only + focus: boolean; // B4: three-only + assetDrop: boolean; // B4: three-only +} +export function engineCapabilities(engine: ViewportEngine): EngineCapabilities { + const three = engine === "three.js"; + return { gizmo: true, play: three, focus: three, assetDrop: three }; +} +``` + +改 5 处调用点(grep `isEngineEditingCapable`): + +- `EditorView.tsx:121` GizmoModeToolbar `disabled` → `selectedNodeId === null || !engineCapabilities(viewportEngine).gizmo`。 +- `PlayButton.tsx:12` → `engineCapabilities(viewportEngine).play`。 +- `use-editor-shortcuts.ts:115`(F focus)→ `.focus`;`:125`(Space/play)→ `.play`。 +- `asset-drag.ts:22` → `.assetDrop`。 +- `render-host.test.ts` 改测 `engineCapabilities`。 + +> 删除 `isEngineEditingCapable`(无遗留调用点后)。 + +## 4. GizmoManager 接线(BabylonRenderHost) + +`mount` 时建 `this.gizmoManager = new GizmoManager(scene)`,`usePointerToAttachGizmos = false`。 + +- `setGizmoMode(mode)`:`positionGizmoEnabled = mode==="translate"`,rotation/scale 同理(互斥)。 +- `setGizmoTarget(node_id, locked)`:`obj = adapter.getRuntimeObject(id)`;`gizmoManager.attachToNode((obj && !locked) ? obj : null)`。 +- **拖拽生命周期**:gizmo lazy 创建,需在三 gizmo 都 enable 一次以实例化并挂 observable(plan 期细化稳妥写法),挂: + - `onDragStartObservable`(三 gizmo):`dragStart = captureTransform(attachedNode)`;若 translate 且修饰键,经 `snapProvider()` 缓存其它节点的 `featureSnapPoints` + `socketPoints`(屏幕投影,相机此刻冻结)。 + - `onDragObservable`(仅 positionGizmo):修饰键按下 → 对 `attachedNode.position` 应用 `computeSnapOffset`(socket→node→grid 链,与 Three 同)。 + - `onDragEndObservable`(三 gizmo):`end = captureTransform`;`transformsEqual(start,end)` 则跳过;否则 `commitCb(nodeId, start, end)`(nodeId 取 `node.metadata.nodeId`)。 +- 修饰键追踪:window `pointermove`/`pointerdown` capture 读 `ctrlKey||metaKey`(同 Three,避免 keydown/keyup 卡死)。 +- 拖拽中 `highlight.removeAllMeshes()`,松手按当前选中恢复(对齐 Three 拖拽期隐藏描边)。 +- dispose:`gizmoManager.dispose()` + 注销 window 监听。 + +## 5. Babylon snap-features(新文件,喂共享 computeSnapOffset) + +`src/runtime/babylon/snap-features.ts`,镜像 `runtime/three/snap-features.ts` 的签名,产同样的 `SnapPoint`/`SocketPoint`(来自 `@/core/snap/*`): + +- `bboxFeatures(node): Vector3[]`:OBB 15 点(中心+8角+6面心,顺序与 Three 一致——node-align 跨引擎对等需顺序一致)。用 `getBoundingInfo().boundingBox` 的 local min/max 算 15 个局部点,经 node world matrix 变换到世界(跟随旋转 = OBB)。无几何 → `[]`。 +- `toScreen(v, scene, w, h)`:`Vector3.Project(v, Matrix.IdentityReadOnly, scene.getTransformMatrix(), {x:0,y:0,width:w,height:h})` 取 x/y。 +- `featureSnapPoints(node, scene, w, h): SnapPoint[]` / `socketPoints(node, sockets, scene, w, h): SocketPoint[]`:同 Three 语义(socket 世界点经 node world matrix)。 + +> 注意:Three 版签名带 `camera`,Babylon 版用 `scene`(Babylon 投影走 `scene.getTransformMatrix()`,相机是 `scene.activeCamera`)。两版各自引擎专有,喂同一个 `computeSnapOffset(args)`(args 是引擎中立的 `SnapPoint[]`/`SocketPoint[]`/`Vec3`)。 + +## 6. Babylon transform-util(新文件) + +`src/runtime/babylon/transform-util.ts`: + +- `captureTransform(node): Transform`:读 `node.position` / `node.rotationQuaternion ?? Quaternion.FromEulerVector(node.rotation)` / `node.scaling`,rotation 统一成四元数 `[x,y,z,w]`(gizmo 旋转写 `rotationQuaternion`,但 Euler 节点首次旋转前 `rotationQuaternion` 可能为 null,须从 `rotation` 转)。 +- 复用 B3a 的引擎中立 `transformsEqual`(`@/runtime/three/transform-util`)?——`transformsEqual` 是纯数组比较、与引擎无关,但住在 `runtime/three`。**搬到中立位置** `src/core/...` 或就近复用?决定:`transformsEqual` 移到 `src/runtime/transform-util.ts`(引擎中立,Three/Babylon 都引),`captureTransform` 各引擎一份(读各自 node)。最小改动:B3b 把 `transformsEqual` 从 `three/transform-util` 提到 `runtime/transform-util`,更新两处 import。 + +## 7. BabylonViewport gizmo 接线 + +mount effect 内(host.mount 之后)镜像 ThreeViewport 薄壳的 gizmo 部分: + +- `host.onTransformCommit((id, prev, next) => executeCommand(new SetNodeTransformCommand({ node_id: id, transform: next, prev_transform: prev })))`。 +- `host.setSnapProvider(() => Object.values(nodes).map(n => ({ id, sockets: n.sockets ?? [], visible, type })))`(同 Three)。 +- 订阅 `useUIStore`:`selectedNodeId` 变 → `host.setGizmoTarget(id, node ? isEffectivelyLocked(node) : false)`(host.setSelection 已在 B2 接);`gizmoMode` 变 → `host.setGizmoMode`。 +- 初始:mount 后 `host.setGizmoMode(useUIStore.getState().gizmoMode)` + `host.setGizmoTarget(selectedNodeId, locked)`。 +- **不接** socket markers / play / focus / asset drop(B4)。 + +## 8. 测试与验证 + +- 单测(headless): + - `babylon/snap-features.test.ts`:bboxFeatures 15 点 / OBB 旋转面心 / 空 node [];toScreen 投影(origin→视口中心);featureSnapPoints/socketPoints。仿 `three/snap-features.test.ts`。 + - `babylon/transform-util.test.ts`:rotationQuaternion 与 Euler 两路捕获。 + - `render-host.test.ts`:`engineCapabilities` 表(gizmo 两引擎 true,play/focus/assetDrop three-only)。 + - GizmoManager 接线轻测(headless):setGizmoTarget 后 attachedNode 正确、setGizmoMode 切 enabled、locked/null detach。 +- 共享 `computeSnapOffset` 已在 B3a 测(三分支)。 +- nice-to-have:Three vs Babylon snap-features 对等断言(等价场景+相机产等价 offset)。 +- **visual smoke(`pnpm tauri dev`,Babylon 模式)**:① 选中后 gizmo 出现;② T/R/S 三模式拖动;③ Ctrl/Cmd 三层吸附(grid/node/socket);④ 一次拖拽=单次 undo;⑤ locked 只描边不挂 gizmo;⑥ 拖拽中描边隐藏、松手恢复;⑦ gizmo pill 在 Babylon 不再灰、play/F/drop 仍灰;⑧ Three 模式零回归;⑨ console 零错。 + +## 9. 风险 + +- GizmoManager 三 gizmo lazy 创建——observable 须在各 gizmo 实例化后挂全(plan 期给稳妥写法:enable 三次以实例化或用 gizmoManager 的 gizmo getter)。 +- Euler 节点首次旋转 `rotationQuaternion` 为 null → captureTransform 须 fallback 到 `rotation`。 +- 屏幕投影依赖相机矩阵已更新(真机拖拽时已 render;测试手动 `updateTransformMatrix`)。 +- node-align 跨引擎对等要求 bboxFeatures 15 点**顺序与 Three 完全一致**(computeSnapOffset 按最近点选,顺序不一致会选到不同特征但结果应等价;仍保持一致以防偏差)。 + +## 10. 交付物 + +- `src/runtime/render-host.ts`:`EngineCapabilities` + `engineCapabilities`,删 `isEngineEditingCapable`。 +- `src/runtime/babylon/render-host.ts`:4 个 gizmo 方法实现 + GizmoManager 字段 + 修饰键监听 + dispose。 +- `src/runtime/babylon/snap-features.ts`(+ 测试)。 +- `src/runtime/babylon/transform-util.ts`(+ 测试)。 +- `src/runtime/transform-util.ts`:`transformsEqual` 中立化(从 `three/transform-util` 提出),更新 import。 +- `src/ui/viewport/BabylonViewport.tsx`:gizmo 接线。 +- 5 处能力门调用点 + `render-host.test.ts`。 +- `docs/roadmap.md`:B3b 勾选 + B3 收口。 diff --git a/src/core/snap/offset.ts b/src/core/snap/offset.ts new file mode 100644 index 0000000..2537ff1 --- /dev/null +++ b/src/core/snap/offset.ts @@ -0,0 +1,31 @@ +import { snapTranslation } from "./grid"; +import { SNAP_PIXELS, snapToNodes, type SnapPoint } from "./nodes"; +import { snapToSockets, type SocketPoint } from "./sockets"; + +type Vec3 = [number, number, number]; + +/** + * Pure snap priority chain: socket-align → node-align (only when the dragged + * node has no tagged socket) → grid fallback. Returns the world-space offset to + * apply to the dragged object's position. Engine-neutral — both ThreeRenderHost + * and BabylonRenderHost feed it engine-specific SnapPoint[]/SocketPoint[]. The + * caller gates on translate-mode + modifier before calling. + */ +export function computeSnapOffset(args: { + currentPos: Vec3; + draggedFeatures: SnapPoint[]; + draggedSockets: SocketPoint[]; + hasSockets: boolean; + targetFeatures: SnapPoint[]; + targetSockets: SocketPoint[]; +}): Vec3 | null { + const { currentPos, draggedFeatures, draggedSockets, hasSockets } = args; + const socketOffset = snapToSockets(draggedSockets, args.targetSockets, SNAP_PIXELS); + if (socketOffset) return socketOffset; + if (!hasSockets) { + const offset = snapToNodes(draggedFeatures, args.targetFeatures, SNAP_PIXELS); + if (offset) return offset; + } + const [gx, gy, gz] = snapTranslation(currentPos); + return [gx - currentPos[0], gy - currentPos[1], gz - currentPos[2]]; +} diff --git a/src/runtime/babylon/render-host.test.ts b/src/runtime/babylon/render-host.test.ts index b32fb2e..9f4d250 100644 --- a/src/runtime/babylon/render-host.test.ts +++ b/src/runtime/babylon/render-host.test.ts @@ -1,5 +1,5 @@ import { ArcRotateCamera, Mesh, NullEngine } from "@babylonjs/core"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { SceneNode } from "@/core/scene/types"; @@ -110,4 +110,143 @@ describe("BabylonRenderHost", () => { expect(() => host.setSelection("box")).not.toThrow(); }); }); + + describe("gizmo wiring (v1.0 B3b)", () => { + function mounted() { + const { host } = makeHost(); + host.mount(document.createElement("canvas")); + return host; + } + + it("mount creates a GizmoManager with all gizmos disabled", () => { + const host = mounted(); + const gm = host.gizmoManagerForTest!; + expect(gm).not.toBeNull(); + expect(gm.positionGizmoEnabled).toBe(false); + expect(gm.rotationGizmoEnabled).toBe(false); + expect(gm.scaleGizmoEnabled).toBe(false); + host.dispose(); + }); + + it("setGizmoMode('translate') enables only the position gizmo", () => { + const host = mounted(); + host.setGizmoMode("translate"); + const gm = host.gizmoManagerForTest!; + expect(gm.positionGizmoEnabled).toBe(true); + expect(gm.rotationGizmoEnabled).toBe(false); + expect(gm.scaleGizmoEnabled).toBe(false); + host.dispose(); + }); + + it("translate gizmo enables plane-drag handles (not just axis arrows)", () => { + const host = mounted(); + host.setGizmoMode("translate"); + // planarGizmoEnabled adds the XY/YZ/XZ plane handles so the user can drag + // on a plane, matching Three's TransformControls. + expect(host.gizmoManagerForTest!.gizmos.positionGizmo!.planarGizmoEnabled).toBe( + true, + ); + host.dispose(); + }); + + it("setGizmoMode('rotate') enables only the rotation gizmo", () => { + const host = mounted(); + host.setGizmoMode("rotate"); + const gm = host.gizmoManagerForTest!; + expect(gm.positionGizmoEnabled).toBe(false); + expect(gm.rotationGizmoEnabled).toBe(true); + expect(gm.scaleGizmoEnabled).toBe(false); + host.dispose(); + }); + + it("setGizmoMode('scale') enables only the scale gizmo", () => { + const host = mounted(); + host.setGizmoMode("scale"); + const gm = host.gizmoManagerForTest!; + expect(gm.positionGizmoEnabled).toBe(false); + expect(gm.rotationGizmoEnabled).toBe(false); + expect(gm.scaleGizmoEnabled).toBe(true); + host.dispose(); + }); + + it("setGizmoMode can switch modes without throwing", () => { + const host = mounted(); + expect(() => { + host.setGizmoMode("translate"); + host.setGizmoMode("rotate"); + host.setGizmoMode("scale"); + host.setGizmoMode("translate"); + }).not.toThrow(); + host.dispose(); + }); + + it("does not accumulate drag observers when a mode is re-selected", () => { + // GizmoManager reuses gizmo instances across *Enabled toggles, so + // re-entering translate must NOT add a second onDragStart observer + // (otherwise captureTransform + commit fire twice per drag). + const host = mounted(); + host.setGizmoMode("translate"); + host.setGizmoMode("rotate"); + host.setGizmoMode("translate"); // reuse the same positionGizmo instance + const pg = host.gizmoManagerForTest!.gizmos.positionGizmo!; + expect(pg.onDragStartObservable.observers).toHaveLength(1); + expect(pg.onDragEndObservable.observers).toHaveLength(1); + expect(pg.onDragObservable.observers).toHaveLength(1); + host.dispose(); + }); + + it("setGizmoTarget with locked=true detaches (attachedNodeId=null)", () => { + const host = mounted(); + host.adapter.syncNode(boxNode("box"), "add"); + host.setGizmoMode("translate"); + // locked node: should not attach + host.setGizmoTarget("box", true); + expect(host.gizmoManagerForTest!.attachedNode).toBeNull(); + host.dispose(); + }); + + it("setGizmoTarget with null detaches", () => { + const host = mounted(); + host.adapter.syncNode(boxNode("box"), "add"); + host.setGizmoMode("translate"); + host.setGizmoTarget(null, false); + expect(host.gizmoManagerForTest!.attachedNode).toBeNull(); + host.dispose(); + }); + + it("setGizmoTarget with valid id and locked=false attaches the node", () => { + const host = mounted(); + host.adapter.syncNode(boxNode("box"), "add"); + host.setGizmoMode("translate"); + host.setGizmoTarget("box", false); + expect(host.gizmoManagerForTest!.attachedNode).not.toBeNull(); + host.dispose(); + }); + + it("onTransformCommit and setSnapProvider do not throw", () => { + const host = mounted(); + expect(() => { + host.onTransformCommit(vi.fn()); + host.setSnapProvider(() => []); + }).not.toThrow(); + host.dispose(); + }); + + it("dispose cleans up GizmoManager without throwing", () => { + const host = mounted(); + host.setGizmoMode("translate"); + expect(() => host.dispose()).not.toThrow(); + expect(host.gizmoManagerForTest).toBeNull(); + }); + + it("setGizmoMode before mount is a no-op (no throw)", () => { + const { host } = makeHost(); + expect(() => host.setGizmoMode("rotate")).not.toThrow(); + }); + + it("setGizmoTarget before mount is a no-op (no throw)", () => { + const { host } = makeHost(); + expect(() => host.setGizmoTarget("box", false)).not.toThrow(); + }); + }); }); diff --git a/src/runtime/babylon/render-host.ts b/src/runtime/babylon/render-host.ts index 76e5400..233501d 100644 --- a/src/runtime/babylon/render-host.ts +++ b/src/runtime/babylon/render-host.ts @@ -3,6 +3,7 @@ import { Color3, Color4, Engine, + GizmoManager, HighlightLayer, Mesh, Vector3, @@ -14,7 +15,11 @@ import type { IRenderHost, SnapNode } from "@/runtime/render-host"; import type { GizmoMode } from "@/core/editor-types"; import type { Transform } from "@/core/scene/types"; +import { computeSnapOffset } from "@/core/snap/offset"; +import { transformsEqual } from "@/runtime/transform-util"; import { BabylonAdapter } from "./adapter"; +import { featureSnapPoints, socketPoints } from "./snap-features"; +import { captureTransform } from "./transform-util"; export interface BabylonRenderHostOptions { /** Test seam — defaults to a real WebGL Engine on the mounted canvas. @@ -42,6 +47,25 @@ export class BabylonRenderHost implements IRenderHost { private adapterInstance: BabylonAdapter | null = null; private highlight: HighlightLayer | null = null; + // ── Gizmo state (B3b) ── + private gizmoManager: GizmoManager | null = null; + private gizmoMode: GizmoMode = "translate"; + /** Gizmo instances we've already attached drag observers to. GizmoManager + * reuses (does NOT dispose) a gizmo when its *Enabled flag toggles, so + * wiring must be idempotent or observers accumulate across mode switches. */ + private readonly wiredGizmos = new WeakSet(); + private attachedNodeId: string | null = null; + private commitCb: ((id: string, prev: Transform, next: Transform) => void) | null = + null; + private snapProvider: (() => SnapNode[]) | null = null; + private dragStart: Transform | null = null; + private cachedTargets: ReturnType = []; + private cachedSocketTargets: ReturnType = []; + private snapModifierDown = false; + private readonly onSnapPointer = (e: PointerEvent) => { + this.snapModifierDown = e.ctrlKey || e.metaKey; + }; + constructor(options?: BabylonRenderHostOptions) { this.createEngine = options?.createEngine ?? ((canvas) => new Engine(canvas, true)); } @@ -55,6 +79,11 @@ export class BabylonRenderHost implements IRenderHost { return this.adapterInstance; } + /** Test-only surface: lets unit tests inspect the GizmoManager. */ + get gizmoManagerForTest(): GizmoManager | null { + return this.gizmoManager; + } + mount(canvas: HTMLCanvasElement): void { const engine = this.createEngine(canvas); this.babylonEngine = engine; @@ -79,6 +108,22 @@ export class BabylonRenderHost implements IRenderHost { this.highlight = new HighlightLayer("selection-highlight", scene); scene.activeCamera = camera; this.camera = camera; + + // GizmoManager — usePointerToAttachGizmos=false because selection is + // driven by the editor store (setGizmoTarget), not pointer picking. + // All three gizmo flags start false; setGizmoMode enables the right one. + // No manual camera detach needed during drag: Babylon gizmos use + // PointerDragBehavior, which has detachCameraControls=true by default, so + // the ArcRotateCamera stops orbiting for the drag's duration on its own + // (unlike Three, where ThreeRenderHost must toggle orbit.enabled). + const gm = new GizmoManager(scene); + gm.usePointerToAttachGizmos = false; + gm.positionGizmoEnabled = false; + gm.rotationGizmoEnabled = false; + gm.scaleGizmoEnabled = false; + this.gizmoManager = gm; + window.addEventListener("pointermove", this.onSnapPointer, true); + window.addEventListener("pointerdown", this.onSnapPointer, true); } start(): void { @@ -117,24 +162,149 @@ export class BabylonRenderHost implements IRenderHost { } } - // ── Gizmo + snap (B3b will implement; Babylon is view-only until then) ── - setGizmoMode(_mode: GizmoMode): void { - // no-op until B3b adds the Babylon GizmoManager + // ── Gizmo + snap (B3b) ── + + setGizmoMode(mode: GizmoMode): void { + this.gizmoMode = mode; + const gm = this.gizmoManager; + if (!gm) return; + // Toggling *Enabled creates the gizmo on first true (and reuses it after); + // false only detaches it. Wire its observers once it exists (idempotent — + // see wireActiveGizmo). + gm.positionGizmoEnabled = mode === "translate"; + gm.rotationGizmoEnabled = mode === "rotate"; + gm.scaleGizmoEnabled = mode === "scale"; + this.wireActiveGizmo(); + } + + setGizmoTarget(node_id: string | null, locked: boolean): void { + this.attachedNodeId = node_id && !locked ? node_id : null; + const gm = this.gizmoManager; + const adapter = this.adapterInstance; + if (!gm || !adapter) return; + const obj = this.attachedNodeId + ? (adapter.getRuntimeObject(this.attachedNodeId) as BabylonNode | undefined) + : undefined; + gm.attachToNode(obj ?? null); } - setGizmoTarget(_node_id: string | null, _locked: boolean): void { - // no-op until B3b + + onTransformCommit(cb: (id: string, prev: Transform, next: Transform) => void): void { + this.commitCb = cb; } - onTransformCommit( - _cb: (node_id: string, prev: Transform, next: Transform) => void, - ): void { - // no-op until B3b + + setSnapProvider(provider: () => SnapNode[]): void { + this.snapProvider = provider; } - setSnapProvider(_provider: () => SnapNode[]): void { - // no-op until B3b + + /** Attach drag observers to the currently-enabled gizmo, once per gizmo + * instance. GizmoManager reuses (does not dispose) a gizmo across *Enabled + * toggles, so without the wiredGizmos guard every mode switch would stack + * another observer and fire onDragStart/End/Drag multiple times per drag. */ + private wireActiveGizmo(): void { + const gm = this.gizmoManager; + if (!gm) return; + if (this.gizmoMode === "translate") { + const pg = gm.gizmos.positionGizmo; + if (!pg || this.wiredGizmos.has(pg)) return; + this.wiredGizmos.add(pg); + // Show the plane-drag handles too (the axis arrows alone only allow + // single-axis drag); matches Three's TransformControls which has both + // axis and plane handles. Plane drags fire the same onDrag* observables. + pg.planarGizmoEnabled = true; + pg.onDragStartObservable.add(() => this.onGizmoDragStart()); + pg.onDragEndObservable.add(() => this.onGizmoDragEnd()); + pg.onDragObservable.add(() => this.onGizmoDrag()); // snap, translate only + return; + } + const g = + this.gizmoMode === "rotate" ? gm.gizmos.rotationGizmo : gm.gizmos.scaleGizmo; + if (!g || this.wiredGizmos.has(g)) return; + this.wiredGizmos.add(g); + g.onDragStartObservable.add(() => this.onGizmoDragStart()); + g.onDragEndObservable.add(() => this.onGizmoDragEnd()); + } + + private draggedNode(): BabylonNode | undefined { + if (!this.attachedNodeId) return undefined; + return this.adapterInstance?.getRuntimeObject(this.attachedNodeId) as + | BabylonNode + | undefined; + } + + private viewportSize(): [number, number] { + const eng = this.babylonEngine; + return [eng?.getRenderWidth() ?? 0, eng?.getRenderHeight() ?? 0]; + } + + private onGizmoDragStart(): void { + const node = this.draggedNode(); + if (!node) return; + this.dragStart = captureTransform(node); + this.highlight?.removeAllMeshes(); // hide selection outline during drag + this.cachedTargets = []; + this.cachedSocketTargets = []; + if (this.gizmoMode !== "translate") return; // only translate snaps + const scene = this.adapterInstance?.scene; + const adapter = this.adapterInstance; + if (!scene || !adapter) return; + const [w, h] = this.viewportSize(); + for (const n of this.snapProvider?.() ?? []) { + if (n.id === this.attachedNodeId || !n.visible || n.type === "helper") continue; + const tobj = adapter.getRuntimeObject(n.id) as BabylonNode | undefined; + if (!tobj) continue; + this.cachedTargets.push(...featureSnapPoints(tobj, scene, w, h)); + this.cachedSocketTargets.push(...socketPoints(tobj, n.sockets, scene, w, h)); + } + } + + private onGizmoDrag(): void { + const node = this.draggedNode() as + | (BabylonNode & { position: Vector3 }) + | undefined; + if (!node || this.gizmoMode !== "translate" || !this.snapModifierDown) return; + const scene = this.adapterInstance?.scene; + if (!scene) return; + const [w, h] = this.viewportSize(); + const draggedNode = this.snapProvider?.().find((n) => n.id === this.attachedNodeId); + const hasSockets = (draggedNode?.sockets ?? []).some((s) => s.tag); + const offset = computeSnapOffset({ + currentPos: [node.position.x, node.position.y, node.position.z], + draggedFeatures: featureSnapPoints(node, scene, w, h), + draggedSockets: socketPoints(node, draggedNode?.sockets ?? [], scene, w, h), + hasSockets, + targetFeatures: this.cachedTargets, + targetSockets: this.cachedSocketTargets, + }); + if (offset) { + node.position.set( + node.position.x + offset[0], + node.position.y + offset[1], + node.position.z + offset[2], + ); + } + } + + private onGizmoDragEnd(): void { + const node = this.draggedNode(); + const start = this.dragStart; + this.dragStart = null; + this.setSelection(this.attachedNodeId); // restore outline hidden on drag start + if (!node || !start) return; + const nodeId = this.attachedNodeId; + if (!nodeId) return; + const end = captureTransform(node); + if (transformsEqual(start, end)) return; + this.commitCb?.(nodeId, start, end); } dispose(): void { this.stop(); + window.removeEventListener("pointermove", this.onSnapPointer, true); + window.removeEventListener("pointerdown", this.onSnapPointer, true); + this.gizmoManager?.dispose(); + this.gizmoManager = null; + this.commitCb = null; + this.snapProvider = null; this.highlight?.dispose(); this.highlight = null; this.camera?.dispose(); diff --git a/src/runtime/babylon/snap-features.test.ts b/src/runtime/babylon/snap-features.test.ts new file mode 100644 index 0000000..fcb4e88 --- /dev/null +++ b/src/runtime/babylon/snap-features.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { + NullEngine, + Scene, + ArcRotateCamera, + Vector3, + MeshBuilder, + TransformNode, +} from "@babylonjs/core"; +import { + bboxFeatures, + toScreen, + socketPoints, + featureSnapPoints, +} from "./snap-features"; + +function makeScene() { + const engine = new NullEngine(); + const scene = new Scene(engine); + const cam = new ArcRotateCamera("c", 0, 1, 8, Vector3.Zero(), scene); + cam.setPosition(new Vector3(4, 3, 4)); + scene.activeCamera = cam; + cam.getViewMatrix(); + scene.updateTransformMatrix(); + return { engine, scene }; +} + +describe("babylon bboxFeatures (OBB)", () => { + it("returns 15 world features for a unit box, center first at origin", () => { + const { scene, engine } = makeScene(); + const box = MeshBuilder.CreateBox("b", { size: 2 }, scene); + box.computeWorldMatrix(true); + const pts = bboxFeatures(box); + expect(pts).toHaveLength(15); + expect(pts[0]!.x).toBeCloseTo(0); + expect(pts[0]!.y).toBeCloseTo(0); + expect(pts[0]!.z).toBeCloseTo(0); + engine.dispose(); + }); + + it("rotates +X face center with the object (OBB not AABB)", () => { + const { scene, engine } = makeScene(); + const box = MeshBuilder.CreateBox("b", { size: 2 }, scene); + box.rotation.y = Math.PI / 4; + box.computeWorldMatrix(true); + const pts = bboxFeatures(box); + const fc = pts[9]!; // +X face center (same index as Three) + expect(Math.hypot(fc.x, fc.z)).toBeCloseTo(1, 4); + engine.dispose(); + }); + + it("returns [] for a transform node with no mesh", () => { + const { scene, engine } = makeScene(); + const tn = new TransformNode("t", scene); + expect(bboxFeatures(tn)).toEqual([]); + engine.dispose(); + }); +}); + +describe("babylon toScreen", () => { + it("projects world origin to viewport center", () => { + const { scene, engine } = makeScene(); + const [x, y] = toScreen(Vector3.Zero(), scene, 800, 600); + expect(x).toBeCloseTo(400, 0); + expect(y).toBeCloseTo(300, 0); + engine.dispose(); + }); +}); + +describe("babylon socketPoints", () => { + it("maps socket local position through the node world matrix", () => { + const { scene, engine } = makeScene(); + const box = MeshBuilder.CreateBox("b", { size: 2 }, scene); + box.position.set(5, 0, 0); + box.computeWorldMatrix(true); + const pts = socketPoints( + box, + [{ id: "s", name: "s", position: [0, 1, 0], tag: "t" }], + scene, + 800, + 600, + ); + expect(pts).toHaveLength(1); + expect(pts[0]!.world[0]).toBeCloseTo(5); + expect(pts[0]!.world[1]).toBeCloseTo(1); + engine.dispose(); + }); +}); + +describe("babylon featureSnapPoints", () => { + it("pairs each bbox feature's world point with its screen projection", () => { + const { scene, engine } = makeScene(); + const box = MeshBuilder.CreateBox("b", { size: 2 }, scene); + box.computeWorldMatrix(true); + const pts = featureSnapPoints(box, scene, 800, 600); + expect(pts).toHaveLength(15); + // center (index 0) is the world origin → projects to viewport center. + expect(pts[0]!.world).toEqual([0, 0, 0]); + expect(pts[0]!.screen[0]).toBeCloseTo(400, 0); + expect(pts[0]!.screen[1]).toBeCloseTo(300, 0); + engine.dispose(); + }); +}); diff --git a/src/runtime/babylon/snap-features.ts b/src/runtime/babylon/snap-features.ts new file mode 100644 index 0000000..4645536 --- /dev/null +++ b/src/runtime/babylon/snap-features.ts @@ -0,0 +1,138 @@ +import { + AbstractMesh, + Matrix, + Vector3, + Viewport, + type Node as BabylonNode, + type Scene, +} from "@babylonjs/core"; + +import type { SnapPoint } from "@/core/snap/nodes"; +import type { SocketPoint } from "@/core/snap/sockets"; +import type { Socket } from "@/core/scene/types"; + +/** Descendant meshes of a node, including the node itself when it is a mesh. */ +function meshesOf(node: BabylonNode): AbstractMesh[] { + const children = node.getChildMeshes(false); + return node instanceof AbstractMesh + ? [node, ...children.filter((m) => m !== node)] + : children; +} + +/** 15 OBB features (center + 8 corners + 6 face centers) in WORLD space; [] + * when the node has no mesh geometry. Mirrors three/snap-features.bboxFeatures: + * accumulate each descendant mesh's local box into the dragged node's local + * space, then transform the 15 local points by the node world matrix so they + * follow rotation (OBB, not world AABB). Index order MUST match the Three + * version for cross-engine node-align parity. */ +export function bboxFeatures(node: BabylonNode): Vector3[] { + node.computeWorldMatrix(true); + const world = node.getWorldMatrix(); + const invWorld = Matrix.Invert(world); + let min: Vector3 | null = null; + let max: Vector3 | null = null; + + const expand = (p: Vector3) => { + if (!min || !max) { + min = p.clone(); + max = p.clone(); + } else { + min = Vector3.Minimize(min, p); + max = Vector3.Maximize(max, p); + } + }; + + for (const mesh of meshesOf(node)) { + mesh.computeWorldMatrix(true); + const bb = mesh.getBoundingInfo().boundingBox; + // mesh-local → node-local = meshWorld · invNodeWorld (Babylon row-vector convention) + const rel = mesh.getWorldMatrix().multiply(invWorld); + const lo = bb.minimum; + const hi = bb.maximum; + for (const x of [lo.x, hi.x]) + for (const y of [lo.y, hi.y]) + for (const z of [lo.z, hi.z]) + expand(Vector3.TransformCoordinates(new Vector3(x, y, z), rel)); + } + + if (!min || !max) return []; + const lmin: Vector3 = min; + const lmax: Vector3 = max; + const c = Vector3.Center(lmin, lmax); + + // Same index order as Three version: center, 8 corners, 6 face centers. + const local: Vector3[] = [ + c, + new Vector3(lmin.x, lmin.y, lmin.z), + new Vector3(lmin.x, lmin.y, lmax.z), + new Vector3(lmin.x, lmax.y, lmin.z), + new Vector3(lmin.x, lmax.y, lmax.z), + new Vector3(lmax.x, lmin.y, lmin.z), + new Vector3(lmax.x, lmin.y, lmax.z), + new Vector3(lmax.x, lmax.y, lmin.z), + new Vector3(lmax.x, lmax.y, lmax.z), + // 6 face centers (index 9–14): +X, -X, +Y, -Y, +Z, -Z + new Vector3(lmax.x, c.y, c.z), + new Vector3(lmin.x, c.y, c.z), + new Vector3(c.x, lmax.y, c.z), + new Vector3(c.x, lmin.y, c.z), + new Vector3(c.x, c.y, lmax.z), + new Vector3(c.x, c.y, lmin.z), + ]; + + return local.map((p) => Vector3.TransformCoordinates(p, world)); +} + +/** World point → screen pixels. Caller ensures scene transform matrix is + * current (render loop updates it; tests call scene.updateTransformMatrix). */ +export function toScreen( + v: Vector3, + scene: Scene, + w: number, + h: number, +): [number, number] { + const p = Vector3.Project( + v, + Matrix.IdentityReadOnly, + scene.getTransformMatrix(), + new Viewport(0, 0, w, h), + ); + return [p.x, p.y]; +} + +/** A node's bbox features → SnapPoint[] (screen + world). */ +export function featureSnapPoints( + node: BabylonNode, + scene: Scene, + w: number, + h: number, +): SnapPoint[] { + return bboxFeatures(node).map((v) => ({ + screen: toScreen(v, scene, w, h), + world: [v.x, v.y, v.z] as [number, number, number], + })); +} + +/** A node + its sockets → SocketPoint[] (world point via node world matrix, with tag). */ +export function socketPoints( + node: BabylonNode, + sockets: readonly Socket[], + scene: Scene, + w: number, + h: number, +): SocketPoint[] { + if (sockets.length === 0) return []; + node.computeWorldMatrix(true); + const world = node.getWorldMatrix(); + return sockets.map((s) => { + const wp = Vector3.TransformCoordinates( + new Vector3(s.position[0], s.position[1], s.position[2]), + world, + ); + return { + screen: toScreen(wp, scene, w, h), + world: [wp.x, wp.y, wp.z] as [number, number, number], + tag: s.tag, + }; + }); +} diff --git a/src/runtime/babylon/transform-util.test.ts b/src/runtime/babylon/transform-util.test.ts new file mode 100644 index 0000000..5ee6a62 --- /dev/null +++ b/src/runtime/babylon/transform-util.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { NullEngine, Scene, TransformNode, Vector3, Quaternion } from "@babylonjs/core"; +import { captureTransform } from "./transform-util"; + +function scene() { + return new Scene(new NullEngine()); +} + +describe("babylon captureTransform", () => { + it("reads position/scaling and rotationQuaternion when present", () => { + const s = scene(); + const n = new TransformNode("n", s); + n.position.set(1, 2, 3); + n.scaling.set(2, 2, 2); + n.rotationQuaternion = new Quaternion(0, 0, 0, 1); + const t = captureTransform(n); + expect(t.position).toEqual([1, 2, 3]); + expect(t.scale).toEqual([2, 2, 2]); + expect(t.rotation).toEqual([0, 0, 0, 1]); + s.getEngine().dispose(); + }); + + it("falls back to Euler rotation when rotationQuaternion is null", () => { + const s = scene(); + const n = new TransformNode("n", s); + n.rotationQuaternion = null; + n.rotation.set(0, Math.PI / 2, 0); + const t = captureTransform(n); + const expected = Quaternion.FromEulerVector(new Vector3(0, Math.PI / 2, 0)); + expect(t.rotation[1]).toBeCloseTo(expected.y, 5); + expect(t.rotation[3]).toBeCloseTo(expected.w, 5); + s.getEngine().dispose(); + }); +}); diff --git a/src/runtime/babylon/transform-util.ts b/src/runtime/babylon/transform-util.ts new file mode 100644 index 0000000..baaa5a0 --- /dev/null +++ b/src/runtime/babylon/transform-util.ts @@ -0,0 +1,26 @@ +import { Quaternion, Vector3, type Node as BabylonNode } from "@babylonjs/core"; + +import type { Transform } from "@/core/scene/types"; + +/** Snapshot a Babylon node's transform. Gizmo rotation writes + * rotationQuaternion, but a node that has never been rotated may still carry a + * null rotationQuaternion + Euler rotation — fall back to converting that so a + * rotate-drag commit captures the real start/end. */ +export function captureTransform(node: BabylonNode): Transform { + const t = node as BabylonNode & { + position?: Vector3; + rotationQuaternion?: Quaternion | null; + rotation?: Vector3; + scaling?: Vector3; + }; + const pos = t.position ?? Vector3.Zero(); + const q = + t.rotationQuaternion ?? + (t.rotation ? Quaternion.FromEulerVector(t.rotation) : null); + const scl = t.scaling ?? new Vector3(1, 1, 1); + return { + position: [pos.x, pos.y, pos.z], + rotation: q ? [q.x, q.y, q.z, q.w] : [0, 0, 0, 1], + scale: [scl.x, scl.y, scl.z], + }; +} diff --git a/src/runtime/render-host.test.ts b/src/runtime/render-host.test.ts index 988b268..5f481e4 100644 --- a/src/runtime/render-host.test.ts +++ b/src/runtime/render-host.test.ts @@ -1,13 +1,18 @@ import { describe, expect, it } from "vitest"; -import { isEngineEditingCapable } from "./render-host"; +import { engineCapabilities } from "./render-host"; -describe("isEngineEditingCapable", () => { - it("three.js viewport supports editing interactions", () => { - expect(isEngineEditingCapable("three.js")).toBe(true); +describe("engineCapabilities", () => { + it("enables gizmo on both engines (B3b)", () => { + expect(engineCapabilities("three.js").gizmo).toBe(true); + expect(engineCapabilities("babylon.js").gizmo).toBe(true); }); - - it("babylon.js viewport is view-only in B1", () => { - expect(isEngineEditingCapable("babylon.js")).toBe(false); + it("keeps play/focus/assetDrop Three-only (B4)", () => { + const b = engineCapabilities("babylon.js"); + expect(b.play).toBe(false); + expect(b.focus).toBe(false); + expect(b.assetDrop).toBe(false); + const t = engineCapabilities("three.js"); + expect(t.play && t.focus && t.assetDrop).toBe(true); }); }); diff --git a/src/runtime/render-host.ts b/src/runtime/render-host.ts index 90a5c8c..1ebdeb4 100644 --- a/src/runtime/render-host.ts +++ b/src/runtime/render-host.ts @@ -54,12 +54,23 @@ export interface IRenderHost { } /** - * 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. + * Per-engine editing capability flags. B1/B2 gated everything behind a single + * "three-only" boolean; B3b splits it so Babylon can enable the gizmo while + * play / focus / asset-drop stay Three-only until B4 implements them. B4 flips + * the remaining flags here in one place. */ -export function isEngineEditingCapable(engine: ViewportEngine): boolean { - return engine === "three.js"; +export interface EngineCapabilities { + /** Transform gizmo + snap (B3b: both engines). */ + gizmo: boolean; + /** Play/pause behavior preview (B4: Three-only). */ + play: boolean; + /** F-to-focus camera (B4: Three-only). */ + focus: boolean; + /** Drag library assets into the viewport (B4: Three-only). */ + assetDrop: boolean; +} + +export function engineCapabilities(engine: ViewportEngine): EngineCapabilities { + const three = engine === "three.js"; + return { gizmo: true, play: three, focus: three, assetDrop: three }; } diff --git a/src/runtime/three/render-host.ts b/src/runtime/three/render-host.ts index 20df187..037398e 100644 --- a/src/runtime/three/render-host.ts +++ b/src/runtime/three/render-host.ts @@ -11,8 +11,10 @@ import type { Transform } from "@/core/scene/types"; import type { IRenderHost, SnapNode } from "@/runtime/render-host"; import { ThreeAdapter } from "@/runtime/three/adapter"; -import { computeSnapOffset, featureSnapPoints, socketPoints } from "./snap-features"; -import { captureTransform, transformsEqual } from "./transform-util"; +import { computeSnapOffset } from "@/core/snap/offset"; +import { featureSnapPoints, socketPoints } from "./snap-features"; +import { captureTransform } from "./transform-util"; +import { transformsEqual } from "@/runtime/transform-util"; /** * Three.js render host (v1.0 B3a) — owns the WebGLRenderer, post-processing diff --git a/src/runtime/three/snap-features.test.ts b/src/runtime/three/snap-features.test.ts index 5f49d86..fd2ac38 100644 --- a/src/runtime/three/snap-features.test.ts +++ b/src/runtime/three/snap-features.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import * as THREE from "three"; -import { bboxFeatures, computeSnapOffset } from "./snap-features"; +import { bboxFeatures } from "./snap-features"; +import { computeSnapOffset } from "@/core/snap/offset"; import type { SnapPoint } from "@/core/snap/nodes"; describe("bboxFeatures (OBB)", () => { diff --git a/src/runtime/three/snap-features.ts b/src/runtime/three/snap-features.ts index 9090696..c5a7de5 100644 --- a/src/runtime/three/snap-features.ts +++ b/src/runtime/three/snap-features.ts @@ -1,8 +1,7 @@ import * as THREE from "three"; -import { snapTranslation } from "@/core/snap/grid"; -import { SNAP_PIXELS, snapToNodes, type SnapPoint } from "@/core/snap/nodes"; -import { snapToSockets, type SocketPoint } from "@/core/snap/sockets"; +import type { SnapPoint } from "@/core/snap/nodes"; +import type { SocketPoint } from "@/core/snap/sockets"; import type { Socket } from "@/core/scene/types"; /** 15 个 bbox 特征(中心 + 8 角 + 6 面中心)的世界坐标;无几何返回 []。 @@ -102,33 +101,3 @@ export function socketPoints( }; }); } - -type Vec3 = [number, number, number]; - -/** - * Pure snap priority chain extracted from ThreeViewport.snapDraggedObject: - * socket-align → node-align (only when the dragged node has no tagged socket) - * → grid fallback. Returns the world-space offset to apply to the dragged - * object's position, or null only when there is genuinely nothing to do - * (never happens on the grid path — grid always returns an offset, possibly - * zero). Behaviour-equivalent to the in-component version; the caller still - * gates on translate-mode + modifier before calling. - */ -export function computeSnapOffset(args: { - currentPos: Vec3; - draggedFeatures: SnapPoint[]; - draggedSockets: SocketPoint[]; - hasSockets: boolean; - targetFeatures: SnapPoint[]; - targetSockets: SocketPoint[]; -}): Vec3 | null { - const { currentPos, draggedFeatures, draggedSockets, hasSockets } = args; - const socketOffset = snapToSockets(draggedSockets, args.targetSockets, SNAP_PIXELS); - if (socketOffset) return socketOffset; - if (!hasSockets) { - const offset = snapToNodes(draggedFeatures, args.targetFeatures, SNAP_PIXELS); - if (offset) return offset; - } - const [gx, gy, gz] = snapTranslation(currentPos); - return [gx - currentPos[0], gy - currentPos[1], gz - currentPos[2]]; -} diff --git a/src/runtime/three/transform-util.test.ts b/src/runtime/three/transform-util.test.ts index 23661a1..a2f140c 100644 --- a/src/runtime/three/transform-util.test.ts +++ b/src/runtime/three/transform-util.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import * as THREE from "three"; -import type { Transform } from "@/core/scene/types"; -import { captureTransform, transformsEqual } from "./transform-util"; +import { captureTransform } from "./transform-util"; describe("captureTransform", () => { it("snapshots position/quaternion/scale arrays", () => { @@ -14,15 +13,3 @@ describe("captureTransform", () => { expect(t.rotation).toHaveLength(4); }); }); - -describe("transformsEqual", () => { - it("true for identical, false for any differing component", () => { - const a: Transform = { - position: [0, 0, 0], - rotation: [0, 0, 0, 1], - scale: [1, 1, 1], - }; - expect(transformsEqual(a, { ...a })).toBe(true); - expect(transformsEqual(a, { ...a, position: [0, 1, 0] })).toBe(false); - }); -}); diff --git a/src/runtime/three/transform-util.ts b/src/runtime/three/transform-util.ts index 4a62e35..7d99c7a 100644 --- a/src/runtime/three/transform-util.ts +++ b/src/runtime/three/transform-util.ts @@ -9,18 +9,3 @@ export function captureTransform(obj: THREE.Object3D): Transform { scale: [obj.scale.x, obj.scale.y, obj.scale.z], }; } - -export function transformsEqual(a: Transform, b: Transform): boolean { - return ( - a.position[0] === b.position[0] && - a.position[1] === b.position[1] && - a.position[2] === b.position[2] && - a.rotation[0] === b.rotation[0] && - a.rotation[1] === b.rotation[1] && - a.rotation[2] === b.rotation[2] && - a.rotation[3] === b.rotation[3] && - a.scale[0] === b.scale[0] && - a.scale[1] === b.scale[1] && - a.scale[2] === b.scale[2] - ); -} diff --git a/src/runtime/transform-util.test.ts b/src/runtime/transform-util.test.ts new file mode 100644 index 0000000..2ddbe17 --- /dev/null +++ b/src/runtime/transform-util.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { transformsEqual } from "./transform-util"; +import type { Transform } from "@/core/scene/types"; + +describe("transformsEqual", () => { + it("true for identical, false for any differing component", () => { + const a: Transform = { + position: [0, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }; + expect(transformsEqual(a, { ...a })).toBe(true); + expect(transformsEqual(a, { ...a, position: [0, 1, 0] })).toBe(false); + expect(transformsEqual(a, { ...a, rotation: [0, 0, 1, 0] })).toBe(false); + }); +}); diff --git a/src/runtime/transform-util.ts b/src/runtime/transform-util.ts new file mode 100644 index 0000000..398eadf --- /dev/null +++ b/src/runtime/transform-util.ts @@ -0,0 +1,18 @@ +import type { Transform } from "@/core/scene/types"; + +/** Engine-neutral exact transform equality (component-wise). Used by both + * render hosts to skip a no-op gizmo drag commit. */ +export function transformsEqual(a: Transform, b: Transform): boolean { + return ( + a.position[0] === b.position[0] && + a.position[1] === b.position[1] && + a.position[2] === b.position[2] && + a.rotation[0] === b.rotation[0] && + a.rotation[1] === b.rotation[1] && + a.rotation[2] === b.rotation[2] && + a.rotation[3] === b.rotation[3] && + a.scale[0] === b.scale[0] && + a.scale[1] === b.scale[1] && + a.scale[2] === b.scale[2] + ); +} diff --git a/src/services/library/asset-drag.ts b/src/services/library/asset-drag.ts index 9ce1e01..2b427b0 100644 --- a/src/services/library/asset-drag.ts +++ b/src/services/library/asset-drag.ts @@ -1,4 +1,4 @@ -import { isEngineEditingCapable } from "@/runtime/render-host"; +import { engineCapabilities } from "@/runtime/render-host"; import { useUIStore } from "@/services/ui/store"; /** @@ -19,7 +19,7 @@ let onUp: (() => void) | null = null; export function beginAssetDrag(id: string, clientX: number, clientY: number): void { // B1: the Babylon viewport has no drop handler (raycastGroundPoint lands in // B4) — without a window-pointerup consumer the drag flag would leak. - if (!isEngineEditingCapable(useUIStore.getState().viewportEngine)) return; + if (!engineCapabilities(useUIStore.getState().viewportEngine).assetDrop) return; teardown(); // drop any stale candidate before starting a new one candidate = { id, x: clientX, y: clientY }; onMove = (e) => { diff --git a/src/ui/viewport/BabylonViewport.tsx b/src/ui/viewport/BabylonViewport.tsx index c9e7775..ca352ce 100644 --- a/src/ui/viewport/BabylonViewport.tsx +++ b/src/ui/viewport/BabylonViewport.tsx @@ -1,8 +1,12 @@ import { useEffect, useRef } from "react"; +import { SetNodeTransformCommand } from "@/core/command/commands/set-node-transform"; +import { isEffectivelyLocked } from "@/core/scene/policy"; import type { SceneNode } from "@/core/scene/types"; import type { SyncOp } from "@/runtime/adapter"; import { BabylonRenderHost } from "@/runtime/babylon/render-host"; +import type { SnapNode } from "@/runtime/render-host"; +import { executeCommand } from "@/services/command-history"; import { useSceneStore } from "@/services/scene/store"; import { useUIStore } from "@/services/ui/store"; @@ -14,9 +18,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 gizmo / play / drop / focus — those are B3/B4; picking + selection - * highlight landed in B2. The surrounding UI disables itself via - * isEngineEditingCapable. + * - No play / drop / focus — those are B4; gizmo landed in B3b; picking + + * selection highlight landed in B2. The surrounding UI disables itself via + * engineCapabilities. * 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). */ @@ -44,6 +48,31 @@ export function BabylonViewport({ host.mount(canvas); const adapter = host.adapter; + host.onTransformCommit((id, prev, next) => + executeCommand( + new SetNodeTransformCommand({ + node_id: id, + transform: next, + prev_transform: prev, + }), + ), + ); + host.setSnapProvider(() => { + const nodes = useSceneStore.getState().project?.scene.nodes ?? {}; + return Object.values(nodes).map((n) => ({ + id: n.id, + sockets: n.sockets ?? [], + visible: n.visible, + type: n.type, + })) satisfies SnapNode[]; + }); + const syncGizmoTarget = (id: string | null) => { + const node = id ? useSceneStore.getState().project?.scene.nodes[id] : undefined; + host.setGizmoTarget(id, node ? isEffectivelyLocked(node) : false); + }; + host.setGizmoMode(useUIStore.getState().gizmoMode); + syncGizmoTarget(useUIStore.getState().selectedNodeId); + const trySync = (node: SceneNode, op: SyncOp) => { try { adapter.syncNode(node, op); @@ -64,6 +93,10 @@ export function BabylonViewport({ // dispose (HighlightLayer auto-cleans), but the NEW mesh instance needs // a fresh addMesh — setSelection is idempotent, so replaying is safe. syncSelection(); + // Replay gizmo target onto the new node instance — after a diff the old + // runtime object may have been disposed; re-pinning ensures the gizmo + // points at the freshly-built mesh rather than a stale reference. + syncGizmoTarget(useUIStore.getState().selectedNodeId); }; const initial = useSceneStore.getState().project; @@ -97,6 +130,10 @@ export function BabylonViewport({ const unsubscribeUI = useUIStore.subscribe((state, prev) => { if (state.selectedNodeId !== prev.selectedNodeId) { host.setSelection(state.selectedNodeId); + syncGizmoTarget(state.selectedNodeId); + } + if (state.gizmoMode !== prev.gizmoMode) { + host.setGizmoMode(state.gizmoMode); } }); diff --git a/src/ui/viewport/PlayButton.tsx b/src/ui/viewport/PlayButton.tsx index 4536809..16fbe6a 100644 --- a/src/ui/viewport/PlayButton.tsx +++ b/src/ui/viewport/PlayButton.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; -import { isEngineEditingCapable } from "@/runtime/render-host"; +import { engineCapabilities } from "@/runtime/render-host"; import { useUIStore } from "@/services/ui/store"; export function PlayButton() { @@ -9,7 +9,7 @@ export function PlayButton() { const setPlayState = useUIStore((s) => s.setPlayState); const viewportEngine = useUIStore((s) => s.viewportEngine); const isPlay = playState === "play"; - const editingCapable = isEngineEditingCapable(viewportEngine); + const editingCapable = engineCapabilities(viewportEngine).play; return (