diff --git a/docs/roadmap.md b/docs/roadmap.md index efa2b5c..ad35528 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, #34 | 🟡 partial | +| v0.4 空间吸附 | Socket 系统 + 几何约束 | #33–#36 | ✅ | | v1.0 多适配器 | Babylon.js 适配器 | — | ⏳ | | v1.x | R3F、Unity | — | ⏳ | @@ -111,9 +111,9 @@ - [x] **A · 网格吸附 + 吸附框架**(#33):gizmo 平移拖拽时按住 Ctrl/Cmd 吸附到 0.5 网格;`snapTranslation` 纯函数引擎(`src/core/snap/`,供 B/C 扩展)+ ThreeViewport `objectChange` hook(pointer 读即时修饰键,不卡)。仅平移。 - [x] **B · 节点对齐吸附**(#34):gizmo 平移拖拽 + 按住 Ctrl/Cmd 时,被拖节点包围盒 15 特征(中心 + 8 角 + 6 面中心,**OBB** 跟随旋转)吸附到附近节点对应特征——屏幕像素(12px)做门槛、**3D 世界距离**选最近对齐对象(避免平行视角吸到深度更远的目标);无命中回退 A 的网格吸附。`snapToNodes` 纯函数(`src/core/snap/nodes.ts`)+ ThreeViewport 投影/OBB 特征提取。仅平移。 - [x] **资源拖拽入视口 + 落点**([#35](https://github.com/longyi-xw/lowcode-3d/pull/35)):从资源库卡片拖拽到视口,以 raycast y=0 地面命中点为落点(覆盖 x/z、保留库项默认 y);双击加节点保留;走 `AddNodeCommand` 可撤销。纯函数 `screenToNdc`/`dropPositionFor`(`src/lib/drop-helpers.ts`)+ `findLibraryItem` + adapter `raycastGroundPoint` + 自定义 pointer 拖拽(Tauri 2 默认抑制 HTML5 DnD)+ DOM ghost。仅 y=0 地面(物体表面落点 / 落点吸附 / 3D 预览 / OS file-drop 见 Backlog)。 - - [ ] **C · Socket 系统**:节点上命名插槽 + 拖拽时兼容插槽咬合。 + - [x] **C · Socket 系统(地基)**([#36](https://github.com/longyi-xw/lowcode-3d/pull/36)):节点命名连接点 `Socket {id,name,position,tag}`(schema `.optional()`,老项目兼容)+ 纯函数 `snapToSockets`(tag 兼容、屏幕门槛、世界最近)+ `SetNodeSocketsCommand`(可撤销、合并)+ Properties「插槽」面板 + 视口标记(解耦 overlay、排除拾取)。**有 socket 的节点只走 socket 吸附**(未匹配落网格 A,不回退 B——否则 B 面吸会掩盖 tag 过滤);无 socket 节点 B→A 不变。仅位置对齐(朝向咬合 / 规则引擎 / glb 内嵌识别见 Backlog)。 - **Depends on**: v0.3 release -- **后续**:「资源拖拽入视口 + 落点」已完成(见上 sub-stage,#35);下一步 sub-stage C(Socket 系统)。 +- **后续**:A 网格(#33)+ B 节点对齐(#34)+ 资源拖拽(#35)+ C Socket 系统地基(#36)均已完成,v0.4 收口。Socket 后续阶段(朝向咬合 / 规则引擎 / glb 内嵌识别 / 点选放置 / 类型库)见 Backlog。 ### v1.0 — Planned @@ -130,6 +130,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→实现。 +- **Socket 系统后续阶段**(从 v0.4 sub-stage C 地基拆出):本期只做位置对齐 + tag 兼容 + 面板手填。延后(各自独立 spec→plan→实现):**朝向咬合**(socket 带 normal/up,转动节点让两 socket 面对面「插上」)、**模型特性驱动的对齐规则引擎**(超越 tag 精确匹配)、**glb 内嵌 socket 自动识别**(importer 读 glTF 命名空节点)、**视口点选放置 socket**(raycast 模型表面落点)、**socket 类型库 / male-female 配对 / 批量拼装**、**socket 标记 LOD / 合批**(本期每次拖拽全量重建标记,大场景需按节点索引只刷被拖节点——视觉验证审查记录的次要优化项)。 - **多源资源上传**(从 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-08-v0.4c-socket-system-plan.md b/docs/superpowers/plans/2026-06-08-v0.4c-socket-system-plan.md new file mode 100644 index 0000000..049be1e --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-v0.4c-socket-system-plan.md @@ -0,0 +1,1050 @@ +# v0.4 sub-stage C · Socket 系统(地基)实现计划 + +> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 + +**目标:** 给节点定义命名的局部连接点(socket = `{id,name,position,tag}`);按住 Ctrl/Cmd 平移拖拽时,被拖节点的 socket **按位置**吸到另一节点上 **tag 相同** 的 socket;socket 在 Properties 面板增删改、视口画标记、随项目持久化、可撤销。 + +**架构:** 可扩展 socket 数据模型(schema `.optional()` 字段)+ 纯函数 `snapToSockets`(并入 `src/core/snap/`,优先级 socket > 节点对齐 B > 网格 A)+ `SetNodeSocketsCommand`(仿 `SetMaterialOverride`,含 merge)+ store `setNodeSockets` mutation + `SocketsSection` 面板 + ThreeViewport socket 吸附与标记。仅位置对齐,不改朝向。 + +**技术栈:** zod + zustand + Three.js(headless adapter 测试)+ vitest/jsdom + React 19 + react-i18next。 + +--- + +## 关键约束与 spec 偏差(实施前必读) + +实施前已通读真实代码。spec:`docs/superpowers/specs/2026-06-08-v0.4c-socket-system-design.md`。**以下以本计划为准**: + +1. **`sockets` 字段用 `.optional()` 而非 spec §4/§8 的 `.default([])`**。原因:`SceneNode = z.infer`,若 `.default([])` 则 output 类型 `sockets: Socket[]` **必填** → 全仓 **26 个构造 SceneNode 字面量的文件**(含 ~15 测试)都要补 `sockets: []`,波及面巨大。`.optional()` 让 output 为 `Socket[] | undefined`,**字面量可省略(零波及)**,消费端用 `node.sockets ?? []`(仅 ~5 处)。老项目/examples 无该字段 → 解析为 `undefined`(仍兼容,无需 migration)。 +2. **`SetNodeSocketsCommand` 逐字仿 `set-material-override.ts`(含 `canMergeWith`/`mergeWith`)**:`Command` 接口(`src/core/command/types.ts:41`)要求这两个方法;按 node_id + 500ms 合并,使面板里连续输入塌成一次 undo。 +3. 真实符号确认:`Vec3Schema`(schemas.ts:7)、`BehaviorBindingSchema`(:125)、`SceneNodeSchema`(:136, behaviors 在 :147,带 `.refine`);group NodeData = `{type:"group"}`(:73);store `mutateNode`(store.ts:79)/`setMeshMaterial`(:238) 模式;`SceneEditorStore`(command/types.ts:10);`getSceneEditorStore`(store.ts:351);`toScreen`/`featureSnapPoints`/`cachedTargets`/`snapModifierDown`/objectChange/mouseDown 均在 ThreeViewport.tsx;`snapToNodes`/`SnapPoint`/`SNAP_PIXELS` from `@/core/snap/nodes`;`MaterialSection`(src/ui/editor/) 在 EditorView `NodeProperties` 里 `{node.data.type==="mesh" && }` 处挂载。 +4. **执行前提**:已在分支 `feat/v0.4c-socket-system`(spec `fcba05c` 已在)。直接 `pnpm`/`git`(Node 20);commit 触发 husky+lint-staged(prettier/eslint --fix,自动格式化属正常)。 + +--- + +## 文件结构 + +**新增** + +| 文件 | 职责 | +| -------------------------------------------------------------- | ----------------------------------------------------- | +| `src/core/snap/sockets.ts` (+ `.test.ts`) | `SocketPoint` + `snapToSockets` 纯函数 | +| `src/core/command/commands/set-node-sockets.ts` (+ `.test.ts`) | `SetNodeSocketsCommand`(整组替换 sockets,含 merge) | +| `src/ui/editor/SocketsSection.tsx` (+ `.test.tsx`) | Properties 面板「Sockets」区 | + +**修改** + +| 文件 | 改动 | +| -------------------------------------------- | --------------------------------------------------------------------------------------- | +| `src/core/scene/schemas.ts` | `SocketSchema` + `SceneNodeSchema.sockets: z.array(SocketSchema).optional()` | +| `src/core/scene/types.ts` | `export type Socket` | +| `src/core/command/types.ts` | `SceneEditorStore.setNodeSockets` | +| `src/services/scene/store.ts` | `setNodeSockets` mutation(SceneState + impl + getSceneEditorStore) | +| `src/ui/views/EditorView.tsx` | `NodeProperties` 渲染 `` | +| `src/i18n/locales/{en-US,zh-CN}/editor.json` | `sockets.*` 文案 | +| `src/ui/viewport/ThreeViewport.tsx` | `socketPoints` helper + mouseDown 缓存 + objectChange socket 吸附优先 + socket 标记渲染 | + +依赖顺序:1→2→3→4→5→6→7→8。任务 1-5 可 TDD/单测;6-7 是视口(视觉验证);8 收口。 + +--- + +### 任务 1:schema + Socket 类型 + +**文件:** 修改 `src/core/scene/schemas.ts`、`src/core/scene/types.ts`;测试 `src/core/scene/schemas.test.ts` + +- [ ] **步骤 1:编写失败的测试** + +`schemas.test.ts`:把顶部对 `./schemas` 的 import 补上 `SocketSchema`(与现有 `SceneNodeSchema` 同组),文件末尾追加: + +```ts +describe("Socket / SceneNode.sockets", () => { + const baseNode = { + id: "n1", + name: "n", + type: "group" as const, + transform: { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] }, + parent_id: null, + children_ids: [], + visible: true, + locked: false, + data: { type: "group" as const }, + behaviors: [], + user_data: {}, + }; + + it("sockets is optional — a node without it parses to undefined", () => { + expect(SceneNodeSchema.parse(baseNode).sockets).toBeUndefined(); + }); + + it("parses a node with sockets", () => { + const n = SceneNodeSchema.parse({ + ...baseNode, + sockets: [{ id: "s1", name: "top", position: [0, 1, 0], tag: "stud" }], + }); + expect(n.sockets).toEqual([ + { id: "s1", name: "top", position: [0, 1, 0], tag: "stud" }, + ]); + }); + + it("SocketSchema requires id/name/position/tag", () => { + expect(() => + SocketSchema.parse({ id: "s", name: "n", position: [0, 0, 0] }), + ).toThrow(); + }); +}); +``` + +- [ ] **步骤 2:运行测试验证失败** + +运行:`pnpm vitest run src/core/scene/schemas.test.ts` +预期:FAIL(`SocketSchema` 未导出 / `sockets` 字段不存在)。 + +- [ ] **步骤 3:编写实现** + +`schemas.ts`:在 `BehaviorBindingSchema`(约 line 125-130)之后、`// ─ Scene node ─` 之前加: + +```ts +// ─────────────────────── Socket(v0.4 C:模块化拼装地基)─────────────────────── + +export const SocketSchema = z.object({ + id: z.string(), // 内部稳定键(仿 BehaviorBinding.id) + name: z.string(), // 用户标签(如 "top"/"bottom") + position: Vec3Schema, // 节点局部坐标 + tag: z.string(), // 兼容分组;空串 = 不参与吸附 +}); +``` + +`SceneNodeSchema` 内 `behaviors: z.array(BehaviorBindingSchema),`(line 147)之后加一行: + +```ts + sockets: z.array(SocketSchema).optional(), +``` + +`types.ts`:在 import 列表加 `SocketSchema`,并在 `BehaviorBinding` 类型附近加: + +```ts +export type Socket = z.infer; +``` + +- [ ] **步骤 4:运行测试验证通过** + +运行:`pnpm vitest run src/core/scene/schemas.test.ts && pnpm typecheck` +预期:PASS + typecheck 0 error(`.optional()` → 现有 SceneNode 字面量无需改动)。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/core/scene/schemas.ts src/core/scene/types.ts src/core/scene/schemas.test.ts +git commit -m "feat(scene): Socket schema + optional SceneNode.sockets field" +``` + +--- + +### 任务 2:snapToSockets 纯函数 + +**文件:** 创建 `src/core/snap/sockets.ts`、`src/core/snap/sockets.test.ts` + +- [ ] **步骤 1:编写失败的测试** + +创建 `src/core/snap/sockets.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; + +import { snapToSockets, type SocketPoint } from "./sockets"; + +const P = ( + screen: [number, number], + world: [number, number, number], + tag: string, +): SocketPoint => ({ screen, world, tag }); + +describe("snapToSockets", () => { + it("aligns a dragged socket to a same-tag target within the pixel threshold", () => { + const offset = snapToSockets( + [P([100, 100], [0, 0, 0], "stud")], + [P([105, 100], [2, 0, 0], "stud")], // 5px away + 12, + ); + expect(offset).toEqual([2, 0, 0]); // target.world - dragged.world + }); + + it("ignores targets with a different tag", () => { + expect( + snapToSockets( + [P([100, 100], [0, 0, 0], "stud")], + [P([101, 100], [2, 0, 0], "pipe")], + 12, + ), + ).toBeNull(); + }); + + it("ignores empty-tag sockets", () => { + expect( + snapToSockets([P([100, 100], [0, 0, 0], "")], [P([101, 100], [2, 0, 0], "")], 12), + ).toBeNull(); + }); + + it("returns null when no pair is within the pixel threshold", () => { + expect( + snapToSockets( + [P([100, 100], [0, 0, 0], "stud")], + [P([200, 200], [2, 0, 0], "stud")], + 12, + ), + ).toBeNull(); + }); + + it("among same-tag in-threshold pairs, picks the world-nearest", () => { + const offset = snapToSockets( + [P([100, 100], [0, 0, 0], "stud")], + [P([104, 100], [5, 0, 0], "stud"), P([108, 100], [1, 0, 0], "stud")], + 12, + ); + expect(offset).toEqual([1, 0, 0]); // world dist 1 < 5 + }); + + it("returns null for empty targets", () => { + expect(snapToSockets([P([100, 100], [0, 0, 0], "stud")], [], 12)).toBeNull(); + }); +}); +``` + +- [ ] **步骤 2:运行测试验证失败** + +运行:`pnpm vitest run src/core/snap/sockets.test.ts` +预期:FAIL(`Failed to resolve import "./sockets"`)。 + +- [ ] **步骤 3:编写实现** + +创建 `src/core/snap/sockets.ts`: + +```ts +import { SNAP_PIXELS, type SnapPoint } from "./nodes"; + +/** socket 吸附候选点:SnapPoint(屏幕算像素门槛 + 世界算 offset)+ tag(兼容匹配)。 */ +export interface SocketPoint extends SnapPoint { + tag: string; +} + +/** 在 dragged × targets 里,**tag 相同且非空**、屏幕距离 < pixelThreshold 的对中, + * 按 **3D 世界距离** 选最近一对,返回把该 dragged socket 对齐到 target socket 的 + * 世界位移(target.world − dragged.world);无命中 null。纯函数,无 three 依赖。 */ +export function snapToSockets( + dragged: readonly SocketPoint[], + targets: readonly SocketPoint[], + pixelThreshold: number = SNAP_PIXELS, +): [number, number, number] | null { + let best: [number, number, number] | null = null; + let bestWorldDist = Infinity; + for (const d of dragged) { + if (!d.tag) continue; + for (const t of targets) { + if (t.tag !== d.tag) continue; + 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; +} +``` + +- [ ] **步骤 4:运行测试验证通过** + +运行:`pnpm vitest run src/core/snap/sockets.test.ts` +预期:PASS(6 用例)。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/core/snap/sockets.ts src/core/snap/sockets.test.ts +git commit -m "feat(snap): snapToSockets — tag-compatible socket alignment (pure, tested)" +``` + +--- + +### 任务 3:store setNodeSockets mutation + +**文件:** 修改 `src/core/command/types.ts`、`src/services/scene/store.ts`;测试 `src/services/scene/store.test.ts` + +- [ ] **步骤 1:编写失败的测试** + +`store.test.ts` 末尾追加(`freshProject`/`IDENTITY`/`SceneNode` 该文件已有): + +```ts +describe("setNodeSockets", () => { + it("replaces a node's sockets immutably (new node identity)", () => { + useSceneStore.setState({ project: freshProject() }); + const node: SceneNode = { + id: "n1", + name: "n1", + type: "group", + transform: IDENTITY, + parent_id: null, + children_ids: [], + visible: true, + locked: false, + data: { type: "group" }, + behaviors: [], + user_data: {}, + }; + useSceneStore.getState().addNode(node); + const before = useSceneStore.getState().getNode("n1"); + const sockets = [ + { + id: "s1", + name: "top", + position: [0, 1, 0] as [number, number, number], + tag: "stud", + }, + ]; + useSceneStore.getState().setNodeSockets("n1", sockets); + const after = useSceneStore.getState().getNode("n1"); + expect(after?.sockets).toEqual(sockets); + expect(after).not.toBe(before); + }); + + it("is a no-op when the node does not exist", () => { + useSceneStore.setState({ project: freshProject() }); + expect(() => useSceneStore.getState().setNodeSockets("nope", [])).not.toThrow(); + }); +}); +``` + +- [ ] **步骤 2:运行测试验证失败** + +运行:`pnpm vitest run src/services/scene/store.test.ts` +预期:FAIL(`setNodeSockets is not a function`)。 + +- [ ] **步骤 3:编写实现** + +`src/core/command/types.ts`:顶部 import 把 `Socket` 加入 `from "../scene/types"`;在 `SceneEditorStore` 接口(`setMeshMaterial` 那行附近)加: + +```ts + setNodeSockets(nodeId: string, sockets: Socket[]): void; +``` + +`src/services/scene/store.ts`: +(a) 顶部 `from "@/core/scene/types"` 的 import 加 `Socket`。 +(b) `SceneState` 接口里(`setMeshMaterial` 之后)加: + +```ts + /** Replace a node's socket list. No-op when the node is missing. Used by + * SetNodeSocketsCommand. */ + setNodeSockets: (nodeId: string, sockets: Socket[]) => void; +``` + +(c) `create` 实现里(`setMeshMaterial` impl 之后)加: + +```ts + setNodeSockets: (nodeId, sockets) => + set((s) => { + if (!s.project) return s; + const node = s.project.scene.nodes[nodeId]; + if (!node) return s; + const nextNode: SceneNode = { ...node, sockets }; + return mutateNode(s, nodeId, nextNode); + }), +``` + +(d) `getSceneEditorStore()` 返回对象里加: + +```ts + setNodeSockets: (nodeId, sockets) => + useSceneStore.getState().setNodeSockets(nodeId, sockets), +``` + +- [ ] **步骤 4:运行测试验证通过** + +运行:`pnpm vitest run src/services/scene/store.test.ts && pnpm typecheck` +预期:PASS + typecheck 0 error。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/core/command/types.ts src/services/scene/store.ts src/services/scene/store.test.ts +git commit -m "feat(scene-store): setNodeSockets mutation" +``` + +--- + +### 任务 4:SetNodeSocketsCommand + +**文件:** 创建 `src/core/command/commands/set-node-sockets.ts`、`set-node-sockets.test.ts` + +- [ ] **步骤 1:编写失败的测试** + +创建 `src/core/command/commands/set-node-sockets.test.ts`(仿 `set-material-override.test.ts` 的精神:用一个最小 stub store 验 apply/revert + merge): + +```ts +import { describe, expect, it } from "vitest"; + +import type { Socket } from "@/core/scene/types"; + +import { SetNodeSocketsCommand } from "./set-node-sockets"; +import type { SceneEditorStore } from "../types"; + +function stubStore() { + const calls: { id: string; sockets: Socket[] }[] = []; + const store = { + setNodeSockets: (id: string, sockets: Socket[]) => calls.push({ id, sockets }), + } as unknown as SceneEditorStore; + return { store, calls }; +} + +const A: Socket[] = [{ id: "s1", name: "top", position: [0, 1, 0], tag: "stud" }]; +const B: Socket[] = []; + +describe("SetNodeSocketsCommand", () => { + it("apply sets sockets, revert restores prev", () => { + const { store, calls } = stubStore(); + const cmd = new SetNodeSocketsCommand({ + node_id: "n1", + sockets: A, + prev_sockets: B, + }); + cmd.apply(store); + cmd.revert(store); + expect(calls).toEqual([ + { id: "n1", sockets: A }, + { id: "n1", sockets: B }, + ]); + }); + + it("merges consecutive edits on the same node into one undo entry", () => { + const first = new SetNodeSocketsCommand({ + node_id: "n1", + sockets: A, + prev_sockets: B, + timestamp: 1000, + }); + const second = new SetNodeSocketsCommand({ + node_id: "n1", + sockets: B, + prev_sockets: A, + timestamp: 1200, + }); + expect(first.canMergeWith(second)).toBe(true); + const merged = first.mergeWith(second); + // earliest prev (B) + latest sockets (B), same id as first + expect(merged.id).toBe(first.id); + expect((merged.payload as { sockets: Socket[] }).sockets).toEqual(B); + expect((merged.payload as { prev_sockets: Socket[] }).prev_sockets).toEqual(B); + }); + + it("does not merge across different nodes", () => { + const a = new SetNodeSocketsCommand({ + node_id: "n1", + sockets: A, + prev_sockets: B, + timestamp: 1000, + }); + const b = new SetNodeSocketsCommand({ + node_id: "n2", + sockets: A, + prev_sockets: B, + timestamp: 1100, + }); + expect(a.canMergeWith(b)).toBe(false); + }); +}); +``` + +- [ ] **步骤 2:运行测试验证失败** + +运行:`pnpm vitest run src/core/command/commands/set-node-sockets.test.ts` +预期:FAIL(模块不存在)。 + +- [ ] **步骤 3:编写实现** + +创建 `src/core/command/commands/set-node-sockets.ts`(逐字仿 `set-material-override.ts`,把 material 换成 sockets): + +```ts +import type { Socket } from "@/core/scene/types"; + +import { generateUUID } from "../../id/uuid"; +import type { Command, SceneEditorStore } from "../types"; + +export const SET_NODE_SOCKETS = "node.sockets.set" as const; + +/** Merge window for consecutive socket edits (rapid typing in the panel) — + * same rationale + value as SetNodeTransform / SetMaterialOverride. */ +export const MERGE_WINDOW_MS = 500; + +export interface SetNodeSocketsPayload extends Record { + node_id: string; + sockets: Socket[]; + prev_sockets: Socket[]; +} + +export interface SetNodeSocketsInput { + node_id: string; + sockets: Socket[]; + prev_sockets: Socket[]; + id?: string; + timestamp?: number; +} + +export class SetNodeSocketsCommand implements Command { + readonly id: string; + readonly type = SET_NODE_SOCKETS; + readonly timestamp: number; + readonly payload: SetNodeSocketsPayload; + + constructor(input: SetNodeSocketsInput) { + this.id = input.id ?? generateUUID(); + this.timestamp = input.timestamp ?? Date.now(); + this.payload = { + node_id: input.node_id, + sockets: input.sockets, + prev_sockets: input.prev_sockets, + }; + } + + apply(store: SceneEditorStore): void { + store.setNodeSockets(this.payload.node_id, this.payload.sockets); + } + + revert(store: SceneEditorStore): void { + store.setNodeSockets(this.payload.node_id, this.payload.prev_sockets); + } + + canMergeWith(other: Command): boolean { + if (other.type !== SET_NODE_SOCKETS) return false; + const o = other.payload as SetNodeSocketsPayload; + if (o.node_id !== this.payload.node_id) return false; + return Math.abs(other.timestamp - this.timestamp) < MERGE_WINDOW_MS; + } + + mergeWith(other: Command): SetNodeSocketsCommand { + if (!this.canMergeWith(other)) { + throw new Error( + `cannot merge ${this.type} with ${other.type}: node or window mismatch`, + ); + } + const o = other.payload as SetNodeSocketsPayload; + return new SetNodeSocketsCommand({ + id: this.id, + node_id: this.payload.node_id, + sockets: o.sockets, + prev_sockets: this.payload.prev_sockets, + timestamp: other.timestamp, + }); + } +} +``` + +- [ ] **步骤 4:运行测试验证通过** + +运行:`pnpm vitest run src/core/command/commands/set-node-sockets.test.ts && pnpm typecheck` +预期:PASS + typecheck 0 error。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/core/command/commands/set-node-sockets.ts src/core/command/commands/set-node-sockets.test.ts +git commit -m "feat(command): SetNodeSocketsCommand (undoable, merges rapid edits)" +``` + +--- + +### 任务 5:SocketsSection 面板 + i18n + 挂载 + +**文件:** 创建 `src/ui/editor/SocketsSection.tsx`、`SocketsSection.test.tsx`;修改 `src/ui/views/EditorView.tsx`、`src/i18n/locales/{en-US,zh-CN}/editor.json` + +- [ ] **步骤 1:i18n 文案** + +`src/i18n/locales/en-US/editor.json`:在 `library` 块之后加一个 `sockets` 块: + +```json + "sockets": { + "section": "Sockets", + "add": "Add socket", + "empty": "No sockets. Click + to add a connection point.", + "tag_placeholder": "tag" + }, +``` + +`src/i18n/locales/zh-CN/editor.json`:对应加: + +```json + "sockets": { + "section": "插槽", + "add": "添加插槽", + "empty": "暂无插槽。点击 + 添加连接点。", + "tag_placeholder": "tag" + }, +``` + +(注意 JSON 合法:在前一块结尾补逗号。) + +- [ ] **步骤 2:编写失败的测试** + +创建 `src/ui/editor/SocketsSection.test.tsx`(仿 `LibraryPanel.test.tsx` spy `useCommandHistoryStore.execute`): + +```tsx +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useCommandHistoryStore } from "@/services/command-history/store"; +import { useSceneStore } from "@/services/scene/store"; +import type { SceneNode } from "@/core/scene/types"; + +import { SocketsSection } from "./SocketsSection"; + +const IDENTITY = { + position: [0, 0, 0] as [number, number, number], + rotation: [0, 0, 0, 1] as [number, number, number, number], + scale: [1, 1, 1] as [number, number, number], +}; + +function groupNode(sockets?: SceneNode["sockets"]): SceneNode { + return { + id: "n1", + name: "n1", + type: "group", + transform: IDENTITY, + parent_id: null, + children_ids: [], + visible: true, + locked: false, + data: { type: "group" }, + behaviors: [], + user_data: {}, + sockets, + }; +} + +describe("SocketsSection", () => { + beforeEach(() => { + useSceneStore.setState({ project: null }); + useCommandHistoryStore.getState().clear(); + }); + + it("shows an existing socket's name", () => { + render( + , + ); + expect(screen.getByDisplayValue("top")).toBeInTheDocument(); + }); + + it("clicking add dispatches a node.sockets.set command", () => { + const exec = vi.spyOn(useCommandHistoryStore.getState(), "execute"); + render(); + fireEvent.click(screen.getByTitle("Add socket")); + expect(exec).toHaveBeenCalledWith( + expect.objectContaining({ type: "node.sockets.set" }), + expect.anything(), + ); + }); +}); +``` + +- [ ] **步骤 3:运行测试验证失败** + +运行:`pnpm vitest run src/ui/editor/SocketsSection.test.tsx` +预期:FAIL(模块不存在)。 + +- [ ] **步骤 4:编写组件** + +创建 `src/ui/editor/SocketsSection.tsx`: + +```tsx +import { Plus, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { SetNodeSocketsCommand } from "@/core/command/commands/set-node-sockets"; +import { generateUUID } from "@/core/id/uuid"; +import type { SceneNode, Socket } from "@/core/scene/types"; +import { executeCommand } from "@/services/command-history"; + +export function SocketsSection({ node }: { node: SceneNode }) { + const { t } = useTranslation("editor"); + const sockets = node.sockets ?? []; + + const commit = (next: Socket[]) => + executeCommand( + new SetNodeSocketsCommand({ + node_id: node.id, + sockets: next, + prev_sockets: sockets, + }), + ); + + const add = () => + commit([ + ...sockets, + { id: generateUUID(), name: "socket", position: [0, 0, 0], tag: "" }, + ]); + const remove = (id: string) => commit(sockets.filter((s) => s.id !== id)); + const patch = (id: string, p: Partial) => + commit(sockets.map((s) => (s.id === id ? { ...s, ...p } : s))); + + return ( +
+
+

+ {t("sockets.section")} +

+ +
+ + {sockets.length === 0 ? ( +

{t("sockets.empty")}

+ ) : ( + sockets.map((s) => ( + patch(s.id, p)} + onRemove={() => remove(s.id)} + /> + )) + )} +
+ ); +} + +function SocketRow({ + socket, + tagPlaceholder, + onPatch, + onRemove, +}: { + socket: Socket; + tagPlaceholder: string; + onPatch: (p: Partial) => void; + onRemove: () => void; +}) { + const inputCls = + "w-0 flex-1 rounded border border-border bg-background/50 px-1.5 py-0.5 outline-none focus:border-primary"; + return ( +
+
+ onPatch({ name: e.target.value })} + className={inputCls} + /> + +
+
+ {([0, 1, 2] as const).map((i) => ( + { + const p: [number, number, number] = [...socket.position]; + p[i] = Number(e.target.value) || 0; + onPatch({ position: p }); + }} + className="w-full rounded border border-border bg-background/50 px-1.5 py-0.5 text-right outline-none focus:border-primary" + /> + ))} +
+ onPatch({ tag: e.target.value })} + className={inputCls} + /> +
+ ); +} +``` + +- [ ] **步骤 5:挂载到 EditorView** + +`src/ui/views/EditorView.tsx`:import 区加 `import { SocketsSection } from "@/ui/editor/SocketsSection";`;在 `NodeProperties` 里 `{node.data.type === "mesh" && }` 这一行**之后**加: + +```tsx + +``` + +- [ ] **步骤 6:运行测试验证通过** + +运行:`pnpm vitest run src/ui/editor/SocketsSection.test.tsx && pnpm typecheck` +预期:PASS + typecheck 0 error。 + +- [ ] **步骤 7:Commit** + +```bash +git add src/ui/editor/SocketsSection.tsx src/ui/editor/SocketsSection.test.tsx src/ui/views/EditorView.tsx src/i18n/locales/en-US/editor.json src/i18n/locales/zh-CN/editor.json +git commit -m "feat(sockets): Properties panel Sockets section (add/edit/remove, undoable)" +``` + +--- + +### 任务 6:ThreeViewport — socket 吸附(优先级 socket > B > A) + +**文件:** 修改 `src/ui/viewport/ThreeViewport.tsx`。无单测(pure fn 已在任务 2 测;视口 WebGL+拖拽靠任务 8 视觉验证)。 + +- [ ] **步骤 1:补 import + helper** + +(a) 顶部:`import { SNAP_PIXELS, snapToNodes, type SnapPoint } from "@/core/snap/nodes";`(已存在)旁加: + +```tsx +import { snapToSockets, type SocketPoint } from "@/core/snap/sockets"; +``` + +`@/core/scene/types` 的 import 加 `Socket`。 + +(b) 在 `featureSnapPoints` 函数(模块级)附近加(复用已有 `toScreen`): + +```tsx +/** 一个节点 + 它的 sockets → SocketPoint[](世界点经 node.matrixWorld,附 tag)。 */ +function socketPoints( + obj: THREE.Object3D, + sockets: readonly Socket[], + camera: THREE.Camera, + w: number, + h: number, +): SocketPoint[] { + if (sockets.length === 0) return []; + obj.updateWorldMatrix(true, false); + const v = new THREE.Vector3(); + return sockets.map((s) => { + v.set(s.position[0], s.position[1], s.position[2]).applyMatrix4(obj.matrixWorld); + return { + screen: toScreen(v, camera, w, h), + world: [v.x, v.y, v.z] as [number, number, number], + tag: s.tag, + }; + }); +} +``` + +- [ ] **步骤 2:mouseDown 缓存目标 socket** + +在 mount effect 里 `let cachedTargets: SnapPoint[] = [];`(约 line 124)旁加: + +```tsx +let cachedSocketTargets: SocketPoint[] = []; +``` + +在 gizmo `mouseDown` handler 内、构建 `cachedTargets` 的循环里(`for (const id of Object.keys(nodes)) { ... }`),把每个目标节点的 socket 也缓存——在该循环体末尾(`cachedTargets.push(...featureSnapPoints(...))` 之后)加: + +```tsx +cachedSocketTargets.push(...socketPoints(tobj, n.sockets ?? [], adapter.camera, w, h)); +``` + +并在该 handler 重置 `cachedTargets = [];` 处一并 `cachedSocketTargets = [];`。 + +- [ ] **步骤 3:objectChange 内 socket 吸附(B 之前)** + +在 objectChange handler 里、现有「节点对齐(B)」的 `snapToNodes` 调用**之前**插入: + +```tsx +// Socket snap (C) — highest priority. Aligns a dragged socket to a +// tag-compatible socket on another node by world position. +const w2 = canvas.clientWidth; +const h2 = canvas.clientHeight; +const draggedNode = + useSceneStore.getState().project?.scene.nodes[obj.userData.nodeId as string]; +const socketOffset = snapToSockets( + socketPoints(obj, draggedNode?.sockets ?? [], adapter.camera, w2, h2), + cachedSocketTargets, + SNAP_PIXELS, +); +if (socketOffset) { + obj.position.set( + obj.position.x + socketOffset[0], + obj.position.y + socketOffset[1], + obj.position.z + socketOffset[2], + ); + return; +} +``` + +(其后保留现有 B `snapToNodes` → A `snapTranslation` 回退逻辑不变。) + +- [ ] **步骤 4:验证不退化** + +运行:`pnpm typecheck && pnpm test` +预期:typecheck 0 error;全量测试绿(本任务无新测试,不得退化)。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/ui/viewport/ThreeViewport.tsx +git commit -m "feat(viewport): socket snap on gizmo drag (priority over node/grid)" +``` + +--- + +### 任务 7:ThreeViewport — socket 标记渲染 + +**文件:** 修改 `src/ui/viewport/ThreeViewport.tsx`。无单测(视觉),任务 8 验证。采用 spec §6.6 方案 (b)(独立 group、世界坐标、解耦)。 + +- [ ] **步骤 1:标记 group + 重建函数(mount effect 内)** + +在 mount effect 内(adapter/scene 建好后、`animate()` 之前),加: + +```tsx +// ── Socket markers (v0.4 C) ────────────────────────────────── +// Decoupled overlay group (spec §6.6 option b): world-positioned markers, +// rebuilt on scene/selection change + during drag. Shared geo/material so +// group.clear() just detaches (no per-marker dispose). raycast no-op so +// pickAt never selects a marker. +const socketGeo = new THREE.SphereGeometry(0.06, 8, 8); +const socketMat = new THREE.MeshBasicMaterial({ color: 0x22d3ee }); +const socketMatSel = new THREE.MeshBasicMaterial({ color: 0xf59e0b }); +const noRaycast = () => {}; +const socketMarkers = new THREE.Group(); +socketMarkers.name = "socketMarkers"; +adapter.scene.add(socketMarkers); + +const rebuildSocketMarkers = () => { + socketMarkers.clear(); + const proj = useSceneStore.getState().project; + if (!proj) return; + const selId = useUIStore.getState().selectedNodeId; + const v = new THREE.Vector3(); + for (const [id, n] of Object.entries(proj.scene.nodes)) { + const sockets = n.sockets; + if (!sockets || sockets.length === 0) continue; + const tobj = adapter.getRuntimeObject(id); + if (!tobj) continue; + tobj.updateWorldMatrix(true, false); + for (const s of sockets) { + const mk = new THREE.Mesh(socketGeo, id === selId ? socketMatSel : socketMat); + v.set(s.position[0], s.position[1], s.position[2]).applyMatrix4(tobj.matrixWorld); + mk.position.copy(v); + mk.raycast = noRaycast; + socketMarkers.add(mk); + } + } +}; +``` + +- [ ] **步骤 2:触发重建(seed 后 / scene 变 / 选中变 / 拖拽中)** + +- seed 之后(`if (initial) void seedScene(...)` 之后,或首个 `syncSelection` 附近):先放一处初始重建。因 `seedScene` 是 async,最稳妥是在动画首帧前调一次 + 订阅里再调。加:`rebuildSocketMarkers();`(紧接 `syncSelection(useUIStore.getState().selectedNodeId);` 之后)。 +- `unsubscribeScene`(`useSceneStore.subscribe`)回调里 `void diffAndApply(...)` 之后加 `rebuildSocketMarkers();`(节点/ socket 变化后重建;diffAndApply 是 async,但标记重建读最新 store + 现有 runtime 对象即可,next tick 不到的新节点会在下次触发补上——可接受)。 +- `unsubscribeUI` 里 `if (state.selectedNodeId !== prev.selectedNodeId)` 分支内(`syncSelection` 调用旁)加 `rebuildSocketMarkers();`(切换强调色)。 +- objectChange handler 末尾(socket/B/A 之后、return 前的总出口)加 `rebuildSocketMarkers();`,使被拖节点的标记每帧跟随。 + +- [ ] **步骤 3:cleanup 释放** + +mount effect 的 `return () => {...}` 里(dispose 区)加: + +```tsx +adapter.scene.remove(socketMarkers); +socketMarkers.clear(); +socketGeo.dispose(); +socketMat.dispose(); +socketMatSel.dispose(); +``` + +- [ ] **步骤 4:验证不退化** + +运行:`pnpm typecheck && pnpm test` +预期:typecheck 0 error;全量测试绿。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/ui/viewport/ThreeViewport.tsx +git commit -m "feat(viewport): render socket markers (decoupled overlay, excluded from pick)" +``` + +--- + +### 任务 8:全量验证 + 视觉冒烟(不可跳过) + +- [ ] **步骤 1:静态 + 单测全绿** + +运行:`pnpm lint && pnpm typecheck && pnpm test` +预期:0 error / 全绿。prettier 若动 `editor.json` 按提示修后重跑。 + +- [ ] **步骤 2:`pnpm tauri dev` 视觉冒烟(对照 spec §2)** + +`pnpm tauri dev`,逐项确认: + +1. 选中节点 → Properties 出现「Sockets」区;+ 添加、改 name/x/y/z/tag、删除都生效;Cmd/Ctrl+Z 撤销(连续输入塌成一次/少数 undo)。 +2. 视口画出 socket 小标记,选中节点的颜色不同;标记不挡点选(点标记处仍选中其下节点或不误选标记)。 +3. 给两个 box 各加一个 **tag 相同** 的 socket;按住 Ctrl/Cmd 拖一个,使其 socket 屏幕靠近另一个的 socket → 两 socket 世界点吸附重合。 +4. tag **不同** → 不吸,回退节点对齐(B)→网格(A)。 +5. 松开 Ctrl/Cmd → 自由移动;拖拽落点 Cmd/Ctrl+Z 可撤销(沿用 SetNodeTransform)。 +6. 保存项目 → 重新打开 → socket 保留;用一个**旧项目/example**(无 sockets 字段)打开不报错。 + +- [ ] **步骤 3:收尾** + +全绿 + 冒烟过后用 superpowers:finishing-a-development-branch 收尾(commit plan + roadmap 勾 v0.4 sub-stage C + 开 PR)。 + +--- + +## 自检 + +**1. 规格覆盖度(spec §2 逐条):** + +- 面板增删改 socket 可撤销 → 任务 4+5 ✓ +- 视口标记不干扰拾取 → 任务 7(`raycast` no-op)✓ +- Ctrl/Cmd 拖拽 tag 相同 socket 吸附重合 → 任务 2+6 ✓ +- 无命中回退 B→A → 任务 6(socket 在前,return;否则落到现有 B/A)✓ +- 落点沿用 SetNodeTransform,不新增吸附命令 → 任务 6(只改 position;mouseUp 现有提交)✓ +- 持久化 + 老项目加载 + 不进导出 → 任务 1(`.optional()` 解析 + 不改 codegen)+ 任务 8 步骤 2.6 ✓ +- snapToSockets + schema 默认值单测 → 任务 2 + 任务 1 ✓ +- lint/type/test + 视觉 → 任务 8 ✓ + +**2. 占位符扫描:** 各步含完整代码 + 命令 + 预期;无 TODO/待定。任务 7 步骤 2 的「插入位置」用现有锚点描述(无现成行号代码可贴,但锚点唯一),实施者按锚点插入给定代码块。✓ + +**3. 类型一致性:** + +- `Socket = {id,name,position:Vec3,tag}`(任务 1)→ 任务 3/4/5/6/7 一致引用 ✓ +- `SocketPoint = SnapPoint & {tag}`(任务 2)→ 任务 6 `socketPoints` 产出一致 ✓ +- `snapToSockets(dragged,targets,threshold)`(任务 2)→ 任务 6 同签名 ✓ +- `setNodeSockets(nodeId, sockets)`(任务 3,SceneEditorStore + store + adapter 三处)→ 任务 4 命令 apply/revert 调用一致 ✓ +- `SetNodeSocketsCommand({node_id,sockets,prev_sockets})`(任务 4)→ 任务 5 面板构造一致;type 字符串 `"node.sockets.set"` 任务 5 测试断言一致 ✓ +- `node.sockets ?? []` 消费(任务 5/6/7)—— 因 `.optional()`,统一加 `?? []` ✓ + +**4. 边界覆盖:** 节点不存在(store no-op)、空 tag(snapToSockets 跳过)、无 socket 节点(贡献空集)、老项目无字段(.optional)、标记拾取排除——均有任务覆盖。 + +发现即修,无遗留。 + +--- + +## 执行交接 + +计划已保存到 `docs/superpowers/plans/2026-06-08-v0.4c-socket-system-plan.md`。两种执行方式: + +1. **子代理驱动(推荐)** —— 每任务一个新子代理 + 审查;任务 1-5 机械 TDD,任务 6-7 视口(视觉验证兜底)。 +2. **内联执行** —— 当前会话用 executing-plans 批量执行 + 检查点。 + +选哪种? diff --git a/docs/superpowers/specs/2026-06-08-v0.4c-socket-system-design.md b/docs/superpowers/specs/2026-06-08-v0.4c-socket-system-design.md new file mode 100644 index 0000000..a3b9e1e --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-v0.4c-socket-system-design.md @@ -0,0 +1,279 @@ +# v0.4 sub-stage C — Socket 系统(地基)设计 + +**状态**: 已批准(2026-06-08) +**前置**: v0.4 A 网格吸附(#33)+ B 节点对齐吸附(#34)+ 资源拖拽入视口(#35)已合并。吸附框架在 `src/core/snap/`(`grid.ts` 的 `snapTranslation`、`nodes.ts` 的 `snapToNodes`/`SnapPoint`/`SNAP_PIXELS`,纯函数)+ `ThreeViewport` 的 gizmo `objectChange` hook(translate + 按住 Ctrl/Cmd;修饰键从 pointer capture 读)。 +**定位**: v0.4 第三个 sub-stage,是「**模块化拼装**」这一项目核心功能的**地基**。 +**架构依据**: 架构 §7 v0.4「空间吸附(socket 系统,几何约束求解)」+ §3 `src/core/snap/`。 + +--- + +## 0. 愿景与分阶段(关键上下文) + +模块化拼装是本项目的核心支柱:用一堆小构件(尤其用户上传的 glb 零件)拼出大模型,乃至复杂城市场景;socket 是构件的连接点;对齐规则最终要由**构件/模型自身的特性**决定(语义化连接规则),而不止「面贴面」。 + +这是个**多阶段产品线**,不是一个 sub-stage 能做完的。本 spec 只做**地基**,但数据模型设计成能往上长(加字段不破坏旧数据)。后续各自独立 spec: + +| 阶段 | 内容 | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | +| **C(本期·地基)** | 可扩展 socket 数据模型 + **位置对齐**吸附 + **tag 兼容** + 面板最小授权 + 视口标记 | +| 后续 | 朝向咬合(normal/up,真正「插上」)→ 模型特性驱动的对齐规则引擎 → glb 内嵌 socket 自动识别 → 视口点选放置 socket → socket 类型库 / 批量拼装 | + +--- + +## 1. 目标 + +给节点定义命名的局部连接点(socket,含兼容 tag),拖拽平移 + 按住 Ctrl/Cmd 时,被拖节点的某 socket **按位置**吸附到**另一节点上 tag 相同的** socket(两 socket 世界点重合,不改朝向)。socket 在 Properties 面板手动增删改、在视口以标记可见、随项目持久化、可撤销。命中 socket 优先;否则回退节点对齐(B)→网格(A)。 + +## 2. Success criteria + +1. 选中节点 → Properties 面板「Sockets」区可 **加 / 删 / 命名 socket、设局部 position(x/y/z)、设 tag**,全部可撤销。 +2. 视口画出 socket 标记(选中节点的强调),且**不干扰拾取**(pickAt 不应选中标记)。 +3. 按住 Ctrl/Cmd 平移拖拽,被拖节点某 socket 在屏幕 12px 内靠近**另一节点 tag 相同**的 socket → 位移使两 socket 世界点重合。 +4. 无 socket 命中 → 回退节点对齐(B)→网格(A);松开修饰键 → 自由移动。 +5. 吸附后落点沿用现有 `mouseUp → SetNodeTransformCommand`(一次 undo,**不新增吸附命令**)。 +6. socket 持久化进 `project.json` 的节点;老项目(无 `sockets` 字段)照常加载(schema `.default([])`);**不进导出代码**(codegen 不动)。 +7. `snapToSockets` 纯函数单测 + schema 默认值单测绿。 +8. `pnpm lint && pnpm typecheck && pnpm test` 全绿 + `pnpm tauri dev` 视觉验证。 + +## 3. 范围 + +### In scope + +- `src/core/scene/schemas.ts`:`SocketSchema` + `SceneNodeSchema.sockets: z.array(SocketSchema).default([])`;`src/core/scene/types.ts`:`Socket` 类型。 +- `src/core/snap/sockets.ts`:`SocketPoint` 类型 + `snapToSockets(dragged, targets, pixelThreshold)` 纯函数(+ 单测)。 +- `src/core/command/commands/set-node-sockets.ts`:`SetNodeSocketsCommand`(整组替换 sockets,可撤销)+ scene store 的 `setNodeSockets` mutation。 +- `ThreeViewport.tsx`:mouseDown 缓存目标 socket 点;objectChange 内 socket 吸附(命中优先,否则 B→A 回退);socket 标记渲染(视口 overlay)。 +- Properties 面板:新增「Sockets」区(增删改 socket)。 + +### Out of scope(延后 → 各自 spec + roadmap Backlog) + +1. **朝向咬合**(socket 带 normal/up,转动节点让两 socket 面对面「插上」)。 +2. **模型特性驱动的对齐规则引擎**(兼容/对齐由模型语义决定,超越 tag 精确匹配)。 +3. **glb 内嵌 socket 自动识别**(importer 读 glTF 命名空节点为 socket)。 +4. **视口点选放置 socket**(raycast 模型表面落 socket)。 +5. **socket 类型库 / male-female 配对 / 批量拼装**。 +6. 旋转 / 缩放吸附。 + +## 4. 数据模型(`schemas.ts` + `types.ts`) + +```ts +// schemas.ts —— 放在 BehaviorBindingSchema 附近;Vec3Schema 已存在(line 7) +export const SocketSchema = z.object({ + id: z.string(), // 内部稳定键(仿 BehaviorBinding.id;面板只露 name/position/tag) + name: z.string(), // 用户标签(如 "top" / "bottom") + position: Vec3Schema, // 节点局部坐标 + tag: z.string(), // 兼容分组;空串 = 不参与吸附 +}); + +// SceneNodeSchema 内(behaviors 之后)新增一行: +// sockets: z.array(SocketSchema).default([]), +// .default([]) 让老项目 / examples(无 sockets 字段)解析不破,无需 migration 函数。 + +// types.ts +export type Socket = z.infer; +``` + +- **扩展位**:日后加 `normal`/`up`/`rule` 字段或 socket 子类型 = 「加字段」,不破坏旧数据。 +- **id vs name**:`id` 用于稳定引用(命令、React key、未来跨节点引用),`name` 是显示标签,`tag` 是兼容分组——三者独立(可有两个 name="top"/"bottom" 但 tag 同为 "stud" 的 socket)。 + +## 5. 吸附引擎(`src/core/snap/sockets.ts`,纯函数,可单测) + +```ts +import { SNAP_PIXELS, type SnapPoint } from "./nodes"; + +/** socket 吸附候选点:SnapPoint(屏幕算像素门槛 + 世界算 offset)+ tag(兼容匹配)。 */ +export interface SocketPoint extends SnapPoint { + tag: string; +} + +/** 在 dragged × targets 里,**tag 相同且非空**、屏幕距离 < pixelThreshold 的对中, + * 按 **3D 世界距离** 选最近一对,返回把该 dragged socket 对齐到 target socket 的 + * 世界位移(target.world − dragged.world);无命中 null。纯函数,无 three 依赖。 */ +export function snapToSockets( + dragged: readonly SocketPoint[], + targets: readonly SocketPoint[], + pixelThreshold: number = SNAP_PIXELS, +): [number, number, number] | null { + let best: [number, number, number] | null = null; + let bestWorldDist = Infinity; + for (const d of dragged) { + if (!d.tag) continue; + for (const t of targets) { + if (t.tag !== d.tag) continue; + 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; +} +``` + +复用 `nodes.ts` 的「屏幕像素门槛 + 世界距离选最近」结构,只多了 tag 过滤。 + +## 6. 架构 / 组件(职责 + 数据流) + +### 6.1 socket 世界点 helper(ThreeViewport) + +被拖/目标节点的某 socket 世界点 = `node.matrixWorld × 局部 position`;屏幕点复用 B 已有的 `toScreen(v, camera, w, h)`。新增一个 helper 把「一个节点 + 它的 sockets」→ `SocketPoint[]`: + +```ts +function socketPoints( + obj: THREE.Object3D, + sockets: Socket[], + camera, + w, + h, +): SocketPoint[] { + obj.updateWorldMatrix(true, false); + const v = new THREE.Vector3(); + return sockets.map((s) => { + v.set(s.position[0], s.position[1], s.position[2]).applyMatrix4(obj.matrixWorld); + return { screen: toScreen(v, camera, w, h), world: [v.x, v.y, v.z], tag: s.tag }; + }); +} +``` + +### 6.2 mouseDown 缓存目标 socket(仿 B 的 cachedTargets) + +effect 内闭包变量 `cachedSocketTargets: SocketPoint[]`。mouseDown(相机已禁拖、屏幕坐标缓存成立):遍历其它节点(≠被拖、可见、非 helper),取其 store 里的 `sockets`,`socketPoints(tobj, n.sockets, ...)` 累加。 + +### 6.3 objectChange 吸附(优先级 socket > B > A) + +> **已实现偏差(2026-06-09,视觉验证后调整,PR #36)**:本节及全文多处写的「socket 未命中 → 回退 B → A」**仅对无 socket 的节点成立**。实测发现:B(#34 节点对齐,含 6 面中心特征)会把两个 box 面对面吸上,从而**掩盖 socket 的 tag 过滤**——tag 不同也照样靠 B 吸合,socket 显不出价值。故改为:**被拖节点只要有任一非空 tag 的 socket,就只走 socket 吸附,未命中直接落网格 A,不再回退 B**;无 socket(或全空 tag)的节点保持 socket→B→A 链不变。这样 tag 兼容真正成为「谁能和谁接」的闸门。实现见 `ThreeViewport.tsx` 的 `hasSockets` 守卫。 + +在现有 objectChange handler 里、**B 的节点对齐之前**插 socket 吸附: + +```ts +// gizmoMode==="translate" && snapModifierDown 已是现有门槛 +const draggedNode = useSceneStore.getState().project?.scene.nodes[obj.userData.nodeId]; +const dragged = socketPoints(obj, draggedNode?.sockets ?? [], adapter.camera, w, h); +const socketOffset = snapToSockets(dragged, cachedSocketTargets, SNAP_PIXELS); +if (socketOffset) { + obj.position.add(new THREE.Vector3(...socketOffset)); + return; +} +// 否则落到 B(snapToNodes)→ A(snapTranslation)现有逻辑 +``` + +落点提交仍是现有 `mouseUp → SetNodeTransformCommand`(一次 undo,**不新增吸附命令**)。 + +### 6.4 授权 UI(Properties 面板「Sockets」区) + +仿 `MaterialSection` / `BehaviorsPanel`:选中节点时在 Properties tab 渲染一个 `SocketsSection`:socket 列表(每行 name 输入 + x/y/z 数值 + tag 输入 + 删除)+「+ 添加 socket」。任一增删改 → 构造新的 `sockets` 数组 → `executeCommand(new SetNodeSocketsCommand({ node_id, sockets, prev_sockets }))`。对**任意节点类型**可用(mesh / prefab_instance / group …)。 + +### 6.5 命令 + store mutation + +```ts +// set-node-sockets.ts(仿 set-node-transform / set-material-override 的样板) +SET_NODE_SOCKETS = "node.set_sockets" +payload: { node_id, sockets } input 另带 prev_sockets +apply(store): store.setNodeSockets(node_id, sockets) +revert(store): store.setNodeSockets(node_id, prev_sockets) +``` + +scene store(`src/services/scene/store.ts` + 其 `SceneEditorStore` 接口)加 `setNodeSockets(node_id, sockets)` mutation(不可变更新对应节点的 `sockets`)。 + +### 6.6 socket 标记渲染(视口 overlay) + +视口维护一个 `socketMarkers` `THREE.Group`(加进 `adapter.scene`,仿 gizmo helper 的加法),为**所有节点的所有 socket** 各放一个小标记(小球 / sprite),选中节点的**强调**(颜色 / 尺寸)。标记 `raycast` 置空(或排除出 pickAt 的 intersect),**不干扰拾取**。 + +跟随策略(plan 阶段二选一,注意权衡): + +- **(a) parent 到节点 Object3D 下**:自动跟随(含拖拽);但与 adapter 节点生命周期耦合——节点 `rebuild`/`remove`/`disposeSubtree` 时标记受影响,需在 `diffAndApply` 后补建/清理。 +- **(b) 独立 group + 世界坐标摆放**:store sockets / 节点增删 / 选中变化时重建;拖拽中只每帧刷新**被拖节点**的标记。无生命周期耦合,但 drag 中要手动更新。 + +**MVP 倾向 (b)**(与 adapter 解耦更稳;被拖节点单个、每帧刷新开销可忽略)。 + +### 6.7 数据流 + +``` +[授权] Sockets 面板 增删改 → SetNodeSocketsCommand → store.setNodeSockets → 持久化 + 标记重建 +[吸附] mouseDown 缓存其它节点 socket 世界/屏幕点(+tag) + → 拖拽 objectChange:被拖节点 socket 点 → snapToSockets(tag 匹配 + 屏幕门槛 + 世界最近) + → 命中 offset → obj.position.add → (否则 B→A) + → mouseUp → SetNodeTransformCommand(可撤销) +``` + +## 7. 兼容规则 + +MVP = **tag 精确匹配**:两 socket 兼容 ⟺ `a.tag === b.tag && a.tag !== ""`。空 tag 不参与吸附。日后由 Out-of-scope 的「模型特性规则引擎」替换/增强。 + +## 8. 持久化 / 迁移 / 导出 + +- socket 随节点存入 `project.json`(per-node 文件含 `sockets`)。 +- 迁移:`schema .default([])` 处理老项目 / `examples/` / demo(无 `sockets` 字段)→ 加载时填 `[]`,无需 migration 函数。 +- 导出:socket 是**编辑期拼装元数据**,拼装结果已 bake 进各节点 `position`;导出的 Three.js 代码**不含 socket**。exporter(vite / standalone emitter)只读既有字段,天然忽略 `sockets`,**codegen 零改动**。 + +## 9. 边界 / 错误处理 + +- 目标排除:被拖节点自己 / helper(grid·axes) / 不可见节点。无 socket 的节点自然贡献空集。 +- 无 socket 命中 → B→A 回退;场景无任何兼容 socket → 等同 B/A 行为。 +- 空 tag socket:可创建(仅作标注),但不参与吸附(§7)。 +- 被拖节点 socket 世界点用拖拽中的 `obj.matrixWorld`(`updateWorldMatrix(true,false)` 刷新)——与 B 一致。 +- 删除节点:其 socket 随节点移除;标记重建剔除。locked 节点:可作吸附目标;其 socket 面板编辑是否禁用跟随现有 locked 策略(plan 阶段对齐 `isEffectivelyLocked`)。 + +## 10. 视觉反馈 + +- socket 标记(§6.6):所有 socket 小标记,选中节点强调。 +- **无对齐参考线 / 命中高亮**(Smart Guides 仍在 Backlog,延后)。 + +## 11. 测试策略 + +- `sockets.test.ts`(纯函数):tag 相同且屏幕内命中 → 正确 world offset;tag 不同 → null;空 tag(dragged 或 target)→ 跳过;屏幕超阈值 → null;多对取**世界最近**;空 targets → null。 +- schema:节点对象无 `sockets` 字段 → `SceneNodeSchema.parse` 后 `sockets === []`(`.default` 生效);带 sockets 正常往返。 +- 命令:`SetNodeSocketsCommand` apply/revert 往返(可参照现有命令测试)。 +- 面板 / 标记渲染 / objectChange 吸附(three + DOM + 拖拽)**不单测** → `pnpm tauri dev` 视觉验证:给两个 box 各加 tag 相同的 socket、按住 Ctrl/Cmd 拖一个使其 socket 靠近另一个的 socket → 吸附重合;tag 不同 → 不吸(回退 B/A);面板增删改可撤销;标记可见且不干扰点选。 + +## 12. 文件清单 + +**新增**: + +- `src/core/snap/sockets.ts` + `sockets.test.ts` +- `src/core/command/commands/set-node-sockets.ts`(+ 测试可选) +- Sockets 面板组件(`src/ui/editor/SocketsSection.tsx`,仿 `MaterialSection`) + +**修改**: + +- `src/core/scene/schemas.ts`(`SocketSchema` + `SceneNodeSchema.sockets`) +- `src/core/scene/types.ts`(`Socket` 类型) +- `src/services/scene/store.ts`(`setNodeSockets` mutation + `SceneEditorStore` 接口) +- `src/services/scene/defaults.ts`(若 `createDefaultProject` 显式建节点,确认 sockets 默认 `[]`) +- `src/ui/views/EditorView.tsx`(在 `NodeProperties` 渲染 `SocketsSection`) +- `src/ui/viewport/ThreeViewport.tsx`(`socketPoints` helper + mouseDown 缓存 + objectChange socket 吸附优先 + socket 标记渲染) + +## 13. 风险 / 延后 + +- **标记性能**:每节点每 socket 一个对象;MVP 场景小可忽略。跟随策略见 §6.6((a) parent 自动跟随但耦合节点生命周期 / (b) 解耦、drag 中只刷新被拖节点;MVP 倾向 b)。大规模场景的标记 LOD / 合批延后。 +- **标记与拾取**:必须排除标记出 pickAt(raycast 置空或 intersect 过滤),否则点选被标记挡。 +- **拼装规模**:大量构件的批量吸附 / 多 socket 同时咬合 → 后续阶段。 +- 朝向咬合 / 规则引擎 / glb 内嵌识别 / 点选放置 / 类型库 → roadmap Backlog(§3 Out of scope)。 + +## 14. 反推源(plan 阶段读真实代码确认的符号) + +- `src/core/scene/schemas.ts`:`Vec3Schema`(line 7)、`BehaviorBindingSchema`(125)、`SceneNodeSchema`(136, 含 `.refine`)——确认新字段位置与 `.default` 写法。 +- `src/core/snap/nodes.ts`:`SnapPoint` / `SNAP_PIXELS` 导出(sockets.ts import)。 +- `src/ui/viewport/ThreeViewport.tsx`:`toScreen` / `featureSnapPoints` / `cachedTargets` / `snapModifierDown` / objectChange handler / mouseDown handler / gizmo `obj.userData.nodeId` —— socket 吸附插在 B 之前;标记 group 加进 `adapter.scene` 的方式仿 gizmo helper。 +- `src/core/command/commands/set-node-transform.ts` 或 `set-material-override.ts`:命令样板(`Command` 接口、payload、apply/revert)。 +- `src/services/scene/store.ts`:`SceneEditorStore` 接口 + 现有 mutation(如 `setNodeTransform`/材质 override 的 setter)——`setNodeSockets` 仿写;确认不可变更新 + 触发 ThreeViewport 的 store 订阅 diff。 +- `src/ui/editor/MaterialSection.tsx` / `BehaviorsPanel.tsx`:面板区组件 + 在 `NodeProperties` 的挂载方式。 +- `src/services/scene/defaults.ts` + `examples/*/`:确认默认/示例节点经 `.default([])` 后含 `sockets: []`。 + +## 15. 验收 + +满足 §2:面板增删改 socket 可撤销、视口标记可见不干扰拾取、按住 Ctrl/Cmd 拖拽时 tag 相同的 socket 屏幕 12px 内吸附重合、否则回退 B→A、socket 持久化且老项目可加载且不进导出代码、`snapToSockets` + schema 默认值单测绿 + 视觉验证。下一步(后续阶段):朝向咬合。 diff --git a/src/core/command/commands/_test-utils.ts b/src/core/command/commands/_test-utils.ts index b57568c..20f4d2f 100644 --- a/src/core/command/commands/_test-utils.ts +++ b/src/core/command/commands/_test-utils.ts @@ -1,5 +1,5 @@ import type { MaterialOverride } from "@/core/scene/material"; -import type { BehaviorBinding, SceneNode, Transform } from "@/core/scene/types"; +import type { BehaviorBinding, SceneNode, Socket, Transform } from "@/core/scene/types"; import type { SceneNodeSnapshot } from "@/core/scene/snapshot"; import type { SceneEditorStore } from "../types"; @@ -22,6 +22,7 @@ type Call = | { op: "setNodeTransform"; id: string; transform: Transform } | { op: "addNode"; node: SceneNode } | { op: "setMeshMaterial"; nodeId: string; override: MaterialOverride | undefined } + | { op: "setNodeSockets"; nodeId: string; sockets: Socket[] } | { op: "removeNodeSubtree"; nodeId: string } | { op: "restoreNodeSubtree"; snapshot: SceneNodeSnapshot } | { @@ -55,6 +56,8 @@ export function makeFakeEditor(node?: SceneNode): FakeEditor { addNode: (node) => calls.push({ op: "addNode", node }), setMeshMaterial: (nodeId, override) => calls.push({ op: "setMeshMaterial", nodeId, override }), + setNodeSockets: (nodeId, sockets) => + calls.push({ op: "setNodeSockets", nodeId, sockets }), removeNodeSubtree: (nodeId) => calls.push({ op: "removeNodeSubtree", nodeId }), restoreNodeSubtree: (snapshot) => calls.push({ op: "restoreNodeSubtree", snapshot }), diff --git a/src/core/command/commands/set-node-sockets.test.ts b/src/core/command/commands/set-node-sockets.test.ts new file mode 100644 index 0000000..86c2fa0 --- /dev/null +++ b/src/core/command/commands/set-node-sockets.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import type { Socket } from "@/core/scene/types"; + +import { SetNodeSocketsCommand } from "./set-node-sockets"; +import type { SceneEditorStore } from "../types"; + +function stubStore() { + const calls: { id: string; sockets: Socket[] }[] = []; + const store = { + setNodeSockets: (id: string, sockets: Socket[]) => calls.push({ id, sockets }), + } as unknown as SceneEditorStore; + return { store, calls }; +} + +const A: Socket[] = [{ id: "s1", name: "top", position: [0, 1, 0], tag: "stud" }]; +const B: Socket[] = []; + +describe("SetNodeSocketsCommand", () => { + it("apply sets sockets, revert restores prev", () => { + const { store, calls } = stubStore(); + const cmd = new SetNodeSocketsCommand({ + node_id: "n1", + sockets: A, + prev_sockets: B, + }); + cmd.apply(store); + cmd.revert(store); + expect(calls).toEqual([ + { id: "n1", sockets: A }, + { id: "n1", sockets: B }, + ]); + }); + + it("merges consecutive edits on the same node into one undo entry", () => { + const first = new SetNodeSocketsCommand({ + node_id: "n1", + sockets: A, + prev_sockets: B, + timestamp: 1000, + }); + const second = new SetNodeSocketsCommand({ + node_id: "n1", + sockets: B, + prev_sockets: A, + timestamp: 1200, + }); + expect(first.canMergeWith(second)).toBe(true); + const merged = first.mergeWith(second); + expect(merged.id).toBe(first.id); + expect((merged.payload as { sockets: Socket[] }).sockets).toEqual(B); + expect((merged.payload as { prev_sockets: Socket[] }).prev_sockets).toEqual(B); + }); + + it("does not merge across different nodes", () => { + const a = new SetNodeSocketsCommand({ + node_id: "n1", + sockets: A, + prev_sockets: B, + timestamp: 1000, + }); + const b = new SetNodeSocketsCommand({ + node_id: "n2", + sockets: A, + prev_sockets: B, + timestamp: 1100, + }); + expect(a.canMergeWith(b)).toBe(false); + }); +}); diff --git a/src/core/command/commands/set-node-sockets.ts b/src/core/command/commands/set-node-sockets.ts new file mode 100644 index 0000000..65feb47 --- /dev/null +++ b/src/core/command/commands/set-node-sockets.ts @@ -0,0 +1,72 @@ +import type { Socket } from "@/core/scene/types"; + +import { generateUUID } from "../../id/uuid"; +import type { Command, SceneEditorStore } from "../types"; + +export const SET_NODE_SOCKETS = "node.sockets.set" as const; + +/** Merge window for consecutive socket edits (rapid typing in the panel) — + * same rationale + value as SetNodeTransform / SetMaterialOverride. */ +export const MERGE_WINDOW_MS = 500; + +export interface SetNodeSocketsPayload extends Record { + node_id: string; + sockets: Socket[]; + prev_sockets: Socket[]; +} + +export interface SetNodeSocketsInput { + node_id: string; + sockets: Socket[]; + prev_sockets: Socket[]; + id?: string; + timestamp?: number; +} + +export class SetNodeSocketsCommand implements Command { + readonly id: string; + readonly type = SET_NODE_SOCKETS; + readonly timestamp: number; + readonly payload: SetNodeSocketsPayload; + + constructor(input: SetNodeSocketsInput) { + this.id = input.id ?? generateUUID(); + this.timestamp = input.timestamp ?? Date.now(); + this.payload = { + node_id: input.node_id, + sockets: input.sockets, + prev_sockets: input.prev_sockets, + }; + } + + apply(store: SceneEditorStore): void { + store.setNodeSockets(this.payload.node_id, this.payload.sockets); + } + + revert(store: SceneEditorStore): void { + store.setNodeSockets(this.payload.node_id, this.payload.prev_sockets); + } + + canMergeWith(other: Command): boolean { + if (other.type !== SET_NODE_SOCKETS) return false; + const o = other.payload as SetNodeSocketsPayload; + if (o.node_id !== this.payload.node_id) return false; + return Math.abs(other.timestamp - this.timestamp) < MERGE_WINDOW_MS; + } + + mergeWith(other: Command): SetNodeSocketsCommand { + if (!this.canMergeWith(other)) { + throw new Error( + `cannot merge ${this.type} with ${other.type}: node or window mismatch`, + ); + } + const o = other.payload as SetNodeSocketsPayload; + return new SetNodeSocketsCommand({ + id: this.id, + node_id: this.payload.node_id, + sockets: o.sockets, + prev_sockets: this.payload.prev_sockets, + timestamp: other.timestamp, + }); + } +} diff --git a/src/core/command/commands/set-node-transform.test.ts b/src/core/command/commands/set-node-transform.test.ts index 3967dce..30988f2 100644 --- a/src/core/command/commands/set-node-transform.test.ts +++ b/src/core/command/commands/set-node-transform.test.ts @@ -61,6 +61,9 @@ function createTestStore(...nodes: SceneNode[]): TestStore { setMeshMaterial: () => { throw new Error("setMeshMaterial not implemented in this fake"); }, + setNodeSockets: () => { + throw new Error("setNodeSockets not implemented in this fake"); + }, removeNodeSubtree: () => { throw new Error("removeNodeSubtree not implemented in this fake"); }, diff --git a/src/core/command/types.ts b/src/core/command/types.ts index 942d2b3..968c36e 100644 --- a/src/core/command/types.ts +++ b/src/core/command/types.ts @@ -1,6 +1,6 @@ import type { MaterialOverride } from "../scene/material"; import type { SceneNodeSnapshot } from "../scene/snapshot"; -import type { BehaviorBinding, SceneNode, Transform } from "../scene/types"; +import type { BehaviorBinding, SceneNode, Socket, Transform } from "../scene/types"; /** * The minimal slice of editor state a scene-editing Command interacts with. @@ -21,6 +21,7 @@ export interface SceneEditorStore { // ── new (Phase 3 · 3.1) ── addNode(node: SceneNode): void; setMeshMaterial(nodeId: string, override: MaterialOverride | undefined): void; + setNodeSockets(nodeId: string, sockets: Socket[]): void; removeNodeSubtree(nodeId: string): void; restoreNodeSubtree(snapshot: SceneNodeSnapshot): void; duplicateNode(sourceNodeId: string, newSubtree: SceneNodeSnapshot): void; diff --git a/src/core/scene/persistence.test.ts b/src/core/scene/persistence.test.ts index 8cce964..83f4009 100644 --- a/src/core/scene/persistence.test.ts +++ b/src/core/scene/persistence.test.ts @@ -111,6 +111,21 @@ describe("deserializeProject", () => { } }); + it("round-trips a node's sockets (v0.4 C)", () => { + const project = fixedDemo(); + const target = Object.keys(project.scene.nodes)[0]!; + project.scene.nodes[target]!.sockets = [ + { id: "s1", name: "top", position: [0, 0.5, 0], tag: "stud" }, + ]; + const result = deserializeProject(serializeProject(project)); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.project.scene.nodes[target]?.sockets).toEqual([ + { id: "s1", name: "top", position: [0, 0.5, 0], tag: "stud" }, + ]); + } + }); + it("reconstructs parent_id and children_ids from hierarchy", () => { const project = fixedDemo(); const files = serializeProject(project); diff --git a/src/core/scene/persistence.ts b/src/core/scene/persistence.ts index e9a8025..60ee387 100644 --- a/src/core/scene/persistence.ts +++ b/src/core/scene/persistence.ts @@ -103,6 +103,7 @@ export function serializeProject(project: SceneProject): SerializedFiles { locked: node.locked, data: node.data, behaviors: node.behaviors, + sockets: node.sockets, user_data: node.user_data, }; files.set(`${NODE_DIR}/${id}.json`, pretty(body)); diff --git a/src/core/scene/schemas.test.ts b/src/core/scene/schemas.test.ts index 0beba83..22231ca 100644 --- a/src/core/scene/schemas.test.ts +++ b/src/core/scene/schemas.test.ts @@ -5,6 +5,7 @@ import { RuntimeTargetSchema, SceneNodeSchema, SceneProjectSchema, + SocketSchema, SPEC_VERSION, TransformSchema, } from "./schemas"; @@ -201,3 +202,39 @@ describe("SceneProjectSchema", () => { ).toThrow(); }); }); + +describe("Socket / SceneNode.sockets", () => { + const baseNode = { + id: "n1", + name: "n", + type: "group" as const, + transform: { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] }, + parent_id: null, + children_ids: [], + visible: true, + locked: false, + data: { type: "group" as const }, + behaviors: [], + user_data: {}, + }; + + it("sockets is optional — a node without it parses to undefined", () => { + expect(SceneNodeSchema.parse(baseNode).sockets).toBeUndefined(); + }); + + it("parses a node with sockets", () => { + const n = SceneNodeSchema.parse({ + ...baseNode, + sockets: [{ id: "s1", name: "top", position: [0, 1, 0], tag: "stud" }], + }); + expect(n.sockets).toEqual([ + { id: "s1", name: "top", position: [0, 1, 0], tag: "stud" }, + ]); + }); + + it("SocketSchema requires id/name/position/tag", () => { + expect(() => + SocketSchema.parse({ id: "s", name: "n", position: [0, 0, 0] }), + ).toThrow(); + }); +}); diff --git a/src/core/scene/schemas.ts b/src/core/scene/schemas.ts index d7bc187..d5273ff 100644 --- a/src/core/scene/schemas.ts +++ b/src/core/scene/schemas.ts @@ -129,6 +129,15 @@ export const BehaviorBindingSchema = z.object({ parameters: z.record(z.string(), z.unknown()), }); +// ─────────────────────── Socket(v0.4 C:模块化拼装地基)─────────────────────── + +export const SocketSchema = z.object({ + id: z.string(), // 内部稳定键(仿 BehaviorBinding.id) + name: z.string(), // 用户标签(如 "top"/"bottom") + position: Vec3Schema, // 节点局部坐标 + tag: z.string(), // 兼容分组;空串 = 不参与吸附 +}); + // ─────────────────────── Scene node ─────────────────────── // Renamed from architecture's `Node` to avoid clashing with the DOM `Node` @@ -145,6 +154,7 @@ export const SceneNodeSchema = z locked: z.boolean(), data: NodeDataSchema, behaviors: z.array(BehaviorBindingSchema), + sockets: z.array(SocketSchema).optional(), user_data: z.record(z.string(), z.unknown()), }) .refine((node) => node.type === node.data.type, { diff --git a/src/core/scene/types.ts b/src/core/scene/types.ts index d670778..c123e15 100644 --- a/src/core/scene/types.ts +++ b/src/core/scene/types.ts @@ -9,6 +9,7 @@ import type { SceneGraphSchema, SceneNodeSchema, SceneProjectSchema, + SocketSchema, TransformSchema, Vec3Schema, } from "./schemas"; @@ -28,6 +29,7 @@ export type SceneNode = z.infer; export type AssetReference = z.infer; export type BehaviorBinding = z.infer; +export type Socket = z.infer; export type SceneGraph = z.infer; export type SceneProject = z.infer; diff --git a/src/core/snap/sockets.test.ts b/src/core/snap/sockets.test.ts new file mode 100644 index 0000000..4b1182b --- /dev/null +++ b/src/core/snap/sockets.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { snapToSockets, type SocketPoint } from "./sockets"; + +const P = ( + screen: [number, number], + world: [number, number, number], + tag: string, +): SocketPoint => ({ screen, world, tag }); + +describe("snapToSockets", () => { + it("aligns a dragged socket to a same-tag target within the pixel threshold", () => { + const offset = snapToSockets( + [P([100, 100], [0, 0, 0], "stud")], + [P([105, 100], [2, 0, 0], "stud")], + 12, + ); + expect(offset).toEqual([2, 0, 0]); + }); + + it("ignores targets with a different tag", () => { + expect( + snapToSockets( + [P([100, 100], [0, 0, 0], "stud")], + [P([101, 100], [2, 0, 0], "pipe")], + 12, + ), + ).toBeNull(); + }); + + it("ignores empty-tag sockets", () => { + expect( + snapToSockets([P([100, 100], [0, 0, 0], "")], [P([101, 100], [2, 0, 0], "")], 12), + ).toBeNull(); + }); + + it("returns null when no pair is within the pixel threshold", () => { + expect( + snapToSockets( + [P([100, 100], [0, 0, 0], "stud")], + [P([200, 200], [2, 0, 0], "stud")], + 12, + ), + ).toBeNull(); + }); + + it("among same-tag in-threshold pairs, picks the world-nearest", () => { + const offset = snapToSockets( + [P([100, 100], [0, 0, 0], "stud")], + [P([104, 100], [5, 0, 0], "stud"), P([108, 100], [1, 0, 0], "stud")], + 12, + ); + expect(offset).toEqual([1, 0, 0]); + }); + + it("returns null for empty targets", () => { + expect(snapToSockets([P([100, 100], [0, 0, 0], "stud")], [], 12)).toBeNull(); + }); +}); diff --git a/src/core/snap/sockets.ts b/src/core/snap/sockets.ts new file mode 100644 index 0000000..84d6a69 --- /dev/null +++ b/src/core/snap/sockets.ts @@ -0,0 +1,43 @@ +import { SNAP_PIXELS, type SnapPoint } from "./nodes"; + +/** socket 吸附候选点:SnapPoint(屏幕算像素门槛 + 世界算 offset)+ tag(兼容匹配)。 */ +export interface SocketPoint extends SnapPoint { + tag: string; +} + +/** 在 dragged × targets 里,**tag 相同且非空**、屏幕距离 < pixelThreshold 的对中, + * 按 **3D 世界距离** 选最近一对,返回把该 dragged socket 对齐到 target socket 的 + * 世界位移(target.world − dragged.world);无命中 null。纯函数,无 three 依赖。 */ +export function snapToSockets( + dragged: readonly SocketPoint[], + targets: readonly SocketPoint[], + pixelThreshold: number = SNAP_PIXELS, +): [number, number, number] | null { + let best: [number, number, number] | null = null; + let bestWorldDist = Infinity; + for (const d of dragged) { + if (!d.tag) continue; + for (const t of targets) { + if (t.tag !== d.tag) continue; + 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/i18n/locales/en-US/editor.json b/src/i18n/locales/en-US/editor.json index f733823..40232ae 100644 --- a/src/i18n/locales/en-US/editor.json +++ b/src/i18n/locales/en-US/editor.json @@ -102,6 +102,12 @@ "add_hint": "Double-click or drag into the viewport to add", "empty_uploads": "No uploads yet — use Upload .glb to add models." }, + "sockets": { + "section": "Sockets", + "add": "Add socket", + "empty": "No sockets. Click + to add a connection point.", + "tag_placeholder": "tag" + }, "ai_command": { "placeholder": "Describe what you want…", "send": "Send", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index eba61a7..ee30ba6 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -102,6 +102,12 @@ "add_hint": "双击或拖入视口添加", "empty_uploads": "暂无上传——点击「上传 .glb」添加模型。" }, + "sockets": { + "section": "插槽", + "add": "添加插槽", + "empty": "暂无插槽。点击 + 添加连接点。", + "tag_placeholder": "tag" + }, "ai_command": { "placeholder": "描述你想要的…", "send": "发送", diff --git a/src/services/command-history/store.test.ts b/src/services/command-history/store.test.ts index 9f93f61..dc22bcc 100644 --- a/src/services/command-history/store.test.ts +++ b/src/services/command-history/store.test.ts @@ -54,6 +54,9 @@ function makeEditor(initial: SceneNode): SceneEditorStore { setMeshMaterial: () => { throw new Error("setMeshMaterial not implemented in this fake"); }, + setNodeSockets: () => { + throw new Error("setNodeSockets not implemented in this fake"); + }, removeNodeSubtree: () => { throw new Error("removeNodeSubtree not implemented in this fake"); }, diff --git a/src/services/scene/store.test.ts b/src/services/scene/store.test.ts index 1ed94b0..2320c4b 100644 --- a/src/services/scene/store.test.ts +++ b/src/services/scene/store.test.ts @@ -377,3 +377,41 @@ describe("useSceneStore.setMeshMaterial", () => { ).toBeUndefined(); }); }); + +describe("setNodeSockets", () => { + it("replaces a node's sockets immutably (new node identity)", () => { + useSceneStore.setState({ project: freshProject() }); + const node: SceneNode = { + id: "n1", + name: "n1", + type: "group", + transform: IDENTITY, + parent_id: null, + children_ids: [], + visible: true, + locked: false, + data: { type: "group" }, + behaviors: [], + user_data: {}, + }; + useSceneStore.getState().addNode(node); + const before = useSceneStore.getState().getNode("n1"); + const sockets = [ + { + id: "s1", + name: "top", + position: [0, 1, 0] as [number, number, number], + tag: "stud", + }, + ]; + useSceneStore.getState().setNodeSockets("n1", sockets); + const after = useSceneStore.getState().getNode("n1"); + expect(after?.sockets).toEqual(sockets); + expect(after).not.toBe(before); + }); + + it("is a no-op when the node does not exist", () => { + useSceneStore.setState({ project: freshProject() }); + expect(() => useSceneStore.getState().setNodeSockets("nope", [])).not.toThrow(); + }); +}); diff --git a/src/services/scene/store.ts b/src/services/scene/store.ts index 66b6099..0d54787 100644 --- a/src/services/scene/store.ts +++ b/src/services/scene/store.ts @@ -7,6 +7,7 @@ import type { SceneGraph, SceneNode, SceneProject, + Socket, Transform, } from "@/core/scene/types"; import type { MaterialOverride } from "@/core/scene/material"; @@ -54,6 +55,9 @@ interface SceneState { * No-op when the node is missing or not a mesh. Used by * SetMaterialOverrideCommand. */ setMeshMaterial: (nodeId: string, override: MaterialOverride | undefined) => void; + /** Replace a node's socket list. No-op when the node is missing. Used by + * SetNodeSocketsCommand. */ + setNodeSockets: (nodeId: string, sockets: Socket[]) => void; /** Remove a node and every descendant; also drops the id from the parent's * children_ids (or from scene.root_node_ids when the node is a root). * Silent no-op when nodeId does not exist. Used by DeleteNodeCommand. */ @@ -249,6 +253,14 @@ export const useSceneStore = create((set, get) => ({ }; return mutateNode(s, nodeId, nextNode); }), + setNodeSockets: (nodeId, sockets) => + set((s) => { + if (!s.project) return s; + const node = s.project.scene.nodes[nodeId]; + if (!node) return s; + const nextNode: SceneNode = { ...node, sockets }; + return mutateNode(s, nodeId, nextNode); + }), removeNodeSubtree: (nodeId) => set((s) => { if (!s.project) return s; @@ -364,6 +376,8 @@ export function getSceneEditorStore(): SceneEditorStore { addNode: (node) => useSceneStore.getState().addNode(node), setMeshMaterial: (nodeId, override) => useSceneStore.getState().setMeshMaterial(nodeId, override), + setNodeSockets: (nodeId, sockets) => + useSceneStore.getState().setNodeSockets(nodeId, sockets), removeNodeSubtree: (nodeId) => useSceneStore.getState().removeNodeSubtree(nodeId), restoreNodeSubtree: (snapshot) => useSceneStore.getState().restoreNodeSubtree(snapshot), diff --git a/src/ui/editor/SocketsSection.test.tsx b/src/ui/editor/SocketsSection.test.tsx new file mode 100644 index 0000000..36e8d0f --- /dev/null +++ b/src/ui/editor/SocketsSection.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useCommandHistoryStore } from "@/services/command-history/store"; +import { useSceneStore } from "@/services/scene/store"; +import type { SceneNode } from "@/core/scene/types"; + +import { SocketsSection } from "./SocketsSection"; + +const IDENTITY = { + position: [0, 0, 0] as [number, number, number], + rotation: [0, 0, 0, 1] as [number, number, number, number], + scale: [1, 1, 1] as [number, number, number], +}; + +function groupNode(sockets?: SceneNode["sockets"]): SceneNode { + return { + id: "n1", + name: "n1", + type: "group", + transform: IDENTITY, + parent_id: null, + children_ids: [], + visible: true, + locked: false, + data: { type: "group" }, + behaviors: [], + user_data: {}, + sockets, + }; +} + +describe("SocketsSection", () => { + beforeEach(() => { + useSceneStore.setState({ project: null }); + useCommandHistoryStore.getState().clear(); + }); + + it("shows an existing socket's name", () => { + render( + , + ); + expect(screen.getByDisplayValue("top")).toBeInTheDocument(); + }); + + it("clicking add dispatches a node.sockets.set command", () => { + const exec = vi.spyOn(useCommandHistoryStore.getState(), "execute"); + render(); + fireEvent.click(screen.getByTitle("Add socket")); + expect(exec).toHaveBeenCalledWith( + expect.objectContaining({ type: "node.sockets.set" }), + expect.anything(), + ); + }); +}); diff --git a/src/ui/editor/SocketsSection.tsx b/src/ui/editor/SocketsSection.tsx new file mode 100644 index 0000000..a9799aa --- /dev/null +++ b/src/ui/editor/SocketsSection.tsx @@ -0,0 +1,120 @@ +import { Plus, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { SetNodeSocketsCommand } from "@/core/command/commands/set-node-sockets"; +import { generateUUID } from "@/core/id/uuid"; +import type { SceneNode, Socket } from "@/core/scene/types"; +import { executeCommand } from "@/services/command-history"; + +export function SocketsSection({ node }: { node: SceneNode }) { + const { t } = useTranslation("editor"); + const sockets = node.sockets ?? []; + + const commit = (next: Socket[]) => + executeCommand( + new SetNodeSocketsCommand({ + node_id: node.id, + sockets: next, + prev_sockets: sockets, + }), + ); + + const add = () => + commit([ + ...sockets, + { id: generateUUID(), name: "socket", position: [0, 0, 0], tag: "" }, + ]); + const remove = (id: string) => commit(sockets.filter((s) => s.id !== id)); + const patch = (id: string, p: Partial) => + commit(sockets.map((s) => (s.id === id ? { ...s, ...p } : s))); + + return ( +
+
+

+ {t("sockets.section")} +

+ +
+ + {sockets.length === 0 ? ( +

{t("sockets.empty")}

+ ) : ( + sockets.map((s) => ( + patch(s.id, p)} + onRemove={() => remove(s.id)} + /> + )) + )} +
+ ); +} + +function SocketRow({ + socket, + tagPlaceholder, + onPatch, + onRemove, +}: { + socket: Socket; + tagPlaceholder: string; + onPatch: (p: Partial) => void; + onRemove: () => void; +}) { + const inputCls = + "rounded border border-border bg-background/50 px-1.5 py-0.5 outline-none focus:border-primary"; + return ( +
+
+ onPatch({ name: e.target.value })} + className={`w-0 flex-1 ${inputCls}`} + /> + +
+
+ {([0, 1, 2] as const).map((i) => ( + { + const p: [number, number, number] = [...socket.position]; + p[i] = Number(e.target.value) || 0; + onPatch({ position: p }); + }} + className="w-full rounded border border-border bg-background/50 px-1.5 py-0.5 text-right outline-none focus:border-primary" + /> + ))} +
+ onPatch({ tag: e.target.value })} + className={`w-full ${inputCls}`} + /> +
+ ); +} diff --git a/src/ui/viewport/ThreeViewport.tsx b/src/ui/viewport/ThreeViewport.tsx index 6fb6cbe..7aab74b 100644 --- a/src/ui/viewport/ThreeViewport.tsx +++ b/src/ui/viewport/ThreeViewport.tsx @@ -11,6 +11,7 @@ import { AddNodeCommand } from "@/core/command/commands/add-node"; 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 { snapToSockets, type SocketPoint } from "@/core/snap/sockets"; import { isEffectivelyLocked } from "@/core/scene/policy"; import { dropPositionFor } from "@/lib/drop-helpers"; import { ThreeAdapter } from "@/runtime/three/adapter"; @@ -19,6 +20,7 @@ import type { AssetReference, SceneNode, SceneProject, + Socket, Transform, } from "@/core/scene/types"; import { useAssetPreviewStore } from "@/services/assets/preview-store"; @@ -125,6 +127,7 @@ export function ThreeViewport() { let dragStart: Transform | null = null; let cachedTargets: SnapPoint[] = []; + let cachedSocketTargets: SocketPoint[] = []; gizmo.addEventListener("dragging-changed", (event) => { const dragging = event.value as unknown as boolean; @@ -153,12 +156,16 @@ export function ThreeViewport() { const w = canvas.clientWidth; const h = canvas.clientHeight; cachedTargets = []; + cachedSocketTargets = []; 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)); + cachedSocketTargets.push( + ...socketPoints(tobj, n.sockets ?? [], adapter.camera, w, h), + ); } }); gizmo.addEventListener("mouseUp", () => { @@ -192,7 +199,7 @@ export function ThreeViewport() { window.addEventListener("pointermove", onSnapPointer, true); window.addEventListener("pointerdown", onSnapPointer, true); - gizmo.addEventListener("objectChange", () => { + const snapDraggedObject = () => { const obj = gizmo.object; if ( !obj || @@ -201,22 +208,43 @@ export function ThreeViewport() { ) { return; } - // Node-align snap first (bbox features → nearest other-node feature). - const draggedPts = featureSnapPoints( - obj, - adapter.camera, - canvas.clientWidth, - canvas.clientHeight, + // Socket snap (C) — highest priority. Aligns a dragged socket to a + // tag-compatible socket on another node by world position. + const w2 = canvas.clientWidth; + const h2 = canvas.clientHeight; + const draggedNode = + useSceneStore.getState().project?.scene.nodes[obj.userData.nodeId as string]; + const socketOffset = snapToSockets( + socketPoints(obj, draggedNode?.sockets ?? [], adapter.camera, w2, h2), + cachedSocketTargets, + SNAP_PIXELS, ); - const offset = snapToNodes(draggedPts, cachedTargets, SNAP_PIXELS); - if (offset) { + if (socketOffset) { obj.position.set( - obj.position.x + offset[0], - obj.position.y + offset[1], - obj.position.z + offset[2], + obj.position.x + socketOffset[0], + obj.position.y + socketOffset[1], + obj.position.z + socketOffset[2], ); return; } + // Node-align snap (B) — only for nodes WITHOUT sockets. Once a node has a + // tagged socket it opts into socket-based assembly: it snaps via its + // sockets alone, so tag compatibility actually gates snapping instead of + // B's whole-bbox face snap masking it. A socket miss then falls through to + // grid only (never B). + const hasSockets = (draggedNode?.sockets ?? []).some((s) => s.tag); + if (!hasSockets) { + const draggedPts = featureSnapPoints(obj, adapter.camera, w2, h2); + 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, @@ -224,8 +252,53 @@ export function ThreeViewport() { obj.position.z, ]); obj.position.set(x, y, z); + }; + + gizmo.addEventListener("objectChange", () => { + snapDraggedObject(); + // Rebuild markers after snapping on EVERY move (all paths, incl. free + // move / rotate / scale and the early-return socket/node-snap paths) so a + // dragged node's socket markers track it instead of freezing mid-snap. + rebuildSocketMarkers(); }); + // ── Socket markers (v0.4 C) ────────────────────────────────── + // Decoupled overlay group: world-positioned markers, rebuilt on + // scene/selection change + during drag. Shared geo/material so + // group.clear() just detaches (no per-marker dispose). raycast no-op so + // pickAt never selects a marker. + const socketGeo = new THREE.SphereGeometry(0.06, 8, 8); + const socketMat = new THREE.MeshBasicMaterial({ color: 0x22d3ee }); + const socketMatSel = new THREE.MeshBasicMaterial({ color: 0xf59e0b }); + const noRaycast = () => {}; + const socketMarkers = new THREE.Group(); + socketMarkers.name = "socketMarkers"; + adapter.scene.add(socketMarkers); + + const rebuildSocketMarkers = () => { + socketMarkers.clear(); + const proj = useSceneStore.getState().project; + if (!proj) return; + const selId = useUIStore.getState().selectedNodeId; + const v = new THREE.Vector3(); + for (const [id, n] of Object.entries(proj.scene.nodes)) { + const sockets = n.sockets; + if (!sockets || sockets.length === 0) continue; + const tobj = adapter.getRuntimeObject(id); + if (!tobj) continue; + tobj.updateWorldMatrix(true, false); + for (const s of sockets) { + const mk = new THREE.Mesh(socketGeo, id === selId ? socketMatSel : socketMat); + v.set(s.position[0], s.position[1], s.position[2]).applyMatrix4( + tobj.matrixWorld, + ); + mk.position.copy(v); + mk.raycast = noRaycast; + socketMarkers.add(mk); + } + } + }; + const syncSelection = (id: string | null) => { if (!id) { gizmo.detach(); @@ -254,6 +327,7 @@ export function ThreeViewport() { outlinePass.selectedObjects = [obj]; }; syncSelection(useUIStore.getState().selectedNodeId); + rebuildSocketMarkers(); const resize = () => { const w = container.clientWidth; @@ -382,6 +456,7 @@ export function ThreeViewport() { if (next === old) return; if (next.metadata.id !== old.metadata.id) return; // handled by effect re-run void diffAndApply(adapter, old, next, gizmo, outlinePass); + rebuildSocketMarkers(); }); const unsubscribeProject = useProjectStore.subscribe((state, prev) => { @@ -393,6 +468,7 @@ export function ThreeViewport() { const unsubscribeUI = useUIStore.subscribe((state, prev) => { if (state.selectedNodeId !== prev.selectedNodeId) { syncSelection(state.selectedNodeId); + rebuildSocketMarkers(); } if (state.gizmoMode !== prev.gizmoMode) { gizmo.setMode(state.gizmoMode); @@ -433,6 +509,11 @@ export function ThreeViewport() { if (canvas.parentNode === container) { container.removeChild(canvas); } + adapter.scene.remove(socketMarkers); + socketMarkers.clear(); + socketGeo.dispose(); + socketMat.dispose(); + socketMatSel.dispose(); adapter.dispose(); adapterRef.current = null; controlsRef.current = null; @@ -549,6 +630,27 @@ function featureSnapPoints( })); } +/** 一个节点 + 它的 sockets → SocketPoint[](世界点经 node.matrixWorld,附 tag)。 */ +function socketPoints( + obj: THREE.Object3D, + sockets: readonly Socket[], + camera: THREE.Camera, + w: number, + h: number, +): SocketPoint[] { + if (sockets.length === 0) return []; + obj.updateWorldMatrix(true, false); + const v = new THREE.Vector3(); + return sockets.map((s) => { + v.set(s.position[0], s.position[1], s.position[2]).applyMatrix4(obj.matrixWorld); + return { + screen: toScreen(v, camera, w, h), + world: [v.x, v.y, v.z] as [number, number, number], + tag: s.tag, + }; + }); +} + function captureTransform(obj: THREE.Object3D): Transform { return { position: [obj.position.x, obj.position.y, obj.position.z], diff --git a/src/ui/views/EditorView.tsx b/src/ui/views/EditorView.tsx index fa930f5..2688cce 100644 --- a/src/ui/views/EditorView.tsx +++ b/src/ui/views/EditorView.tsx @@ -14,6 +14,7 @@ import { eulerDegToQuat, quatToEulerDeg } from "@/lib/euler"; import { AiCommandBar } from "@/ui/editor/AiCommandBar"; import { BehaviorsPanel } from "@/ui/editor/BehaviorsPanel"; import { MaterialSection } from "@/ui/editor/MaterialSection"; +import { SocketsSection } from "@/ui/editor/SocketsSection"; import { ShortcutsHelpDialog } from "@/ui/help/ShortcutsHelpDialog"; import { AssetDragGhost } from "@/ui/library/AssetDragGhost"; import { LibraryPanel } from "@/ui/library/LibraryPanel"; @@ -228,6 +229,7 @@ function NodeProperties({ node }: { node: SceneNode }) { )} {node.data.type === "mesh" && } + ); }