Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added design/screenshots/img_1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
997 changes: 997 additions & 0 deletions docs/superpowers/plans/2026-06-16-v1.0b3b-babylon-gizmo.md

Large diffs are not rendered by default.

137 changes: 137 additions & 0 deletions docs/superpowers/specs/2026-06-16-v1.0b3b-babylon-gizmo-design.md
Original file line number Diff line number Diff line change
@@ -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 收口。
31 changes: 31 additions & 0 deletions src/core/snap/offset.ts
Original file line number Diff line number Diff line change
@@ -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]];
}
Loading
Loading