Skip to content
Merged
11 changes: 8 additions & 3 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 系统 + 几何约束 | | ⏳ |
| v0.4 空间吸附 | Socket 系统 + 几何约束 | #33 | 🟡 partial |
| v1.0 多适配器 | Babylon.js 适配器 | — | ⏳ |
| v1.x | R3F、Unity | — | ⏳ |

Expand Down Expand Up @@ -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

Expand All @@ -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` 项目暂无实现)。用户明确「多轮后续肯定要完善」。
Expand Down
268 changes: 268 additions & 0 deletions docs/superpowers/plans/2026-06-06-v0.4a-grid-snap-plan.md
Original file line number Diff line number Diff line change
@@ -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 <noreply@anthropic.com>"
```

---

## 任务 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 <noreply@anthropic.com>"
```

---

## 任务 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 <noreply@anthropic.com>"
```

---

## 任务 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(节点对齐吸附)。
Loading
Loading