diff --git a/docs/roadmap.md b/docs/roadmap.md index f2ff9aa..dc351c6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 | — | ⏳ | @@ -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。 diff --git a/docs/superpowers/plans/2026-06-06-v0.4b-node-snap-plan.md b/docs/superpowers/plans/2026-06-06-v0.4b-node-snap-plan.md new file mode 100644 index 0000000..d6fa4b3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-v0.4b-node-snap-plan.md @@ -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 " +``` + +--- + +## 任务 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 " +``` + +--- + +## 任务 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 系统)。 diff --git a/docs/superpowers/specs/2026-06-06-v0.4b-node-snap-design.md b/docs/superpowers/specs/2026-06-06-v0.4b-node-snap-design.md new file mode 100644 index 0000000..8dda394 --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-v0.4b-node-snap-design.md @@ -0,0 +1,190 @@ +# v0.4 sub-stage B — 节点对齐吸附 设计 + +**状态**: 已批准(2026-06-06) +**前置**: v0.4 sub-stage A(网格吸附 + 吸附框架 #33)已合并——`src/core/snap/grid.ts` 的 `snapTranslation` + `ThreeViewport` 的 gizmo `objectChange` hook(translate + 按住 Ctrl/Cmd;修饰键从 pointer capture 读)。 +**定位**: v0.4 第二个 sub-stage。之后做「资源拖拽入视口」(用户定),再 sub-stage C(Socket 系统)。 +**架构依据**: 架构 §7 v0.4(几何约束/对齐)——MVP 自定义。 + +--- + +## 1. 目标 + +gizmo 平移拖拽 + 按住 Ctrl/Cmd 时,被拖节点的包围盒特征(中心 + 8 角)吸附到**其它节点**的对应特征(屏幕像素阈值内最近的一对对齐)。命中则节点对齐优先;否则回退 sub-stage A 的网格吸附。 + +## 2. Success criteria + +1. 拖一个节点平移、按住 Ctrl/Cmd,当它的某个 bbox 特征(角/中心)在屏幕上靠近(< 12px)另一节点的对应特征 → 被拖节点位移使两特征对齐。 +2. 无节点特征命中 → 回退 grid 吸附(A 的 `snapTranslation`);松开修饰键 → 自由移动。 +3. 吸附后 `mouseUp` 提交 `SetNodeTransformCommand`,可撤销(沿用 A,不改)。 +4. `snapToNodes` 纯函数单测绿。 +5. `pnpm lint && pnpm typecheck && pnpm test` 全绿 + `pnpm tauri dev` 视觉验证。 + +## 3. 范围 + +### In scope + +- `src/core/snap/nodes.ts`:`SnapPoint` 类型 + `snapToNodes(dragged, targets, pixelThreshold)` 纯函数 + `SNAP_PIXELS=12`。 +- `ThreeViewport.tsx`:mouseDown 缓存目标特征(投影屏幕)+ objectChange 节点吸附(命中优先,否则 grid 回退)+ bbox 特征提取 + world→screen 投影 helper。 + +### Out of scope(延后 sub-stage C + roadmap Backlog) + +1. **Socket 系统**(命名插槽 + 咬合)→ sub-stage C。 +2. **Smart Guides 智能辅助线**(对齐参考线 / 命中特征高亮等视觉反馈)——用户提的优化,记 Backlog。 +3. **面中心 / 边中点特征**(本期只中心 + 8 角)。 +4. **旋转 / 缩放吸附**。 +5. 资源拖拽入视口(独立 Backlog 项,排在本期之后)。 + +## 4. 数据模型 / 类型(`src/core/snap/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; +} +``` + +## 5. 架构 / 组件(职责 + 数据流) + +### 5.1 吸附引擎 + +`snapToNodes` 纯函数(§4)——接收**已投影到屏幕**的候选点(屏幕距离比较)+ 各自世界坐标(算 offset)。无状态、无 three、可单测。投影在 ThreeViewport 做(three 相关)。 + +### 5.2 视口接入(`ThreeViewport.tsx`) + +新增两个 helper(模块内或 effect 内): + +```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, w: number, h: number): [number, number] { + const ndc = v.clone().project(camera); + return [((ndc.x + 1) / 2) * w, ((1 - ndc.y) / 2) * h]; +} +``` + +- **mouseDown**(现有 handler 加缓存;相机拖拽中不动,故 mouseDown 缓存目标屏幕坐标成立): + ```ts + // ... dragStart = captureTransform(obj); (现有) + const draggedId = obj.userData.nodeId as string | undefined; + const nodes = useSceneStore.getState().project?.scene.nodes ?? {}; + const w = canvas.clientWidth, + 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; + for (const v of bboxFeatures(tobj)) { + cachedTargets.push({ + screen: toScreen(v, adapter.camera, w, h), + world: [v.x, v.y, v.z], + }); + } + } + ``` + `cachedTargets: SnapPoint[]` 是 effect 内闭包变量。 +- **objectChange**(在 A 的 grid 之前插入节点吸附): + ```ts + if (!obj || useUIStore.getState().gizmoMode !== "translate" || !snapModifierDown) + return; + const w = canvas.clientWidth, + h = canvas.clientHeight; + const draggedPts = bboxFeatures(obj).map((v) => ({ + screen: toScreen(v, adapter.camera, w, h), + world: [v.x, v.y, v.z] as [number, number, number], + })); + 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 回退(A) + const [x, y, z] = snapTranslation([obj.position.x, obj.position.y, obj.position.z]); + obj.position.set(x, y, z); + ``` +- pointer 修饰键追踪 + mouseUp 提交(一次 undo)**不变**。 + +数据流:mouseDown 缓存目标特征屏幕点 → 拖拽 objectChange 算被拖特征屏幕点 → `snapToNodes` 找最近对 → offset → obj.position.add → mouseUp 提交(可撤销)。 + +## 6. 边界 / 错误处理 + +- 目标排除:自己(被拖节点)/ helper(grid/axes) / 不可见;空 bbox(group/camera 无几何)自然跳过(`bboxFeatures` 返回 [])。locked 节点可作目标。light 含 sub-stage A 的标记小球 → 可作目标(吸附到灯位置,可接受)。 +- 无目标命中 → grid 回退;场景无其它有几何节点 → 纯 grid。 +- 相机拖拽中已禁(orbit disabled),目标屏幕坐标 mouseDown 缓存有效。 + +## 7. 视觉反馈 + +MVP **无额外指示**(节点对齐的位置阶跃可感知)。**Smart Guides(对齐参考线 / 命中特征高亮)延后**(用户提,记 Backlog)。 + +## 8. 测试策略 + +- `nodes.test.ts`:`snapToNodes` —— 命中最近对返回正确 world offset;屏幕距离超阈值 → null;空 targets → null;多对取屏幕最近的;命中点世界 offset = target.world − dragged.world。 +- 投影 / bbox / objectChange(three + DOM + 拖拽)**不单测**——靠 `pnpm tauri dev` 视觉验证:拖一个 box 的角靠近另一个 box 的角 → 吸附贴合;中心对中心;超出阈值 → 回退 grid;松开修饰键自由;Cmd+Z 撤销。 + +## 9. 文件清单 + +**新增**:`src/core/snap/nodes.ts` + `nodes.test.ts`。 +**修改**:`src/ui/viewport/ThreeViewport.tsx`(`bboxFeatures`/`toScreen` helper + mouseDown 缓存目标 + objectChange 节点吸附优先、grid 回退)。 + +## 10. 风险 / 延后 + +- 每帧投影被拖节点 9 点 + 比较 N×9 目标点:MVP 场景小,开销可忽略;目标已 mouseDown 缓存(不每帧重算)。 +- 屏幕像素阈值依赖 canvas 尺寸 + camera 投影;正交/透视都用 `project`(一致)。 +- Smart Guides / 面边特征 / socket(C) / 旋转缩放吸附 → roadmap Backlog。 + +## 11. 验收 + +满足 §2:按住 Ctrl/Cmd 拖拽时节点 bbox 特征屏幕 12px 内吸附对齐、否则回退 grid、可撤销 + `snapToNodes` 单测绿 + 视觉验证。下一步:资源拖拽入视口(用户定),再 sub-stage C(Socket)。 diff --git a/src/core/snap/nodes.test.ts b/src/core/snap/nodes.test.ts new file mode 100644 index 0000000..9a7ae52 --- /dev/null +++ b/src/core/snap/nodes.test.ts @@ -0,0 +1,39 @@ +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("among in-threshold pairs, picks the one closest in 3D world space", () => { + // Both targets are within the pixel threshold on screen, but one is much + // farther in depth. The screen-nearest must NOT win — pick the 3D-nearest + // so a parallel/side view doesn't snap to a far box behind a near one. + const dragged: SnapPoint[] = [{ screen: [100, 100], world: [0, 0, 0] }]; + const targets: SnapPoint[] = [ + { screen: [102, 100], world: [0, 0, 10] }, // screen-near (2px) but far in 3D (10) + { screen: [108, 100], world: [0, 0, 1] }, // screen 8px (<12) but near in 3D (1) + ]; + expect(snapToNodes(dragged, targets, 12)).toEqual([0, 0, 1]); + }); + + it("defaults to SNAP_PIXELS (12)", () => { + expect(SNAP_PIXELS).toBe(12); + }); +}); diff --git a/src/core/snap/nodes.ts b/src/core/snap/nodes.ts new file mode 100644 index 0000000..3b661dd --- /dev/null +++ b/src/core/snap/nodes.ts @@ -0,0 +1,46 @@ +/** 一个吸附候选点:屏幕坐标(算像素距离)+ 世界坐标(算对齐 offset)。 */ +export interface SnapPoint { + screen: readonly [number, number]; + world: readonly [number, number, number]; +} + +/** 节点吸附的屏幕像素阈值。 */ +export const SNAP_PIXELS = 12; + +/** 在 dragged × targets 里,屏幕距离 < pixelThreshold 的候选对中,选 **3D 世界 + * 距离最近** 的一对,返回把该 dragged 点对齐到该 target 点所需的世界位移 + * (target.world − dragged.world);无命中返回 null。纯函数,无 three 依赖。 + * + * 屏幕像素只做"够不够近才考虑吸"的门槛;最终按世界距离选,避免平行/侧视角下 + * 远近两个目标屏幕投影重叠时吸到 3D 空间里较远的那个。 */ +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 bestWorldDist = Infinity; + for (const d of dragged) { + for (const t of targets) { + const screenDist = Math.hypot( + d.screen[0] - t.screen[0], + d.screen[1] - t.screen[1], + ); + if (screenDist >= pixelThreshold) continue; + const worldDist = Math.hypot( + d.world[0] - t.world[0], + d.world[1] - t.world[1], + d.world[2] - t.world[2], + ); + if (worldDist < bestWorldDist) { + bestWorldDist = worldDist; + best = [ + t.world[0] - d.world[0], + t.world[1] - d.world[1], + t.world[2] - d.world[2], + ]; + } + } + } + return best; +} diff --git a/src/ui/viewport/ThreeViewport.tsx b/src/ui/viewport/ThreeViewport.tsx index 5317913..0179059 100644 --- a/src/ui/viewport/ThreeViewport.tsx +++ b/src/ui/viewport/ThreeViewport.tsx @@ -9,6 +9,7 @@ import { RenderPass } from "three/addons/postprocessing/RenderPass.js"; import { SetNodeTransformCommand } from "@/core/command/commands/set-node-transform"; import { snapTranslation } from "@/core/snap/grid"; +import { SNAP_PIXELS, snapToNodes, type SnapPoint } from "@/core/snap/nodes"; import { isEffectivelyLocked } from "@/core/scene/policy"; import { ThreeAdapter } from "@/runtime/three/adapter"; import { describeTemplate } from "@/runtime/three/asset-cache"; @@ -120,6 +121,7 @@ export function ThreeViewport() { gizmo.setMode(useUIStore.getState().gizmoMode); let dragStart: Transform | null = null; + let cachedTargets: SnapPoint[] = []; gizmo.addEventListener("dragging-changed", (event) => { const dragging = event.value as unknown as boolean; @@ -140,6 +142,21 @@ export function ThreeViewport() { 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 the 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)); + } }); gizmo.addEventListener("mouseUp", () => { const obj = gizmo.object; @@ -181,6 +198,23 @@ export function ThreeViewport() { ) { 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, @@ -403,6 +437,83 @@ export function ThreeViewport() { return
; } +/** 15 个 bbox 特征(中心 + 8 角 + 6 面中心)的世界坐标;无几何返回 []。 + * 面中心让"球放到 box 顶面/侧面"这类面对面对齐也能吸附。 + * + * 用 **有向包围盒(OBB)**:在 obj 的局部空间算 bbox(遍历子 mesh 的几何 + * boundingBox,经"相对 obj"的矩阵 union),再把 15 个局部特征点整体经 + * `obj.matrixWorld` 变换到世界。这样角/面中心会跟随物体旋转——而 `Box3. + * setFromObject` 给的是轴对齐世界 AABB,旋转后的 box 其 AABB 顶面中心并不在 + * 它真正的顶面上,导致"球吸到旋转 box 顶部"困难。 */ +function bboxFeatures(obj: THREE.Object3D): THREE.Vector3[] { + obj.updateWorldMatrix(true, true); + const local = new THREE.Box3(); + const invWorld = obj.matrixWorld.clone().invert(); + const rel = new THREE.Matrix4(); + const tmp = new THREE.Box3(); + let found = false; + obj.traverse((child) => { + const mesh = child as THREE.Mesh; + const geom = mesh.geometry as THREE.BufferGeometry | undefined; + if (!mesh.isMesh || !geom) return; + if (!geom.boundingBox) geom.computeBoundingBox(); + if (!geom.boundingBox) return; + // child 相对 obj 的变换 = obj.matrixWorld⁻¹ · child.matrixWorld + rel.multiplyMatrices(invWorld, child.matrixWorld); + tmp.copy(geom.boundingBox).applyMatrix4(rel); + local.union(tmp); + found = true; + }); + if (!found || local.isEmpty()) return []; + const c = new THREE.Vector3(); + local.getCenter(c); + const { min, max } = local; + const pts = [ + 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), + // 6 face centers — so "ball on a box's top/side" (face-to-face) snaps too. + new THREE.Vector3(max.x, c.y, c.z), + new THREE.Vector3(min.x, c.y, c.z), + new THREE.Vector3(c.x, max.y, c.z), + new THREE.Vector3(c.x, min.y, c.z), + new THREE.Vector3(c.x, c.y, max.z), + new THREE.Vector3(c.x, c.y, min.z), + ]; + // 局部特征点 → 世界(含 obj 的旋转/缩放/平移)。 + return pts.map((p) => p.applyMatrix4(obj.matrixWorld)); +} + +/** 世界坐标 → 屏幕像素(用 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], + })); +} + function captureTransform(obj: THREE.Object3D): Transform { return { position: [obj.position.x, obj.position.y, obj.position.z],