Skip to content
4 changes: 2 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
| v0.5 行为系统(提前部分) | Behavior framework + auto-rotate + UI + Play/Pause | #20, #21 | 🟡 partial |
| v0.2 资源库 + 材质编辑 | 内置库 + 用户上传 + 材质参数 | #29, #30 | ✅ |
| v0.3 AI Skill 框架 | Skill 接口 + AI proxy + 自然语言操作 | #31, #32 | ✅ |
| v0.4 空间吸附 | Socket 系统 + 几何约束 | #33 | 🟡 partial |
| v0.4 空间吸附 | Socket 系统 + 几何约束 | #33, #34 | 🟡 partial |
| v1.0 多适配器 | Babylon.js 适配器 | — | ⏳ |
| v1.x | R3F、Unity | — | ⏳ |

Expand Down Expand Up @@ -109,7 +109,7 @@
- **Goals**: 空间吸附 / Socket 系统(架构 §7 v0.4)。节点之间几何关系约束求解,类似 Unity 的 Snap 或 Blender 的 Snap to。
- **Sub-stages**:
- [x] **A · 网格吸附 + 吸附框架**(#33):gizmo 平移拖拽时按住 Ctrl/Cmd 吸附到 0.5 网格;`snapTranslation` 纯函数引擎(`src/core/snap/`,供 B/C 扩展)+ ThreeViewport `objectChange` hook(pointer 读即时修饰键,不卡)。仅平移。
- [ ] **B · 节点对齐吸附**:拖拽节点时其包围盒角/中心/面吸附到附近节点的对应特征(建在 A 的引擎上)
- [x] **B · 节点对齐吸附**(#34):gizmo 平移拖拽 + 按住 Ctrl/Cmd 时,被拖节点包围盒 15 特征(中心 + 8 角 + 6 面中心,**OBB** 跟随旋转)吸附到附近节点对应特征——屏幕像素(12px)做门槛、**3D 世界距离**选最近对齐对象(避免平行视角吸到深度更远的目标);无命中回退 A 的网格吸附。`snapToNodes` 纯函数(`src/core/snap/nodes.ts`)+ ThreeViewport 投影/OBB 特征提取。仅平移
- [ ] **C · Socket 系统**:节点上命名插槽 + 拖拽时兼容插槽咬合。
- **Depends on**: v0.3 release
- **后续插入**:sub-stage B 完成后做「资源拖拽入视口 + 落点」(见 Backlog,用户定的顺序),再进 C。
Expand Down
327 changes: 327 additions & 0 deletions docs/superpowers/plans/2026-06-06-v0.4b-node-snap-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
# v0.4 sub-stage B — 节点对齐吸附 实现计划

> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。

**目标:** gizmo 平移拖拽 + 按住 Ctrl/Cmd 时,被拖节点 bbox 特征(中心+8角)吸附到其它节点的对应特征(屏幕 12px 内最近对对齐),命中优先、否则回退 sub-stage A 的 grid 吸附。

**架构:** `snapToNodes` 纯函数(`src/core/snap/nodes.ts`,收已投影屏幕点,无 three);`ThreeViewport` 的 gizmo `objectChange` hook 里:算被拖节点特征屏幕点 → `snapToNodes(draggedPts, cachedTargets)` 命中则 `obj.position.add(offset)`,否则 `snapTranslation`(A)。目标特征在 mouseDown 缓存(相机拖拽中不动)。`mouseUp` 提交不变(可撤销)。

**技术栈:** TypeScript + Three.js (Box3 / Vector3.project) + Vitest。

**前置:** spec `docs/superpowers/specs/2026-06-06-v0.4b-node-snap-design.md`。分支 `feat/v0.4b-node-snap`(spec 已 commit)。基线 `pnpm test` 429 绿。

接入锚点(ThreeViewport.tsx,本会话已确认):`let dragStart: Transform | null = null;`(122) · mouseDown handler(139–143,现仅 `dragStart = captureTransform(obj)`) · objectChange handler(175–190,现 A 的 pointer+grid 版) · `snapModifierDown` 闭包(168) · `canvas`(clientWidth/Height) · `adapter.getRuntimeObject`/`adapter.camera`/`useSceneStore` 已 import · 模块底部有 helper(`captureTransform`/`transformsEqual`)。

---

## 文件结构

**新增:** `src/core/snap/nodes.ts`(+`nodes.test.ts`)
**修改:** `src/ui/viewport/ThreeViewport.tsx`(`bboxFeatures`/`toScreen` 模块 helper + `cachedTargets` + mouseDown 缓存 + objectChange 节点吸附优先 + import)

---

## 任务 T1:节点吸附引擎 snapToNodes(纯函数)

**文件:** 创建 `src/core/snap/nodes.ts` + `nodes.test.ts`

- [ ] **步骤 1:写失败测试 `nodes.test.ts`**

```ts
import { describe, expect, it } from "vitest";

import { SNAP_PIXELS, snapToNodes, type SnapPoint } from "./nodes";

describe("snapToNodes", () => {
it("returns the world offset to align the nearest in-threshold pair", () => {
const dragged: SnapPoint[] = [{ screen: [100, 100], world: [0, 0, 0] }];
const targets: SnapPoint[] = [{ screen: [105, 103], world: [1, 2, 3] }];
// screen dist hypot(5,3) ≈ 5.83 < 12 → align dragged(0,0,0) to target(1,2,3)
expect(snapToNodes(dragged, targets, 12)).toEqual([1, 2, 3]);
});

it("returns null when no pair is within the pixel threshold", () => {
const dragged: SnapPoint[] = [{ screen: [0, 0], world: [0, 0, 0] }];
const targets: SnapPoint[] = [{ screen: [200, 200], world: [1, 1, 1] }];
expect(snapToNodes(dragged, targets, 12)).toBeNull();
});

it("returns null for empty targets", () => {
const dragged: SnapPoint[] = [{ screen: [0, 0], world: [0, 0, 0] }];
expect(snapToNodes(dragged, [], 12)).toBeNull();
});

it("picks the screen-closest pair among several", () => {
const dragged: SnapPoint[] = [{ screen: [100, 100], world: [0, 0, 0] }];
const targets: SnapPoint[] = [
{ screen: [108, 100], world: [9, 9, 9] }, // dist 8
{ screen: [102, 100], world: [5, 0, 0] }, // dist 2 — closest
];
expect(snapToNodes(dragged, targets, 12)).toEqual([5, 0, 0]);
});

it("defaults to SNAP_PIXELS (12)", () => {
expect(SNAP_PIXELS).toBe(12);
});
});
```

- [ ] **步骤 2:运行确认失败**

运行:`pnpm vitest run src/core/snap/nodes.test.ts`
预期:FAIL(`./nodes` 未解析)。

- [ ] **步骤 3:实现 `nodes.ts`**

```ts
/** 一个吸附候选点:屏幕坐标(算像素距离)+ 世界坐标(算对齐 offset)。 */
export interface SnapPoint {
screen: readonly [number, number];
world: readonly [number, number, number];
}

/** 节点吸附的屏幕像素阈值。 */
export const SNAP_PIXELS = 12;

/** 找 dragged × targets 中屏幕距离最近且 < pixelThreshold 的一对,返回把该
* dragged 点对齐到该 target 点所需的世界位移(target.world − dragged.world);
* 无命中返回 null。纯函数,无 three 依赖。 */
export function snapToNodes(
dragged: readonly SnapPoint[],
targets: readonly SnapPoint[],
pixelThreshold: number = SNAP_PIXELS,
): [number, number, number] | null {
let best: [number, number, number] | null = null;
let bestDist = pixelThreshold;
for (const d of dragged) {
for (const t of targets) {
const dist = Math.hypot(d.screen[0] - t.screen[0], d.screen[1] - t.screen[1]);
if (dist < bestDist) {
bestDist = dist;
best = [
t.world[0] - d.world[0],
t.world[1] - d.world[1],
t.world[2] - d.world[2],
];
}
}
}
return best;
}
```

- [ ] **步骤 4:运行确认通过**

运行:`pnpm vitest run src/core/snap/nodes.test.ts`
预期:PASS(5)。

- [ ] **步骤 5:Commit**

```sh
git add src/core/snap/nodes.ts src/core/snap/nodes.test.ts
git commit -m "feat(snap): snapToNodes — align bbox features to other nodes (pure, tested)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## 任务 T2:ThreeViewport 接入(节点吸附优先 + grid 回退)

**文件:** 修改 `src/ui/viewport/ThreeViewport.tsx`

> 无新单测——three + DOM + 拖拽,靠 T3 `pnpm tauri dev` 视觉验证。

- [ ] **步骤 1:import**

`import { snapTranslation } from "@/core/snap/grid";` 之后加:

```ts
import { SNAP_PIXELS, snapToNodes, type SnapPoint } from "@/core/snap/nodes";
```

- [ ] **步骤 2:模块级 helper**(加在文件底部 `captureTransform` 等 helper 同处)

```ts
/** 9 个 bbox 特征(中心 + 8 角)的世界坐标;无几何(bbox 空)返回 []。 */
function bboxFeatures(obj: THREE.Object3D): THREE.Vector3[] {
const box = new THREE.Box3().setFromObject(obj);
if (box.isEmpty()) return [];
const c = new THREE.Vector3();
box.getCenter(c);
const { min, max } = box;
return [
c,
new THREE.Vector3(min.x, min.y, min.z),
new THREE.Vector3(min.x, min.y, max.z),
new THREE.Vector3(min.x, max.y, min.z),
new THREE.Vector3(min.x, max.y, max.z),
new THREE.Vector3(max.x, min.y, min.z),
new THREE.Vector3(max.x, min.y, max.z),
new THREE.Vector3(max.x, max.y, min.z),
new THREE.Vector3(max.x, max.y, max.z),
];
}

/** 世界 → 屏幕像素(用 canvas 尺寸)。 */
function toScreen(
v: THREE.Vector3,
camera: THREE.Camera,
w: number,
h: number,
): [number, number] {
const ndc = v.clone().project(camera);
return [((ndc.x + 1) / 2) * w, ((1 - ndc.y) / 2) * h];
}

/** 一个 Object3D 的 bbox 特征 → SnapPoint[](屏幕 + 世界)。 */
function featureSnapPoints(
obj: THREE.Object3D,
camera: THREE.Camera,
w: number,
h: number,
): SnapPoint[] {
return bboxFeatures(obj).map((v) => ({
screen: toScreen(v, camera, w, h),
world: [v.x, v.y, v.z] as [number, number, number],
}));
}
```

- [ ] **步骤 3:cachedTargets 闭包变量**

`let dragStart: Transform | null = null;`(122)之后加:

```ts
let cachedTargets: SnapPoint[] = [];
```

- [ ] **步骤 4:mouseDown 缓存目标特征**

mouseDown handler(现 `dragStart = captureTransform(obj);`)改为:

```ts
gizmo.addEventListener("mouseDown", () => {
const obj = gizmo.object;
if (!obj) return;
dragStart = captureTransform(obj);
// Cache snap targets (other nodes' bbox features, projected to screen).
// The camera is frozen during the drag (orbit disabled), so screen
// coords stay valid for the whole gesture.
const draggedId = obj.userData.nodeId as string | undefined;
const nodes = useSceneStore.getState().project?.scene.nodes ?? {};
const w = canvas.clientWidth;
const h = canvas.clientHeight;
cachedTargets = [];
for (const id of Object.keys(nodes)) {
const n = nodes[id];
if (id === draggedId || !n || !n.visible || n.type === "helper") continue;
const tobj = adapter.getRuntimeObject(id);
if (!tobj) continue;
cachedTargets.push(...featureSnapPoints(tobj, adapter.camera, w, h));
}
});
```

- [ ] **步骤 5:objectChange 节点吸附优先、grid 回退**

objectChange handler(175–190,现仅 grid)改为:

```ts
gizmo.addEventListener("objectChange", () => {
const obj = gizmo.object;
if (!obj || useUIStore.getState().gizmoMode !== "translate" || !snapModifierDown) {
return;
}
// Node-align snap first (bbox features → nearest other-node feature).
const draggedPts = featureSnapPoints(
obj,
adapter.camera,
canvas.clientWidth,
canvas.clientHeight,
);
const offset = snapToNodes(draggedPts, cachedTargets, SNAP_PIXELS);
if (offset) {
obj.position.set(
obj.position.x + offset[0],
obj.position.y + offset[1],
obj.position.z + offset[2],
);
return;
}
// Grid fallback (sub-stage A).
const [x, y, z] = snapTranslation([obj.position.x, obj.position.y, obj.position.z]);
obj.position.set(x, y, z);
});
```

- [ ] **步骤 6:typecheck + 全量 test 不退化**

运行:`pnpm typecheck && pnpm test`
预期:typecheck 0;test 全绿(ThreeViewport 无单测;nodes 5 已在 T1)。

- [ ] **步骤 7:Commit**

```sh
git add src/ui/viewport/ThreeViewport.tsx
git commit -m "feat(viewport): node-align snap on gizmo drag (Ctrl/Cmd), grid as fallback

mouseDown 缓存其它节点 bbox 特征(投影屏幕);objectChange 时被拖节点特征与缓存目标
比,屏幕 12px 内最近对 → obj.position.add(offset),否则回退 grid(A)。mouseUp 提交不变。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## 任务 T3:验证 + 视觉验证 + PR + roadmap + merge

- [ ] **步骤 1:全套验证** `pnpm lint && pnpm typecheck && pnpm test`(全绿,0 error)。

- [ ] **步骤 2:视觉验证(人工 `pnpm tauri dev`,spec §2/§8)**

需场景里有 ≥2 个有几何的节点(如双击库里加两个 Box)。

- [ ] 选中一个 Box,Move 拖拽时**按住 Ctrl/Cmd**,把它的角拖到靠近另一个 Box 的角(屏幕 < 12px)→ **吸附贴合**(角对角)
- [ ] 中心拖到靠近另一节点中心 → 吸附(中心对中心)
- [ ] 拖到**远离**任何节点特征(但按住 Ctrl/Cmd)→ 回退**网格吸附**(A 行为)
- [ ] 松开修饰键 → 自由移动;Cmd+Z 撤销 / 恢复
- [ ] Rotate / Scale 模式不吸附
- [ ] 场景只有一个节点时 → 纯 grid 回退(无报错)

- [ ] **步骤 3:push + PR**

```sh
git push -u origin feat/v0.4b-node-snap
/opt/homebrew/bin/gh pr create --base main --head feat/v0.4b-node-snap \
--title "feat(v0.4b): node-align snap (bbox features → other nodes)" \
--body "$(cat <<'EOF'
## What
v0.4 第二个 sub-stage:gizmo 平移拖拽 + 按住 Ctrl/Cmd 时,被拖节点 bbox 特征(中心+8角)吸附到其它节点的对应特征(屏幕 12px 内最近对对齐),命中优先、否则回退 sub-stage A 的网格吸附。
- `snapToNodes` 纯函数(`src/core/snap/nodes.ts`,收已投影屏幕点,可单测)。
- `ThreeViewport`:mouseDown 缓存其它节点 bbox 特征(投影屏幕);objectChange 节点吸附优先、grid 回退。`mouseUp` 提交不变(可撤销)。

## Why
- Spec: \`docs/superpowers/specs/2026-06-06-v0.4b-node-snap-design.md\`
- Plan: \`docs/superpowers/plans/2026-06-06-v0.4b-node-snap-plan.md\`
- 延后:Smart Guides 智能辅助线(视觉反馈)、面/边特征、socket(C)、旋转缩放吸附 → roadmap Backlog。

## How to test
- [x] lint / typecheck / test 本地绿(snapToNodes 单测)
- [ ] CI 绿
- [ ] 人工 pnpm tauri dev(plan T3 步骤 2:两个 Box 角对角/中心对中心吸附 / 超出回退 grid / undo / rotate-scale 不吸附)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```

- [ ] **步骤 4:CI 绿 + 视觉验证后回填 roadmap + merge**

`docs/roadmap.md`:v0.4 节 sub-stage B `[x]`(#NN);表格行 v0.4 保持 `🟡 partial`(C 未做,且 B 后还有资源拖拽)+ PR 列加 `, #NN`。commit + push。CI 绿 + 视觉验证通过 → `gh pr merge NN --merge --delete-branch`。

---

## 收尾验收

满足 spec §2:节点 bbox 特征屏幕 12px 内吸附对齐、否则回退 grid、可撤销 + `snapToNodes` 单测绿 + 视觉验证。**下一步:资源拖拽入视口**(用户定的顺序),再 sub-stage C(Socket 系统)。
Loading
Loading