diff --git a/docs/roadmap.md b/docs/roadmap.md
index dc351c6..efa2b5c 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -110,9 +110,10 @@
- **Sub-stages**:
- [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 系统**:节点上命名插槽 + 拖拽时兼容插槽咬合。
- **Depends on**: v0.3 release
-- **后续插入**:sub-stage B 完成后做「资源拖拽入视口 + 落点」(见 Backlog,用户定的顺序),再进 C。
+- **后续**:「资源拖拽入视口 + 落点」已完成(见上 sub-stage,#35);下一步 sub-stage C(Socket 系统)。
### v1.0 — Planned
@@ -129,7 +130,6 @@
从已完成 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.4 网格吸附结合:落点吸附网格)。**用户已定顺序:排在 v0.4 sub-stage B 之后做**。
- **多源资源上传**(从 v0.2 资源库拆出):`AssetSourceSchema` 已含 `builtin/user_upload/online/ai_generated` 且 catalog 来源无关;目前只实装 `builtin`(几何/灯光预设)+ `user_upload`(.glb)。延后:`online`(在线模型/材质库浏览+下载入库)、`ai_generated`(AI 生成模型/贴图)。
- **多材质槽**(从 v0.2 材质编辑拆出):本期材质编辑只支持 slot 0;prefab/glTF 多材质对象的 slot 1+ 延后。
- **agentic 多轮 Skill 执行**(从 v0.3 sub-stage B 拆出):本期 Skill 是**单轮结构化输出**(NL→LLM 一次返回 operations→Command)。延后:架构 §4.3 的 `call_tool` / `allowed_tools` 多轮 agent 循环(LLM 多轮调工具、读场景、迭代纠错)+ `SkillContext.memory`(`MemoryStore` 项目暂无实现)。用户明确「多轮后续肯定要完善」。
diff --git a/docs/superpowers/plans/2026-06-08-asset-drag-into-viewport-plan.md b/docs/superpowers/plans/2026-06-08-asset-drag-into-viewport-plan.md
new file mode 100644
index 0000000..5b32888
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-08-asset-drag-into-viewport-plan.md
@@ -0,0 +1,899 @@
+# 资源拖拽入视口 + 落点 实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 从资源库卡片拖拽到 3D 视口,以 y=0 地面平面的 raycast 命中点作为新节点落点(覆盖 x/z、保留库项默认 y);双击加节点行为不变;走 `AddNodeCommand` 可撤销。
+
+**架构:** 自定义 pointer 拖拽(不用 HTML5 DnD —— Tauri 2 默认 `dragDropEnabled=true` 抑制 webview 内 `ondrop`)。三段状态独立、无竞争:(1) module 级 controller `asset-drag.ts` 记录候选并在移动 > 5px 时把 UI store 翻成 active drag;(2) `AssetDragGhost` 读 store + 本地 pointermove 渲染跟随光标的 DOM ghost;(3) `ThreeViewport` 的 window `pointerup` 在 canvas 内落点 raycast 地面、加节点、收尾。纯几何 (`screenToNdc`/`dropPositionFor`)、catalog 查找 (`findLibraryItem`)、adapter 地面 raycast (`raycastGroundPoint`) 全部 headless 单测。
+
+**技术栈:** React 19 + zustand + Three.js(headless adapter 单测,无 WebGL)+ vitest/jsdom + react-i18next。
+
+---
+
+## 关键约束与 spec 偏差(实施前必读)
+
+实施前已通读真实代码。spec(`docs/superpowers/specs/2026-06-08-asset-drag-into-viewport-design.md`)多处用「架构命名」而非真实路径/符号,**以下以本计划为准**:
+
+1. **路径映射**(spec → 实际):
+ - `src/state/ui-store.ts` → **`src/services/ui/store.ts`**(导出 `useUIStore`)
+ - `src/ui/editor/LibraryPanel.tsx` → **`src/ui/library/LibraryPanel.tsx`**
+ - `src/ui/editor/EditorView.tsx` → **`src/ui/views/EditorView.tsx`**
+ - `src/ui/editor/AssetDragGhost.tsx` → **`src/ui/library/AssetDragGhost.tsx`**(与 LibraryPanel 同目录,内聚于资源库 UI;偏离 spec 的 editor/ 目录,刻意为之)
+ - `src/runtime/three/adapter.ts`、`src/services/library/catalog.ts`、`src/services/library/asset-drag.ts`、`src/lib/drop-helpers.ts` 与 spec 一致 ✓
+2. **`item.icon` 是 `LucideIcon` 组件,不是 emoji**。spec §6.3 ghost 写 `{item.icon}`(当字符串渲染)是 bug。正确:`const Icon = item.icon; `(同 LibraryPanel `node-builders` 渲染卡片图标的写法)。
+3. **没有 `useAssetStore`**。spec §6.3/§6.4 读 `useAssetStore((s)=>s.assets)` 不存在。资源列表在 scene store:**`useSceneStore.getState().project?.assets ?? []`**(LibraryPanel 也是这么取的)。
+4. **`raycastGroundPoint` 可 headless 单测**,不止视觉验证。`adapter.test.ts` 已证明 `new ThreeAdapter()` 无 WebGL 即可构造相机/场景;地面 raycast 只用 camera + raycaster + `Plane.intersectPlane`,本计划补 2 个单测(spec §9 把它划到「视觉 only」偏保守)。
+5. **`beginAssetDrag` 可 headless 单测**:jsdom 环境(`vite.config.ts` `environment:"jsdom"`),用 `window.dispatchEvent(new MouseEvent("pointermove",{clientX,clientY}))` 触发 window 监听(`PointerEvent extends MouseEvent`,`e.clientX` 可读)。
+6. **落点后选中新节点**:双击路径 `addItem` 会 `setSelectedNodeId(node.id)`;drop 路径同样选中以保持一致(spec §6.4 漏了)。
+7. 真实符号确认:adapter 私有字段 `viewportWidth`/`viewportHeight`/`raycaster`/`ndc` 都在;`camera` 公有可读;`pickAt` 的 NDC 写法 = `(x/w)*2-1, -((y/h)*2-1)`,`raycastGroundPoint` 复刻它(含 `camera.updateMatrixWorld(true)`)。runtime→`@/lib` import 合法(`asset-cache.ts` 已 `import {isTauri} from "@/lib/runtime"`,无 eslint 边界规则)。
+
+---
+
+## 文件结构
+
+**新增**
+
+| 文件 | 职责 |
+| ----------------------------------------- | ---------------------------------------------------------------------------- |
+| `src/lib/drop-helpers.ts` | 纯函数 `screenToNdc` + `dropPositionFor`(无 DOM/Three 依赖) |
+| `src/lib/drop-helpers.test.ts` | 上两者单测 |
+| `src/services/library/asset-drag.ts` | module 级拖拽发起 controller `beginAssetDrag`(5px 阈值 → `startAssetDrag`) |
+| `src/services/library/asset-drag.test.ts` | controller 阈值/激活/取消单测 |
+| `src/ui/library/AssetDragGhost.tsx` | 拖拽中跟随光标的 DOM ghost(视觉验证,无单测) |
+
+**修改**
+
+| 文件 | 改动 |
+| -------------------------------------- | -------------------------------------------------------------------------------------------- |
+| `src/services/library/catalog.ts` | 加 `findLibraryItem(id, uploads)` |
+| `src/services/library/catalog.test.ts` | `findLibraryItem` 单测(builtin / upload / 未知) |
+| `src/runtime/three/adapter.ts` | 加 `GROUND_PLANE` 常量 + `groundHit` 字段 + `raycastGroundPoint` 方法 + import `screenToNdc` |
+| `src/runtime/three/adapter.test.ts` | `raycastGroundPoint` 命中 / 朝天回 null 单测 |
+| `src/services/ui/store.ts` | 加 `assetDragItemId` + `startAssetDrag` / `endAssetDrag` |
+| `src/services/ui/store.test.ts` | asset drag 状态 round-trip 单测 |
+| `src/ui/library/LibraryPanel.tsx` | 卡片 `onPointerDown={beginAssetDrag}` + `draggable={false}` |
+| `src/ui/library/LibraryPanel.test.tsx` | pointerdown 触发 beginAssetDrag(mock)单测 |
+| `src/ui/views/EditorView.tsx` | 挂 `` |
+| `src/ui/viewport/ThreeViewport.tsx` | mount effect 内 window `pointerup` 落点处理 + cleanup |
+| `src/i18n/locales/en-US/editor.json` | `library.add_hint` 文案加「drag」 |
+| `src/i18n/locales/zh-CN/editor.json` | `library.add_hint` 文案加「拖入视口」 |
+
+**执行前提**:当前已在分支 `feat/asset-drag-into-viewport`(spec commit `75bdfd2` 已在)。无需新建 worktree/branch。任务 1→8 有依赖顺序(见各任务),任务 9 收口。
+
+---
+
+### 任务 1:纯函数 drop-helpers(screenToNdc + dropPositionFor)
+
+无依赖。最先做,adapter(任务 4)与 ThreeViewport(任务 8)都引用。
+
+**文件:**
+
+- 创建:`src/lib/drop-helpers.ts`
+- 测试:`src/lib/drop-helpers.test.ts`
+
+- [ ] **步骤 1:编写失败的测试**
+
+创建 `src/lib/drop-helpers.test.ts`:
+
+```ts
+import { describe, expect, it } from "vitest";
+
+import { dropPositionFor, screenToNdc } from "./drop-helpers";
+
+describe("screenToNdc", () => {
+ it("maps the canvas center to the NDC origin", () => {
+ expect(screenToNdc(50, 50, 100, 100)).toEqual([0, 0]);
+ });
+ it("maps the top-left corner to (-1, 1)", () => {
+ expect(screenToNdc(0, 0, 100, 100)).toEqual([-1, 1]);
+ });
+ it("maps the bottom-right corner to (1, -1)", () => {
+ expect(screenToNdc(100, 100, 100, 100)).toEqual([1, -1]);
+ });
+ it("maps each axis independently for a non-square viewport", () => {
+ expect(screenToNdc(100, 75, 200, 100)).toEqual([0, -0.5]);
+ });
+});
+
+describe("dropPositionFor", () => {
+ it("takes x/z from the hit and keeps the default y", () => {
+ expect(dropPositionFor([0, 0.5, 0], [3, 0, -2])).toEqual([3, 0.5, -2]);
+ });
+ it("keeps a light's authored height", () => {
+ expect(dropPositionFor([0, 3, 0], [-1.5, 0, 4.2])).toEqual([-1.5, 3, 4.2]);
+ });
+ it("preserves negative hit coordinates", () => {
+ expect(dropPositionFor([0, 1, 0], [-5, 0, -7])).toEqual([-5, 1, -7]);
+ });
+});
+```
+
+- [ ] **步骤 2:运行测试验证失败**
+
+运行:`pnpm vitest run src/lib/drop-helpers.test.ts`
+预期:FAIL —— 报 `Failed to resolve import "./drop-helpers"` / `screenToNdc is not a function`。
+
+- [ ] **步骤 3:编写最少实现代码**
+
+创建 `src/lib/drop-helpers.ts`:
+
+```ts
+/**
+ * Pure geometry helpers for asset drag-and-drop placement. No Three.js / DOM
+ * deps so they unit-test headlessly; the ThreeAdapter and ThreeViewport
+ * consume them at drop time.
+ */
+
+/** Canvas-relative screen pixels → normalized device coords ([-1, 1], y up). */
+export function screenToNdc(
+ px: number,
+ py: number,
+ w: number,
+ h: number,
+): [number, number] {
+ // y is `1 - …` rather than `-( … - 1)` so the canvas center maps to a clean
+ // +0 instead of -0 — vitest's toEqual uses Object.is, where -0 !== 0.
+ return [(px / w) * 2 - 1, 1 - (py / h) * 2];
+}
+
+/**
+ * New node position from a ground-plane hit: take the hit's x/z, keep the
+ * library item's default y. So a box lifted to y=0.5 still rests on the floor
+ * (not half-buried) and a light keeps its authored height.
+ */
+export function dropPositionFor(
+ defaultPos: readonly [number, number, number],
+ hit: readonly [number, number, number],
+): [number, number, number] {
+ return [hit[0], defaultPos[1], hit[2]];
+}
+```
+
+- [ ] **步骤 4:运行测试验证通过**
+
+运行:`pnpm vitest run src/lib/drop-helpers.test.ts`
+预期:PASS(7 个用例全绿)。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add src/lib/drop-helpers.ts src/lib/drop-helpers.test.ts
+git commit -m "feat(drop): pure screenToNdc + dropPositionFor helpers (tested)"
+```
+
+---
+
+### 任务 2:catalog.findLibraryItem
+
+无依赖(用现有 `BUILTIN_LIBRARY_ITEMS` + `uploadLibraryItems`)。drop 时按 id 取回 `LibraryItem.makeNode`。
+
+**文件:**
+
+- 修改:`src/services/library/catalog.ts`(文件末尾追加导出函数)
+- 测试:`src/services/library/catalog.test.ts`(追加 describe + 补 import)
+
+- [ ] **步骤 1:编写失败的测试**
+
+在 `src/services/library/catalog.test.ts`:把第 3 行的 import 改为带上 `findLibraryItem`,并在文件顶部 import 区加 `AssetReference` 类型:
+
+```ts
+import { BUILTIN_LIBRARY_ITEMS, findLibraryItem, uploadLibraryItems } from "./catalog";
+import type { AssetReference } from "@/core/scene/types";
+```
+
+在 `describe("library catalog", () => { … })` 之后追加:
+
+```ts
+describe("findLibraryItem", () => {
+ it("finds a builtin item by id", () => {
+ const item = findLibraryItem("geo-box", []);
+ expect(item?.id).toBe("geo-box");
+ expect(item?.makeNode().data).toMatchObject({
+ type: "mesh",
+ geometry: { kind: "box" },
+ });
+ });
+
+ it("finds an upload-derived item by its upload-* id", () => {
+ const uploads: AssetReference[] = [
+ {
+ id: "a1",
+ content_hash: "h",
+ kind: "geometry",
+ relative_path: "assets/h.glb",
+ tags: [],
+ description: "",
+ source: { kind: "user_upload", original_filename: "chair.glb" },
+ },
+ ];
+ const item = findLibraryItem("upload-a1", uploads);
+ expect(item?.name).toBe("chair.glb");
+ expect(item?.makeNode().data).toMatchObject({
+ type: "prefab_instance",
+ asset_id: "a1",
+ });
+ });
+
+ it("returns undefined for an unknown id", () => {
+ expect(findLibraryItem("nope", [])).toBeUndefined();
+ });
+});
+```
+
+- [ ] **步骤 2:运行测试验证失败**
+
+运行:`pnpm vitest run src/services/library/catalog.test.ts`
+预期:FAIL —— `findLibraryItem is not exported` / `is not a function`。
+
+- [ ] **步骤 3:编写最少实现代码**
+
+在 `src/services/library/catalog.ts` 文件末尾(`uploadLibraryItems` 之后)追加:
+
+```ts
+/**
+ * Look up a library item by id across builtins + upload-derived items. Used by
+ * drag-drop to recover the item (and its makeNode) at drop time, after only the
+ * id was carried through the drag.
+ */
+export function findLibraryItem(
+ id: string,
+ uploads: AssetReference[],
+): LibraryItem | undefined {
+ return [...BUILTIN_LIBRARY_ITEMS, ...uploadLibraryItems(uploads)].find(
+ (item) => item.id === id,
+ );
+}
+```
+
+(`AssetReference` 已在 catalog.ts 第 14 行 import,无需新增。)
+
+- [ ] **步骤 4:运行测试验证通过**
+
+运行:`pnpm vitest run src/services/library/catalog.test.ts`
+预期:PASS(原有 + 新增 3 个用例全绿)。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add src/services/library/catalog.ts src/services/library/catalog.test.ts
+git commit -m "feat(library): findLibraryItem lookup across builtins + uploads"
+```
+
+---
+
+### 任务 3:UI store —— assetDragItemId + start/endAssetDrag
+
+无依赖。controller(任务 5)、ghost(任务 6)、drop(任务 8)都读写它。
+
+**文件:**
+
+- 修改:`src/services/ui/store.ts`(接口 + 实现各加 3 项)
+- 测试:`src/services/ui/store.test.ts`(追加 describe)
+
+- [ ] **步骤 1:编写失败的测试**
+
+在 `src/services/ui/store.test.ts` 末尾追加:
+
+```ts
+describe("useUIStore — asset drag (asset-drag-into-viewport)", () => {
+ beforeEach(() => useUIStore.setState({ assetDragItemId: null }));
+
+ it("defaults to null (no drag active)", () => {
+ expect(useUIStore.getState().assetDragItemId).toBeNull();
+ });
+
+ it("startAssetDrag sets the id and endAssetDrag clears it", () => {
+ useUIStore.getState().startAssetDrag("geo-box");
+ expect(useUIStore.getState().assetDragItemId).toBe("geo-box");
+ useUIStore.getState().endAssetDrag();
+ expect(useUIStore.getState().assetDragItemId).toBeNull();
+ });
+});
+```
+
+- [ ] **步骤 2:运行测试验证失败**
+
+运行:`pnpm vitest run src/services/ui/store.test.ts`
+预期:FAIL —— `startAssetDrag is not a function`(且 TS 报缺字段)。
+
+- [ ] **步骤 3:编写最少实现代码**
+
+在 `src/services/ui/store.ts` 的 `interface UIState` 内(`consumeFocusRequest` 之后、`}` 之前)加:
+
+```ts
+ /** Library item id currently being drag-dropped into the viewport; null when
+ * no asset drag is active. Set once the pointer passes the drag threshold
+ * (services/library/asset-drag.ts), cleared on drop / cancel by the
+ * viewport's window pointerup. */
+ assetDragItemId: string | null;
+ startAssetDrag: (id: string) => void;
+ endAssetDrag: () => void;
+```
+
+在 `create((set) => ({ … }))` 实现里(`consumeFocusRequest: …` 之后)加:
+
+```ts
+ assetDragItemId: null,
+ startAssetDrag: (assetDragItemId) => set({ assetDragItemId }),
+ endAssetDrag: () => set({ assetDragItemId: null }),
+```
+
+- [ ] **步骤 4:运行测试验证通过**
+
+运行:`pnpm vitest run src/services/ui/store.test.ts && pnpm typecheck`
+预期:PASS + typecheck 0 error。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add src/services/ui/store.ts src/services/ui/store.test.ts
+git commit -m "feat(ui-store): assetDragItemId + start/endAssetDrag"
+```
+
+---
+
+### 任务 4:adapter.raycastGroundPoint + GROUND_PLANE
+
+依赖任务 1(`screenToNdc`)。headless 单测。
+
+**文件:**
+
+- 修改:`src/runtime/three/adapter.ts`
+- 测试:`src/runtime/three/adapter.test.ts`(追加 describe)
+
+- [ ] **步骤 1:编写失败的测试**
+
+在 `src/runtime/three/adapter.test.ts` 末尾追加(`target` 变量该文件已有):
+
+```ts
+describe("ThreeAdapter.raycastGroundPoint", () => {
+ it("hits the ground plane near the origin for a centered ray", () => {
+ // Default camera sits at [4,3,4] looking at the origin, so the center ray
+ // (NDC 0,0) crosses y=0 exactly at the origin.
+ const adapter = new ThreeAdapter(target);
+ adapter.setViewportSize(100, 100);
+ const hit = adapter.raycastGroundPoint(50, 50);
+ expect(hit).not.toBeNull();
+ expect(hit![1]).toBeCloseTo(0, 5);
+ expect(Math.hypot(hit![0], hit![2])).toBeLessThan(1e-4);
+ });
+
+ it("returns null when the camera faces away from the ground", () => {
+ // Tilt up (above the horizon) but NOT straight up — a forward parallel to
+ // the up vector makes lookAt degenerate (NaN basis). [0,5,3] tilts up
+ // around x, so the center ray points above y=0 and never crosses it.
+ const adapter = new ThreeAdapter(target, {
+ defaultCamera: { position: [0, 1, 0], lookAt: [0, 5, 3] },
+ });
+ adapter.setViewportSize(100, 100);
+ expect(adapter.raycastGroundPoint(50, 50)).toBeNull();
+ });
+});
+```
+
+- [ ] **步骤 2:运行测试验证失败**
+
+运行:`pnpm vitest run src/runtime/three/adapter.test.ts -t raycastGroundPoint`
+预期:FAIL —— `adapter.raycastGroundPoint is not a function`。
+
+- [ ] **步骤 3:编写最少实现代码**
+
+在 `src/runtime/three/adapter.ts`:
+
+(a) import 区(顶部 `import * as THREE from "three";` 之后)加:
+
+```ts
+import { screenToNdc } from "@/lib/drop-helpers";
+```
+
+(b) 模块常量(放在 `const EXPORTERS … };` 之后、`export class ThreeAdapter` 之前)加:
+
+```ts
+/** Shared y=0 ground plane (normal +y) for drop raycasts. Module-level to
+ * avoid per-pick allocation. */
+const GROUND_PLANE = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
+```
+
+(c) 字段:在 `private readonly ndc = new THREE.Vector2();` 之后加:
+
+```ts
+ /** Reused target for raycastGroundPoint's plane intersection. */
+ private readonly groundHit = new THREE.Vector3();
+```
+
+(d) 方法:在 `pickAt(...)` 方法结束的 `}` 之后、`// ───── Export ─────` 注释之前加:
+
+```ts
+ /**
+ * Raycast the screen point `(screen_x, screen_y)` (viewport-pixel space)
+ * against the y=0 ground plane. Returns the world-space hit `[x, y, z]`, or
+ * null when the ray is parallel to / points away from the plane (camera
+ * facing the sky). Mirrors pickAt's NDC + matrix-refresh setup so a drop
+ * handled synchronously (before the next animation frame) still uses the
+ * current camera pose.
+ */
+ raycastGroundPoint(
+ screen_x: number,
+ screen_y: number,
+ ): [number, number, number] | null {
+ if (this.viewportWidth <= 0 || this.viewportHeight <= 0) return null;
+ this.camera.updateMatrixWorld(true);
+ const [ndcX, ndcY] = screenToNdc(
+ screen_x,
+ screen_y,
+ this.viewportWidth,
+ this.viewportHeight,
+ );
+ this.ndc.set(ndcX, ndcY);
+ this.raycaster.setFromCamera(this.ndc, this.camera);
+ const hit = this.raycaster.ray.intersectPlane(GROUND_PLANE, this.groundHit);
+ return hit ? [hit.x, hit.y, hit.z] : null;
+ }
+```
+
+- [ ] **步骤 4:运行测试验证通过**
+
+运行:`pnpm vitest run src/runtime/three/adapter.test.ts -t raycastGroundPoint && pnpm typecheck`
+预期:PASS + typecheck 0 error。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add src/runtime/three/adapter.ts src/runtime/three/adapter.test.ts
+git commit -m "feat(adapter): raycastGroundPoint — screen → y=0 world hit (tested)"
+```
+
+---
+
+### 任务 5:asset-drag controller(beginAssetDrag)
+
+依赖任务 3(store)。headless 单测(jsdom window 事件)。
+
+**文件:**
+
+- 创建:`src/services/library/asset-drag.ts`
+- 测试:`src/services/library/asset-drag.test.ts`
+
+- [ ] **步骤 1:编写失败的测试**
+
+创建 `src/services/library/asset-drag.test.ts`:
+
+```ts
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { useUIStore } from "@/services/ui/store";
+
+import { beginAssetDrag } from "./asset-drag";
+
+function move(clientX: number, clientY: number) {
+ window.dispatchEvent(new MouseEvent("pointermove", { clientX, clientY }));
+}
+function up() {
+ window.dispatchEvent(new MouseEvent("pointerup", {}));
+}
+
+describe("beginAssetDrag", () => {
+ beforeEach(() => useUIStore.setState({ assetDragItemId: null }));
+
+ it("does not activate the drag below the 5px threshold", () => {
+ beginAssetDrag("geo-box", 100, 100);
+ move(103, 101); // ~3.2px
+ expect(useUIStore.getState().assetDragItemId).toBeNull();
+ up(); // cleanup
+ });
+
+ it("activates the drag once the pointer passes 5px", () => {
+ beginAssetDrag("geo-box", 100, 100);
+ move(110, 100); // 10px
+ expect(useUIStore.getState().assetDragItemId).toBe("geo-box");
+ });
+
+ it("a release before the threshold tears down without activating", () => {
+ beginAssetDrag("geo-box", 100, 100);
+ up(); // plain click/double-click — no drag
+ move(200, 200); // listeners removed → no late activation
+ expect(useUIStore.getState().assetDragItemId).toBeNull();
+ });
+});
+```
+
+- [ ] **步骤 2:运行测试验证失败**
+
+运行:`pnpm vitest run src/services/library/asset-drag.test.ts`
+预期:FAIL —— `Failed to resolve import "./asset-drag"`。
+
+- [ ] **步骤 3:编写最少实现代码**
+
+创建 `src/services/library/asset-drag.ts`:
+
+```ts
+import { useUIStore } from "@/services/ui/store";
+
+/**
+ * Module-level drag-from-library controller. A library card calls
+ * beginAssetDrag on pointerdown; we record a candidate and watch the pointer.
+ * Only once it moves past DRAG_THRESHOLD_PX do we flip the UI store into a live
+ * asset drag — so a plain click / double-click never flashes a ghost. After
+ * activation this controller drops its own listeners; the drag is then owned by
+ * AssetDragGhost (visual) + ThreeViewport (drop), which clear it via
+ * endAssetDrag. Releasing before the threshold tears down quietly.
+ */
+const DRAG_THRESHOLD_PX = 5;
+
+let candidate: { id: string; x: number; y: number } | null = null;
+let onMove: ((e: PointerEvent) => void) | null = null;
+let onUp: (() => void) | null = null;
+
+export function beginAssetDrag(id: string, clientX: number, clientY: number): void {
+ teardown(); // drop any stale candidate before starting a new one
+ candidate = { id, x: clientX, y: clientY };
+ onMove = (e) => {
+ if (!candidate) return;
+ const moved = Math.hypot(e.clientX - candidate.x, e.clientY - candidate.y);
+ if (moved > DRAG_THRESHOLD_PX) {
+ useUIStore.getState().startAssetDrag(candidate.id);
+ teardown();
+ }
+ };
+ onUp = () => teardown();
+ window.addEventListener("pointermove", onMove);
+ window.addEventListener("pointerup", onUp);
+}
+
+function teardown(): void {
+ if (onMove) window.removeEventListener("pointermove", onMove);
+ if (onUp) window.removeEventListener("pointerup", onUp);
+ candidate = null;
+ onMove = null;
+ onUp = null;
+}
+```
+
+- [ ] **步骤 4:运行测试验证通过**
+
+运行:`pnpm vitest run src/services/library/asset-drag.test.ts`
+预期:PASS(3 个用例全绿)。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add src/services/library/asset-drag.ts src/services/library/asset-drag.test.ts
+git commit -m "feat(library): asset-drag controller — 5px threshold activates drag"
+```
+
+---
+
+### 任务 6:AssetDragGhost + 挂载到 EditorView
+
+依赖任务 2(findLibraryItem)、任务 3(store)。无单测(DOM 位置/视觉 —— 任务 9 `pnpm tauri dev` 验证)。
+
+**文件:**
+
+- 创建:`src/ui/library/AssetDragGhost.tsx`
+- 修改:`src/ui/views/EditorView.tsx`
+
+- [ ] **步骤 1:编写组件**
+
+创建 `src/ui/library/AssetDragGhost.tsx`:
+
+```tsx
+import { useEffect, useState } from "react";
+
+import { findLibraryItem } from "@/services/library/catalog";
+import { useSceneStore } from "@/services/scene/store";
+import { useUIStore } from "@/services/ui/store";
+
+/**
+ * Cursor-following ghost shown while a library item is dragged into the
+ * viewport. Reads the active drag id from the UI store and tracks the live
+ * pointer in LOCAL state (not the store — pointermove is high-frequency and we
+ * don't want to re-render unrelated subscribers). Renders nothing until the
+ * pointer has moved at least once after activation, or if the id can't be
+ * resolved (e.g. an upload removed mid-drag).
+ */
+export function AssetDragGhost() {
+ const id = useUIStore((s) => s.assetDragItemId);
+ const uploads = useSceneStore((s) => s.project?.assets);
+ const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
+
+ useEffect(() => {
+ if (!id) return;
+ const onMove = (e: PointerEvent) => setPos({ x: e.clientX, y: e.clientY });
+ window.addEventListener("pointermove", onMove);
+ // Reset position in cleanup (runs on drag end AND target switch) — not in
+ // the effect body, which trips eslint react-hooks/set-state-in-effect.
+ return () => {
+ window.removeEventListener("pointermove", onMove);
+ setPos(null);
+ };
+ }, [id]);
+
+ if (!id || !pos) return null;
+ const item = findLibraryItem(id, uploads ?? []);
+ if (!item) return null;
+ const Icon = item.icon;
+
+ return (
+
+
+
+ );
+}
+```
+
+- [ ] **步骤 2:挂载到 EditorView**
+
+在 `src/ui/views/EditorView.tsx`:import 区(`import { LibraryPanel } from "@/ui/library/LibraryPanel";` 旁)加:
+
+```tsx
+import { AssetDragGhost } from "@/ui/library/AssetDragGhost";
+```
+
+在 return 的根 `` 末尾,把 `
` 那行改为:
+
+```tsx
+
+
+
+```
+
+- [ ] **步骤 3:验证 typecheck + 既有测试不退化**
+
+运行:`pnpm typecheck && pnpm vitest run src/ui`
+预期:typecheck 0 error;UI 测试全绿(ghost 默认 `assetDragItemId=null` → 渲染 null,不影响任何现有用例)。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add src/ui/library/AssetDragGhost.tsx src/ui/views/EditorView.tsx
+git commit -m "feat(library): AssetDragGhost cursor-following drag preview"
+```
+
+---
+
+### 任务 7:LibraryPanel 卡片 pointerdown 接线 + i18n 文案
+
+依赖任务 5(beginAssetDrag)。
+
+**文件:**
+
+- 修改:`src/ui/library/LibraryPanel.tsx`
+- 修改:`src/ui/library/LibraryPanel.test.tsx`
+- 修改:`src/i18n/locales/en-US/editor.json`、`src/i18n/locales/zh-CN/editor.json`
+
+- [ ] **步骤 1:编写失败的测试**
+
+在 `src/ui/library/LibraryPanel.test.tsx`:把新 import 与其他 import 放一起(顶部),再在**所有 import 之后**(`function seed` 之前)写 `vi.mock`。vitest 会把 `vi.mock` 提升到 import 之上,这样既能 mock 成功,又不违反 eslint `import/first`(import 必须在语句之前):
+
+```tsx
+// 与现有 import 同组(顶部):
+import { beginAssetDrag } from "@/services/library/asset-drag";
+
+// 所有 import 之后、function seed 之前:
+vi.mock("@/services/library/asset-drag", () => ({
+ beginAssetDrag: vi.fn(),
+}));
+```
+
+在 `describe("LibraryPanel", …)` 的 `beforeEach` 里追加一行清 mock:
+
+```tsx
+vi.mocked(beginAssetDrag).mockClear();
+```
+
+在该 describe 内追加用例:
+
+```tsx
+it("pointer-down on a card begins an asset drag with the item id", () => {
+ render();
+ fireEvent.pointerDown(screen.getByText("Sphere"), { clientX: 12, clientY: 34 });
+ expect(beginAssetDrag).toHaveBeenCalledTimes(1);
+ expect(vi.mocked(beginAssetDrag).mock.calls[0]?.[0]).toBe("geo-sphere");
+});
+```
+
+(只断言第一个实参 = id,避开 jsdom 对 pointer 事件 clientX 的差异。)
+
+- [ ] **步骤 2:运行测试验证失败**
+
+运行:`pnpm vitest run src/ui/library/LibraryPanel.test.tsx`
+预期:FAIL —— `expected beginAssetDrag to be called` / `import "./asset-drag"`(卡片还没接 onPointerDown)。
+
+- [ ] **步骤 3:接线 LibraryPanel**
+
+在 `src/ui/library/LibraryPanel.tsx`:import 区(`import { ... } from "@/services/library/catalog";` 旁)加:
+
+```tsx
+import { beginAssetDrag } from "@/services/library/asset-drag";
+```
+
+找到卡片 `