diff --git a/docs/roadmap.md b/docs/roadmap.md index 7f30e95..f2ff9aa 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 系统 + 几何约束 | — | ⏳ | +| v0.4 空间吸附 | Socket 系统 + 几何约束 | #33 | 🟡 partial | | v1.0 多适配器 | Babylon.js 适配器 | — | ⏳ | | v1.x | R3F、Unity | — | ⏳ | @@ -104,10 +104,15 @@ - [x] 自然语言指令("加一盏右上暖白定向光" / "给选中节点加绕 Y 轴旋转")实际在场景生成对应节点/行为,可撤销 - **Depends on**: v0.2 release -### v0.4 — Planned +### v0.4 — In progress(拆为 3 个 sub-stage) - **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 的引擎上)。 + - [ ] **C · Socket 系统**:节点上命名插槽 + 拖拽时兼容插槽咬合。 - **Depends on**: v0.3 release +- **后续插入**:sub-stage B 完成后做「资源拖拽入视口 + 落点」(见 Backlog,用户定的顺序),再进 C。 ### v1.0 — Planned @@ -124,7 +129,7 @@ 从已完成 sub-stage 中刻意拆出的功能点,尚未排期到具体 release: - **材质贴图 pipeline**(从 v0.2 材质编辑拆出):normalMap / map / roughnessMap / metalnessMap / aoMap 等。需 `MaterialOverrideSchema` 加贴图字段(引用 `kind:"texture"` 的 `AssetReference`)+ texture 资源上传入库 + runtime `TextureLoader`(`mesh.ts applyOverrides` 加贴图通道)+ codegen emit `TextureLoader().load("./assets/…")` + UI 贴图选择器。独立子系统,单独 spec→plan→实现。 -- **资源拖拽入视口 + 落点**(从 v0.2 资源库拆出):当前资源库为**双击**加节点到默认位置(几何抬 `y=0.5`)。延后:从库卡片**拖拽**到视口,以 raycast 命中地面/物体的点作为新节点 `position`。 +- **资源拖拽入视口 + 落点**(从 v0.2 资源库拆出):当前资源库为**双击**加节点到默认位置(几何抬 `y=0.5`)。从库卡片**拖拽**到视口,以 raycast 命中地面/物体的点作为新节点 `position`(可与 v0.4 网格吸附结合:落点吸附网格)。**用户已定顺序:排在 v0.4 sub-stage B 之后做**。 - **多源资源上传**(从 v0.2 资源库拆出):`AssetSourceSchema` 已含 `builtin/user_upload/online/ai_generated` 且 catalog 来源无关;目前只实装 `builtin`(几何/灯光预设)+ `user_upload`(.glb)。延后:`online`(在线模型/材质库浏览+下载入库)、`ai_generated`(AI 生成模型/贴图)。 - **多材质槽**(从 v0.2 材质编辑拆出):本期材质编辑只支持 slot 0;prefab/glTF 多材质对象的 slot 1+ 延后。 - **agentic 多轮 Skill 执行**(从 v0.3 sub-stage B 拆出):本期 Skill 是**单轮结构化输出**(NL→LLM 一次返回 operations→Command)。延后:架构 §4.3 的 `call_tool` / `allowed_tools` 多轮 agent 循环(LLM 多轮调工具、读场景、迭代纠错)+ `SkillContext.memory`(`MemoryStore` 项目暂无实现)。用户明确「多轮后续肯定要完善」。 diff --git a/docs/superpowers/plans/2026-06-06-v0.4a-grid-snap-plan.md b/docs/superpowers/plans/2026-06-06-v0.4a-grid-snap-plan.md new file mode 100644 index 0000000..b6fb35b --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-v0.4a-grid-snap-plan.md @@ -0,0 +1,268 @@ +# v0.4 sub-stage A — 网格吸附 + 吸附框架 实现计划 + +> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 + +**目标:** gizmo 平移拖拽时按住 Ctrl/Cmd 把节点位置吸附到 0.5 网格步长;建立可单测的吸附引擎 + 拖拽接入点(供 sub-stage B/C 扩展)。 + +**架构:** 纯函数 `snapTranslation`(`src/core/snap/grid.ts`,无 three)作为吸附引擎第一个 snapper;`ThreeViewport` 的 gizmo `objectChange` 事件里、当 translate 模式且修饰键按住时调它改 `obj.position`;`mouseUp` 不变(已提交吸附后 transform → 一次 undo)。 + +**技术栈:** TypeScript + Three.js (TransformControls) + Zustand + Vitest。 + +**前置:** spec `docs/superpowers/specs/2026-06-06-v0.4a-grid-snap-design.md`。分支 `feat/v0.4a-grid-snap`(spec 已 commit)。基线 `pnpm test` 424 绿。 + +--- + +## 文件结构 + +**新增:** `src/core/snap/grid.ts`(+`grid.test.ts`) +**修改:** `src/ui/viewport/ThreeViewport.tsx`(修饰键追踪 + objectChange 吸附 + cleanup)· `src/ui/help/shortcuts-registry.ts` · `src/i18n/locales/{en-US,zh-CN}/editor.json`(`shortcuts.snap_grid`) + +接入锚点(ThreeViewport.tsx,本会话已确认):gizmo 创建 ~114、事件 dragging-changed/mouseDown/mouseUp ~123–159、effect cleanup `return () => {` ~318(含 `canvas.removeEventListener("pointerdown", onPointerDown)` 后 `gizmo.dispose()`)。`useUIStore` 已 import 并用 `getState().gizmoMode`。无 ThreeViewport.test。 + +--- + +## 任务 T1:吸附引擎 snapTranslation(纯函数) + +**文件:** 创建 `src/core/snap/grid.ts` + `grid.test.ts` + +- [ ] **步骤 1:写失败测试 `grid.test.ts`** + +> 注:JS `Math.round` 对 .5 向 +∞ 取整(`Math.round(-1.5)===-1`)。测试用无歧义值避开半值边界。 + +```ts +import { describe, expect, it } from "vitest"; + +import { SNAP_STEP, snapTranslation } from "./grid"; + +describe("snapTranslation", () => { + it("snaps each axis to the nearest 0.5 step", () => { + expect(snapTranslation([0.3, 0.7, -0.4], 0.5)).toEqual([0.5, 0.5, -0.5]); + }); + + it("leaves exact grid points unchanged", () => { + expect(snapTranslation([1, -2, 0], 0.5)).toEqual([1, -2, 0]); + }); + + it("is symmetric for negatives", () => { + expect(snapTranslation([-0.8, 0.8, 0], 0.5)).toEqual([-1, 1, 0]); + }); + + it("returns the position unchanged for step <= 0", () => { + expect(snapTranslation([0.3, 0.7, -0.4], 0)).toEqual([0.3, 0.7, -0.4]); + }); + + it("defaults to SNAP_STEP (0.5)", () => { + expect(SNAP_STEP).toBe(0.5); + expect(snapTranslation([0.24, 0.26, 0])).toEqual([0, 0.5, 0]); + }); +}); +``` + +- [ ] **步骤 2:运行确认失败** + +运行:`pnpm vitest run src/core/snap/grid.test.ts` +预期:FAIL(`./grid` 未解析)。 + +- [ ] **步骤 3:实现 `grid.ts`** + +```ts +type Vec3 = readonly [number, number, number]; + +/** 默认网格吸附步长(单位)。GridHelper 是 10×10 单位;0.5 落在格点 + 格中点。 */ +export const SNAP_STEP = 0.5; + +/** 把世界坐标按 step 吸附到网格(每轴独立 round)。纯函数,无 three 依赖。 + * step <= 0 时原样返回(防御)。吸附引擎的第一个 snapper(grid);sub-stage + * B/C 往本目录加 snapToNodes / snapToSockets。 */ +export function snapTranslation( + position: Vec3, + step: number = SNAP_STEP, +): [number, number, number] { + if (!(step > 0)) return [position[0], position[1], position[2]]; + return [ + Math.round(position[0] / step) * step, + Math.round(position[1] / step) * step, + Math.round(position[2] / step) * step, + ]; +} +``` + +- [ ] **步骤 4:运行确认通过** + +运行:`pnpm vitest run src/core/snap/grid.test.ts` +预期:PASS(5)。 + +- [ ] **步骤 5:Commit** + +```sh +git add src/core/snap/grid.ts src/core/snap/grid.test.ts +git commit -m "feat(snap): snapTranslation grid snapper (pure, tested) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## 任务 T2:ThreeViewport 接入(修饰键 + objectChange 吸附) + +**文件:** 修改 `src/ui/viewport/ThreeViewport.tsx` + +> 无新单测——three + DOM + 拖拽,靠 T4 `pnpm tauri dev` 视觉验证。 + +- [ ] **步骤 1:import snapTranslation** + +在 `@/core` 区 import(`import { SetNodeTransformCommand } ...` 附近)加: + +```ts +import { snapTranslation } from "@/core/snap/grid"; +``` + +- [ ] **步骤 2:修饰键追踪 + objectChange handler** + +在 gizmo 的 `mouseUp` 事件块之后(`gizmo.addEventListener("mouseUp", () => { ... });` 结束、`const syncSelection = ...` 之前)插入: + +```ts +// Grid snap: hold Ctrl/Cmd while dragging to snap the translate gizmo to +// the grid. Tracked via a closure flag because objectChange carries no +// modifier-key info. +let snapModifierDown = false; +const onSnapKey = (e: KeyboardEvent) => { + snapModifierDown = e.metaKey || e.ctrlKey; +}; +const onSnapBlur = () => { + snapModifierDown = false; +}; +window.addEventListener("keydown", onSnapKey); +window.addEventListener("keyup", onSnapKey); +window.addEventListener("blur", onSnapBlur); + +gizmo.addEventListener("objectChange", () => { + const obj = gizmo.object; + if (!obj || useUIStore.getState().gizmoMode !== "translate" || !snapModifierDown) { + return; + } + const [x, y, z] = snapTranslation([obj.position.x, obj.position.y, obj.position.z]); + obj.position.set(x, y, z); +}); +``` + +- [ ] **步骤 3:cleanup 移除监听** + +在 effect 的 `return () => {` 内、`canvas.removeEventListener("pointerdown", onPointerDown);` 之后(`gizmo.dispose();` 之前)加: + +```ts +window.removeEventListener("keydown", onSnapKey); +window.removeEventListener("keyup", onSnapKey); +window.removeEventListener("blur", onSnapBlur); +``` + +- [ ] **步骤 4:typecheck + 全量 test 不退化** + +运行:`pnpm typecheck && pnpm test` +预期:typecheck 0;test 全绿(ThreeViewport 无单测,不受影响)。 + +- [ ] **步骤 5:Commit** + +```sh +git add src/ui/viewport/ThreeViewport.tsx +git commit -m "feat(viewport): grid snap on gizmo translate drag while holding Ctrl/Cmd + +objectChange 时(translate 模式 + 修饰键按住)用 snapTranslation 吸附 obj.position; +mouseUp 提交吸附后 transform(可撤销,不改);window keydown/keyup/blur 追踪修饰键。 + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## 任务 T3:帮助条目(shortcuts-registry + i18n) + +**文件:** 修改 `src/ui/help/shortcuts-registry.ts`、`src/i18n/locales/{en-US,zh-CN}/editor.json` + +- [ ] **步骤 1:shortcuts-registry 加条目** + +`KEYBOARD_SHORTCUTS` 的 `shortcuts.section.transform` 分组(含 `move_mode`/`rotate_mode`/`scale_mode`)的 `items` 末尾加: + +```ts + { keys: ["⌘", "Drag"], i18nKey: "shortcuts.snap_grid" }, +``` + +- [ ] **步骤 2:i18n(editor.json en + zh 的 `shortcuts` 段)** + +`en-US/editor.json` 的 `shortcuts` 内(`scale_mode` 旁)加: + +```json + "snap_grid": "Hold while dragging: snap to grid", +``` + +`zh-CN/editor.json` 对应: + +```json + "snap_grid": "拖拽时按住:吸附网格", +``` + +- [ ] **步骤 3:typecheck** + +运行:`pnpm typecheck` +预期:exit 0(typed-i18n key 从 en 推断)。 + +- [ ] **步骤 4:Commit** + +```sh +git add src/ui/help/shortcuts-registry.ts src/i18n/locales/en-US/editor.json src/i18n/locales/zh-CN/editor.json +git commit -m "feat(help): shortcuts entry for grid snap (hold Cmd/Ctrl + drag) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## 任务 T4:验证 + 视觉验证 + PR + roadmap + merge + +- [ ] **步骤 1:全套验证** `pnpm lint && pnpm typecheck && pnpm test`(全绿,0 error)。 + +- [ ] **步骤 2:视觉验证(人工 `pnpm tauri dev`,spec §2/§7)** + +- [ ] 选中节点,Move(translate) gizmo 拖拽时**按住 Ctrl/Cmd** → 位置吸附到 0.5 网格(落格点/格中点);松开修饰键 → 自由移动 +- [ ] 吸附后松手 → Cmd+Z 撤销 / Cmd+Shift+Z 恢复 +- [ ] Rotate / Scale 模式拖拽**不受影响**(不吸附) +- [ ] 帮助对话框(`?`)有「按住 ⌘/Ctrl 拖拽吸附网格」+ 中英文 +- [ ] 切走窗口再回来不会误以为还按着修饰键(blur 重置) + +- [ ] **步骤 3:push + PR** + +```sh +git push -u origin feat/v0.4a-grid-snap +/opt/homebrew/bin/gh pr create --base main --head feat/v0.4a-grid-snap \ + --title "feat(v0.4a): grid snap on gizmo drag (hold Cmd/Ctrl)" \ + --body "$(cat <<'EOF' +## What +v0.4「空间吸附 / Socket 系统」第一个 sub-stage(基础):gizmo 平移拖拽时按住 Ctrl/Cmd 吸附到 0.5 网格。 +- `snapTranslation` 纯函数吸附引擎(`src/core/snap/grid.ts`,可单测,供 B/C 扩展)。 +- `ThreeViewport` gizmo `objectChange` hook:translate 模式 + 修饰键按住时吸附 `obj.position`;`mouseUp` 不变(可撤销)。 +- 纯按住修饰键(无持久状态)+ 帮助条目。仅平移。 + +## Why +- Spec: \`docs/superpowers/specs/2026-06-06-v0.4a-grid-snap-design.md\` +- Plan: \`docs/superpowers/plans/2026-06-06-v0.4a-grid-snap-plan.md\` +- sub-stage B(节点对齐吸附)/ C(socket 系统)依赖本期的吸附引擎 + 拖拽 hook。 + +## How to test +- [x] lint / typecheck / test 本地绿(snapTranslation 单测) +- [ ] CI 绿 +- [ ] 人工 pnpm tauri dev(plan T4 步骤 2:按住 Ctrl/Cmd 拖拽吸附 / undo / rotate-scale 不吸附 / 帮助) + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] **步骤 4:CI 绿 + 视觉验证后回填 roadmap + merge** + +`docs/roadmap.md`:v0.4 节从「Planned」改「In progress(拆为 3 个 sub-stage)」+ Sub-stages 列 `[x] A 网格吸附+吸附框架(#NN)` / `[ ] B 节点对齐吸附` / `[ ] C Socket 系统`;表格行 v0.4 status 🟡 + #NN。commit + push。CI 绿 + 视觉验证通过 → `gh pr merge NN --merge --delete-branch`。 + +--- + +## 收尾验收 + +满足 spec §2:按住 Ctrl/Cmd 拖拽吸附 0.5 网格 + 可撤销 + `snapTranslation` 单测绿 + 帮助说明 + 视觉验证。建立吸附引擎 + 拖拽 hook。下一步:sub-stage B(节点对齐吸附)。 diff --git a/docs/superpowers/specs/2026-06-06-v0.4a-grid-snap-design.md b/docs/superpowers/specs/2026-06-06-v0.4a-grid-snap-design.md new file mode 100644 index 0000000..40d94a4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-v0.4a-grid-snap-design.md @@ -0,0 +1,129 @@ +# v0.4 sub-stage A — 网格吸附 + 吸附框架 设计 + +**状态**: 已批准(2026-06-06) +**前置**: v0.3 完成(AI Skill 框架 #31+#32)。 +**定位**: v0.4「空间吸附 / Socket 系统」的第一个 sub-stage(基础)。后续 B(节点对齐吸附)、C(Socket 系统)依赖本期的吸附引擎 + 拖拽 hook。 +**架构依据**: `design/framework/architecture.md` §二目录 `snap/ 空间吸附(v0.3+,先留空接口)` + §7 v0.4——架构未给详细设计,本期定义 MVP。 + +--- + +## 1. 目标 + +让用户用 gizmo 平移拖拽节点时,**按住 Ctrl/Cmd** 临时把位置吸附到网格步长(默认 `0.5`),松开即自由移动。建立可单测的吸附引擎 + 拖拽接入点,供 sub-stage B/C 扩展。 + +## 2. Success criteria + +1. 选中节点,translate gizmo 拖拽时**按住 Ctrl/Cmd** → 位置实时吸附到 `0.5` 网格步长;松开修饰键 → 自由移动。 +2. 吸附后的位置经 `mouseUp` 提交 `SetNodeTransformCommand`,**Cmd+Z 撤销 / 恢复**有效(沿用现有拖拽=一次 undo)。 +3. `snapTranslation` 纯函数单测绿。 +4. `ShortcutsHelpDialog` 有「按住 ⌘/Ctrl 拖拽吸附网格」说明(en/zh)。 +5. `pnpm lint && pnpm typecheck && pnpm test` 全绿 + `pnpm tauri dev` 视觉验证。 + +## 3. 范围 + +### In scope + +- `src/core/snap/grid.ts`:`snapTranslation(position, step)` 纯函数 + `SNAP_STEP=0.5`。 +- `ThreeViewport.tsx`:修饰键追踪(window keydown/keyup/blur)+ gizmo `objectChange` 吸附 hook。 +- 帮助条目(`shortcuts-registry.ts` + `editor.json`)。 + +### Out of scope(延后 sub-stage B/C + roadmap Backlog) + +1. **节点对齐吸附**(bbox 角/中心/面)→ sub-stage B。 +2. **Socket 系统**(命名插槽 + 咬合)→ sub-stage C。 +3. **旋转/缩放吸附**(角度/比例增量)。 +4. **持久 toggle 开关 + 配置步长 UI**(本期纯按住修饰键 + 固定 0.5)。 +5. **吸附参考线/高亮指示器**(本期靠现有 GridHelper + 位置阶跃可感知)。 + +## 4. 数据模型 / 类型(`src/core/snap/grid.ts`) + +```ts +type Vec3 = readonly [number, number, number]; + +/** 默认网格吸附步长(单位)。GridHelper 是 10×10 单位;0.5 落在格点 + 格中点。 */ +export const SNAP_STEP = 0.5; + +/** 把一个世界坐标按 step 吸附到网格(每轴独立 round)。纯函数,无 three 依赖。 + * step <= 0 时原样返回(防御)。 */ +export function snapTranslation( + position: Vec3, + step: number = SNAP_STEP, +): [number, number, number] { + if (!(step > 0)) return [position[0], position[1], position[2]]; + return [ + Math.round(position[0] / step) * step, + Math.round(position[1] / step) * step, + Math.round(position[2] / step) * step, + ]; +} +``` + +> 这是吸附引擎的第一个 snapper(grid)。sub-stage B/C 往 `src/core/snap/` 加 `snapToNodes` / `snapToSockets` + 一个派发(本期 YAGNI,单函数即可)。 + +## 5. 架构 / 组件(职责 + 数据流) + +### 5.1 吸附引擎 + +纯函数 `snapTranslation`(§4)。无状态、无 three、可单测。 + +### 5.2 视口接入(`src/ui/viewport/ThreeViewport.tsx`) + +现有 gizmo 拖拽流程:`mouseDown`(记 dragStart) → 拖拽 → `mouseUp`(建 `SetNodeTransformCommand`,一次 undo)。**本期在拖拽中插入吸附**: + +- 修饰键追踪(gizmo effect 内,闭包变量): + ```ts + let snapModifierDown = false; + const onKey = (e: KeyboardEvent) => { + snapModifierDown = e.metaKey || e.ctrlKey; + }; + const onBlur = () => { + snapModifierDown = false; + }; + window.addEventListener("keydown", onKey); + window.addEventListener("keyup", onKey); + window.addEventListener("blur", onBlur); + ``` +- gizmo `objectChange` handler(拖拽中 TransformControls 每次移动 object 后触发): + ```ts + gizmo.addEventListener("objectChange", () => { + const obj = gizmo.object; + if (!obj || gizmo.getMode() !== "translate" || !snapModifierDown) return; + const [x, y, z] = snapTranslation([obj.position.x, obj.position.y, obj.position.z]); + obj.position.set(x, y, z); + }); + ``` + 下一次指针移动 TransformControls 又按指针设 position、再触发 objectChange、再吸附 → 视觉上停在网格点。`mouseUp` 读 `captureTransform(obj)` 已是吸附后的位置 → 提交(**mouseUp 代码不改**)。 +- effect cleanup 移除三个监听(与现有 dispose 一起)。 + +数据流:拖拽 + Ctrl 按住 → objectChange → snapTranslation → obj.position 落网格 → mouseUp → SetNodeTransformCommand(吸附后位置) → undo 栈。 + +### 5.3 帮助 / 可发现性 + +- `shortcuts-registry.ts` 的 transform 分组加 `{ keys: ["⌘", "Drag"], i18nKey: "shortcuts.snap_grid" }`。 +- `editor.json`(en/zh)加 `shortcuts.snap_grid`(en "Hold while dragging: snap to grid" / zh "拖拽时按住:吸附网格")。 + +## 6. 边界 / 错误处理 + +- 只 `translate` 模式吸附;`rotate`/`scale` 不受影响(handler 早返回)。 +- locked / play 节点本就不能 gizmo 拖(现有 policy / fieldset)。 +- 拖拽中 OrbitControls 已禁(现有 dragging-changed),修饰键不与相机/TransformControls 冲突。 +- window blur 重置 `snapModifierDown`(防止切走再回来误以为还按着)。 + +## 7. 测试策略 + +- `grid.test.ts`:`snapTranslation([0.3,0.7,-0.4], 0.5)` → `[0.5,0.5,-0.5]`;整网格点不变;负数对称;`step<=0` 原样返回;默认 step。 +- viewport 的 `objectChange` hook(three + DOM + 拖拽)**不单测**——靠 `pnpm tauri dev` 视觉验证:选中节点 → 平移拖拽按住 Ctrl/Cmd 吸附网格、松开自由、Cmd+Z 撤销、帮助对话框有说明、中英文。 + +## 8. 文件清单 + +**新增**:`src/core/snap/grid.ts` + `grid.test.ts`。 +**修改**:`src/ui/viewport/ThreeViewport.tsx`(修饰键追踪 + objectChange 吸附 + cleanup)· `src/ui/help/shortcuts-registry.ts` · `src/i18n/locales/{en-US,zh-CN}/editor.json`(`shortcuts.snap_grid`)。 + +## 9. 风险 / 延后 + +- objectChange 里改 position 是常见 snap 做法;若与 TransformControls 内部有抖动,回退为用其内置 `translationSnap`(但那不延续到 B/C 的自定义吸附,故优先自定义 hook)。 +- 吸附引擎本期单函数;B(节点对齐)抽出派发 + bbox 检测,C(socket)加 socket 数据模型——记 roadmap Backlog。 + +## 10. 验收 + +满足 §2:按住 Ctrl/Cmd 拖拽吸附 0.5 网格 + 可撤销 + 纯函数测试绿 + 帮助说明 + 视觉验证。建立吸附引擎 + 拖拽 hook,供 sub-stage B(节点对齐)/ C(socket)扩展。 diff --git a/src/core/snap/grid.test.ts b/src/core/snap/grid.test.ts new file mode 100644 index 0000000..e8c4c13 --- /dev/null +++ b/src/core/snap/grid.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { SNAP_STEP, snapTranslation } from "./grid"; + +describe("snapTranslation", () => { + it("snaps each axis to the nearest 0.5 step", () => { + expect(snapTranslation([0.3, 0.7, -0.4], 0.5)).toEqual([0.5, 0.5, -0.5]); + }); + + it("leaves exact grid points unchanged", () => { + expect(snapTranslation([1, -2, 0], 0.5)).toEqual([1, -2, 0]); + }); + + it("is symmetric for negatives", () => { + expect(snapTranslation([-0.8, 0.8, 0], 0.5)).toEqual([-1, 1, 0]); + }); + + it("returns the position unchanged for step <= 0", () => { + expect(snapTranslation([0.3, 0.7, -0.4], 0)).toEqual([0.3, 0.7, -0.4]); + }); + + it("defaults to SNAP_STEP (0.5)", () => { + expect(SNAP_STEP).toBe(0.5); + expect(snapTranslation([0.24, 0.26, 0])).toEqual([0, 0.5, 0]); + }); +}); diff --git a/src/core/snap/grid.ts b/src/core/snap/grid.ts new file mode 100644 index 0000000..321d84b --- /dev/null +++ b/src/core/snap/grid.ts @@ -0,0 +1,19 @@ +type Vec3 = readonly [number, number, number]; + +/** 默认网格吸附步长(单位)。GridHelper 是 10×10 单位;0.5 落在格点 + 格中点。 */ +export const SNAP_STEP = 0.5; + +/** 把世界坐标按 step 吸附到网格(每轴独立 round)。纯函数,无 three 依赖。 + * step <= 0 时原样返回(防御)。吸附引擎的第一个 snapper(grid);sub-stage + * B/C 往本目录加 snapToNodes / snapToSockets。 */ +export function snapTranslation( + position: Vec3, + step: number = SNAP_STEP, +): [number, number, number] { + if (!(step > 0)) return [position[0], position[1], position[2]]; + return [ + Math.round(position[0] / step) * step, + Math.round(position[1] / step) * step, + Math.round(position[2] / step) * step, + ]; +} diff --git a/src/i18n/locales/en-US/editor.json b/src/i18n/locales/en-US/editor.json index 968128c..7f77be7 100644 --- a/src/i18n/locales/en-US/editor.json +++ b/src/i18n/locales/en-US/editor.json @@ -73,6 +73,7 @@ "move_mode": "Move mode", "rotate_mode": "Rotate mode", "scale_mode": "Scale mode", + "snap_grid": "Hold while dragging: snap to grid", "focus": "Focus camera on selection", "clear_selection": "Clear selection", "toggle_library": "Toggle asset library", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 10ef05d..a9295a5 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -73,6 +73,7 @@ "move_mode": "移动模式", "rotate_mode": "旋转模式", "scale_mode": "缩放模式", + "snap_grid": "拖拽时按住:吸附网格", "focus": "聚焦相机到选中节点", "clear_selection": "清除选中", "toggle_library": "切换资源库", diff --git a/src/ui/help/shortcuts-registry.ts b/src/ui/help/shortcuts-registry.ts index 5c5507f..072eff7 100644 --- a/src/ui/help/shortcuts-registry.ts +++ b/src/ui/help/shortcuts-registry.ts @@ -37,6 +37,7 @@ export const KEYBOARD_SHORTCUTS: ShortcutSection[] = [ { keys: ["G"], i18nKey: "shortcuts.move_mode" }, { keys: ["R"], i18nKey: "shortcuts.rotate_mode" }, { keys: ["S"], i18nKey: "shortcuts.scale_mode" }, + { keys: ["⌘", "Drag"], i18nKey: "shortcuts.snap_grid" }, ], }, { diff --git a/src/ui/viewport/ThreeViewport.tsx b/src/ui/viewport/ThreeViewport.tsx index ebe72bb..5317913 100644 --- a/src/ui/viewport/ThreeViewport.tsx +++ b/src/ui/viewport/ThreeViewport.tsx @@ -8,6 +8,7 @@ import { OutputPass } from "three/addons/postprocessing/OutputPass.js"; import { RenderPass } from "three/addons/postprocessing/RenderPass.js"; import { SetNodeTransformCommand } from "@/core/command/commands/set-node-transform"; +import { snapTranslation } from "@/core/snap/grid"; import { isEffectivelyLocked } from "@/core/scene/policy"; import { ThreeAdapter } from "@/runtime/three/adapter"; import { describeTemplate } from "@/runtime/three/asset-cache"; @@ -158,6 +159,36 @@ export function ThreeViewport() { ); }); + // Grid snap: hold Ctrl/Cmd while dragging to snap the translate gizmo to + // the grid. Read the live modifier from pointer events (capture phase, so + // it updates before TransformControls moves the object + fires + // objectChange). Tracking via keydown/keyup is fragile — a missed keyup + // (Cmd+Tab / Cmd+Z) leaves the flag stuck "down" and everything snaps; + // pointer events carry the true current modifier every move. + let snapModifierDown = false; + const onSnapPointer = (e: PointerEvent) => { + snapModifierDown = e.ctrlKey || e.metaKey; + }; + window.addEventListener("pointermove", onSnapPointer, true); + window.addEventListener("pointerdown", onSnapPointer, true); + + gizmo.addEventListener("objectChange", () => { + const obj = gizmo.object; + if ( + !obj || + useUIStore.getState().gizmoMode !== "translate" || + !snapModifierDown + ) { + return; + } + const [x, y, z] = snapTranslation([ + obj.position.x, + obj.position.y, + obj.position.z, + ]); + obj.position.set(x, y, z); + }); + const syncSelection = (id: string | null) => { if (!id) { gizmo.detach(); @@ -322,6 +353,8 @@ export function ThreeViewport() { unsubscribeUI(); canvas.removeEventListener("click", onClick); canvas.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("pointermove", onSnapPointer, true); + window.removeEventListener("pointerdown", onSnapPointer, true); ro.disconnect(); gizmo.detach(); gizmo.dispose();