From 75bdfd2a708863e9b587cac1f3bf619e7218b7f7 Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Mon, 8 Jun 2026 10:40:29 +0800 Subject: [PATCH 01/11] docs(spec): asset-drag-into-viewport design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom pointer drag (Tauri dragDropEnabled=true suppresses HTML5 ondrop) → ghost follows cursor → drop on canvas raycasts y=0 ground plane → new node at hit x/z, keeping the library item's default y. Double-click add-at-default preserved. Pure fns screenToNdc/dropPositionFor/ findLibraryItem (lib layer so runtime adapter can reuse). Undoable via AddNodeCommand. Co-Authored-By: Claude Opus 4.8 --- ...6-06-08-asset-drag-into-viewport-design.md | 278 ++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-asset-drag-into-viewport-design.md diff --git a/docs/superpowers/specs/2026-06-08-asset-drag-into-viewport-design.md b/docs/superpowers/specs/2026-06-08-asset-drag-into-viewport-design.md new file mode 100644 index 0000000..dee8075 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-asset-drag-into-viewport-design.md @@ -0,0 +1,278 @@ +# 资源拖拽入视口 + 落点 设计 + +**状态**: 已批准(2026-06-08) +**前置**: v0.2 资源库(#29)—— `LibraryPanel` 双击卡片 `executeCommand(new AddNodeCommand({ node: item.makeNode() }))`;`catalog.ts` 的 `BUILTIN_LIBRARY_ITEMS`(4 几何 + 4 灯光)+ `uploadLibraryItems(assets)`(从 user_upload 派生)+ `LibraryItem.makeNode()`(每次新 uuid,几何 `position:[0,0.5,0]`、灯光给固定空中位)。v0.4 sub-stage A 网格吸附(#33)+ B 节点对齐吸附(#34)已合并。 +**定位**: 从 v0.2 资源库拆出的 Backlog 项,用户定的顺序:排在 v0.4 sub-stage B(#34)之后、sub-stage C(Socket)之前。 +**架构依据**: 架构 §6 资源库交互 —— MVP 自定义。 + +--- + +## 1. 目标 + +从资源库卡片**拖拽**到视口,以 raycast 命中 **y=0 地面平面**的点作为新节点的落点(覆盖 position 的 x/z、保留库项默认 y)。现有**双击**加节点(默认位置)保留不变。 + +## 2. Success criteria + +1. 从库卡片 `pointerdown` 拖动(移动 > 5px 激活),半透明 ghost 跟随光标;在 canvas 内 `pointerup` → 在落点新增对应节点(落点 = 地面 y=0 交点的 x/z + 库项默认 y)。 +2. 全部库项(几何 / 灯光 / 上传 glb)可拖;**双击行为保留**(纯双击不移动 → 不触发拖拽 → 仍按默认位加节点)。 +3. canvas 外释放 → 取消(不加节点,ghost 消失);raycast 未命中(相机朝天 / 平行地面)→ 回退库项默认 position(仍加节点,等同双击效果)。 +4. 拖入走 `AddNodeCommand` → 可撤销(与双击一致)。 +5. 纯函数 `screenToNdc` / `dropPositionFor` / `findLibraryItem` 单测绿。 +6. `pnpm lint && pnpm typecheck && pnpm test` 全绿 + `pnpm tauri dev` 视觉验证。 + +## 3. 范围 + +### In scope + +- 纯函数 `src/lib/drop-helpers.ts`:`screenToNdc` + `dropPositionFor`。 +- `src/services/library/catalog.ts`:`findLibraryItem(id, uploads)`。 +- `src/runtime/three/adapter.ts`:`raycastGroundPoint(x, y)`(屏幕坐标 → y=0 平面世界交点 | null)。 +- `src/state/ui-store.ts`:`assetDragItemId` + `startAssetDrag` / `endAssetDrag`。 +- `src/services/library/asset-drag.ts`:拖拽发起 controller(候选 → 5px 阈值激活 → 调 `startAssetDrag`)。 +- `src/ui/editor/AssetDragGhost.tsx`:拖拽中 ghost(fixed 跟随光标)。 +- `src/ui/editor/LibraryPanel.tsx`:`LibraryCard` 加 `onPointerDown`(双击不动)。 +- `src/ui/viewport/ThreeViewport.tsx`:`window pointerup` 落点接收(拖拽中 & canvas 内 → 加节点 + 收尾)。 +- library i18n 提示文案。 + +### Out of scope(延后 / roadmap Backlog) + +1. **命中物体表面落点**(放物体顶上)—— 本期只 y=0 地面平面;堆叠交给落地后 gizmo + #34 节点对齐吸附。 +2. **落点网格吸附** —— 本期落点自由;吸附是落地后 gizmo 阶段的事(v0.4a 已覆盖)。 +3. **3D 落点预览**(半透明 mesh 实时跟落点)—— 本期只 DOM ghost;3D 预览 / 落点指示圈延后(Smart Guides 类优化)。 +4. **从 OS 拖文件进编辑器导入** —— 需 `dragDropEnabled`(本期用 pointer 拖拽不动该配置,保留未来该能力)。 +5. glb 落点的 bbox 底部对齐(避免半埋)—— 本期统一「覆盖 x/z、保留默认 y」,glb 可能半埋,落地后 gizmo 调。 + +## 4. 关键约束(探索发现) + +`src-tauri/tauri.conf.json` 的 `app.windows[]` **未设** `dragDropEnabled` → Tauri 2 默认 `true`,会抑制 webview 内 HTML5 `ondrop` DOM 事件。故**不走 HTML5 DnD**,改**自定义 pointer 拖拽**(不动 tauri.conf、与现有 pointer 体系一致、ghost 顺手、保留未来 OS file-drop)。 + +## 5. 数据模型 / 类型 + +### 5.1 纯函数(`src/lib/drop-helpers.ts`) + +```ts +/** 相对 canvas 的屏幕像素 → NDC([-1,1],y 向上)。 */ +export function screenToNdc( + px: number, + py: number, + w: number, + h: number, +): [number, number] { + return [(px / w) * 2 - 1, -((py / h) * 2 - 1)]; +} + +/** 落点世界坐标 → 新节点 position:覆盖 x/z,保留库项默认 y + * (box 坐落点的地面不半埋;灯光保留空中默认高度)。 */ +export function dropPositionFor( + defaultPos: readonly [number, number, number], + hit: readonly [number, number, number], +): [number, number, number] { + return [hit[0], defaultPos[1], hit[2]]; +} +``` + +### 5.2 catalog(`src/services/library/catalog.ts`) + +```ts +/** 按 id 在 builtin + upload 派生项里查 LibraryItem(drop 时取回 makeNode)。 */ +export function findLibraryItem( + id: string, + uploads: AssetReference[], +): LibraryItem | undefined { + return [...BUILTIN_LIBRARY_ITEMS, ...uploadLibraryItems(uploads)].find( + (i) => i.id === id, + ); +} +``` + +### 5.3 ui-store(`src/state/ui-store.ts`) + +```ts +assetDragItemId: string | null; // 拖拽激活中的库项 id(null = 无拖拽) +startAssetDrag: (id: string) => void; // 设 assetDragItemId +endAssetDrag: () => void; // 置 null +``` + +## 6. 架构 / 组件(职责 + 数据流) + +### 6.1 拖拽发起 controller(`src/services/library/asset-drag.ts`) + +module-level,把"按下卡片 → 移动超阈值才算拖拽"与双击区分开(与 canvas click-vs-drag 5px 守卫一致,避免纯双击闪 ghost): + +```ts +let candidate: { id: string; x: number; y: number } | null = null; +let onMove: ((e: PointerEvent) => void) | null = null; +let onUp: (() => void) | null = null; + +/** LibraryCard pointerdown 调用:记候选 + 监听,移动 > 5px 激活拖拽。 */ +export function beginAssetDrag(id: string, clientX: number, clientY: number) { + candidate = { id, x: clientX, y: clientY }; + onMove = (e) => { + if (!candidate) return; + if (Math.hypot(e.clientX - candidate.x, e.clientY - candidate.y) > 5) { + useUIStore.getState().startAssetDrag(candidate.id); + teardown(); // 激活后撤候选监听;ghost + ThreeViewport 接管 + } + }; + onUp = () => teardown(); // 未超阈值就松手(纯双击)→ 不激活,dblclick 正常 + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); +} + +function teardown() { + if (onMove) window.removeEventListener("pointermove", onMove); + if (onUp) window.removeEventListener("pointerup", onUp); + candidate = null; + onMove = null; + onUp = null; +} +``` + +激活后 controller 不再持有监听;收尾(`endAssetDrag`)统一由 ThreeViewport 的 `pointerup` 负责(§6.4)——两处状态独立(controller 的 module 候选 vs store 的 `assetDragItemId`),无竞争。 + +### 6.2 LibraryCard(`src/ui/editor/LibraryPanel.tsx`) + +```tsx +