# CrowdRoom · 物品替换实施手册(v0.2) > **版本**:v0.2(2026-05-19)· 子任务 9 产出 > **定位**:把分散在 [`04_web_app_plan.md`](04_web_app_plan.md) §5–§7、[`01_data_schema.md`](01_data_schema.md) §5、[`02_api_contract.md`](02_api_contract.md) §4–§5 的"物品替换"逻辑**聚合 + 深化**为一份**开发者拿来就能写代码**的实施手册。 > > **本手册不修改任何 v0.2 架构**——所有字段命名、op 类型、API 端点都与现有 4 份文档严格一致;任何新增能力(如 `remix-update` 端点、`bbox_filter` 检索参数)均以「**建议增量**」形式标注,落地需在下一版 02/04 文档中追认。 > > 文档语言:简体中文;代码与字段命名:英文(与既有契约对齐)。 --- ## 0. 阅读导航 | 你的角色 | 重点章节 | |---------|---------| | 前端工程师(写交互) | §3 选中 · §4 AssetPicker · §6 4DoF gizmo · §10 自动保存 | | 前端工程师(写算法) | §5 OBB 对齐 · §9 applyOverlay | | 后端 / Edge Function | §2 数据流 · §8 overlay schema · §10 冲突安全 | | 资产库 / 运营 | §4 检索维度 · §5.4 anchor 缺失兜底 · §14 与子任务 10 的接口 | | QA / 测试 | §12 失败模式 · §13 M1 验收清单 | --- ## 1. 物品替换能力总览 ### 1.1 一句话定义 > **"让用户在浏览器里点击扫描得到的任一家具或材质 → 从公共资产库挑一件替换 → 系统按 OBB 自动对齐 → 用户可选 4 自由度微调 → 一键发布为 Remix。"** 该能力是 CrowdRoom 直接回应用户原始需求 *"更换其他家具 / 更换材质"* 的核心交互,对应 [`02_api_contract.md`](02_api_contract.md) §8.2 Web-Y2 契约(浏览器内实时合成、零服务端预合成)。 ### 1.2 5 条用户故事 | # | 用户故事 | 关键能力 | |---|---------|---------| | US-1 | **普通替换**:Alice 看到一个房间里的旧沙发,点击它 → 选个北欧风新沙发 → 自动对齐 → 发布 | §3 / §4 / §5 / §8 | | US-2 | **批量风格化**:Bob 选中房间里所有椅子(Shift+Click),一次性换成同一系列工业风 | §7 批量 | | US-3 | **材质混搭**:Carol 不换家具,只把墙面换成浅蓝乳胶漆 + 地面换成深色橡木 | §4 材质 tab + `op:replace_material` | | US-4 | **AR 预览**(P2 预留):David 在 iOS Safari 把已 Remix 的房间打开 AR 视图,把新家具"摆"在自家客厅 | §14 接口预留 `viewState.ar_mode` | | US-5 | **Remix 继续 fork**:Eve 在 Bob 的工业风 Remix 上再 fork → 把椅子又换成藤编 | overlay 的 op 是天然可叠加的 | ### 1.3 与 v0.2 架构对照表 | 本手册章节 | v0.2 文档对应 | 实现关系 | |-----------|--------------|---------| | §1 总览 | [`04_web_app_plan.md`](04_web_app_plan.md) §6 家具替换 + §5 材质替换 | **直接引用** | | §2 数据流 | [`04_web_app_plan.md`](04_web_app_plan.md) §7.1 Remix 端到端序列图 | **深化补充**(从 9 步扩到 27 步,纳入 raycaster/AssetPicker/对齐) | | §3 选中机制 | [`04_web_app_plan.md`](04_web_app_plan.md) §3.2 选中态高亮 + §4.3 交互细节 | **深化补充**(补 raycaster TSX 实现、触屏长按) | | §4 AssetPicker | [`04_web_app_plan.md`](04_web_app_plan.md) §5.4 / §8.6 资产库 | **深化补充**(补无限滚动 + bbox_filter 检索参数) | | §5 OBB 对齐 | [`04_web_app_plan.md`](04_web_app_plan.md) §6.3 自动对齐 + [`01_data_schema.md`](01_data_schema.md) §5.2 `furnitureItem.obb/anchor_point` | **深化补充**(补完整数学 + 3 种缩放模式 + 朝向兜底) | | §6 4DoF 微调 | [`04_web_app_plan.md`](04_web_app_plan.md) §6.4 | **深化补充**(补 TransformControls TSX + Snap 策略 + Undo) | | §7 批量替换 | [`04_web_app_plan.md`](04_web_app_plan.md) §6.6 整体隐藏 | **新增** | | §8 写 overlay | [`02_api_contract.md`](02_api_contract.md) §4.2 `remix_overlay.json` schema | **直接引用 + 补 TS 类型** | | §9 applyOverlay | [`04_web_app_plan.md`](04_web_app_plan.md) §5.3 / §6.3 实时预览伪代码 | **深化补充**(补完整 switch + clone 副本策略) | | §10 自动保存 / 冲突 | [`04_web_app_plan.md`](04_web_app_plan.md) §7.2 自动保存与冲突 | **深化补充**(补 IndexedDB schema + BroadcastChannel + 父删兜底) | | §11 性能 | [`04_web_app_plan.md`](04_web_app_plan.md) §3.3 / §10.1 | **深化补充**(补 LRU + 图层独显) | | §12 失败模式 | [`02_api_contract.md`](02_api_contract.md) §7 错误码 + [`04_web_app_plan.md`](04_web_app_plan.md) §7.4 | **深化补充**(10 种失败情形表) | | §13 M1 验收 | [`04_web_app_plan.md`](04_web_app_plan.md) §11.1 MVP | **新增** | | §14 P2 预留 | [`04_web_app_plan.md`](04_web_app_plan.md) §11.2 不做项 | **新增**(CRDT / AI / AR 三条接口 hook) | > 全表 13 行,**全部能在 v0.2 现有文档找到锚点**——本手册不引入任何字段级破坏性改动。 --- ## 2. 数据流总览(最关键章节) 下面这张 sequenceDiagram **共 27 个步骤**,串联从"用户点击 mesh"到"跳转 Remix 详情页"的完整链路,是本手册其它章节的总纲。 ```mermaid sequenceDiagram autonumber participant U as 用户 participant R3F as R3F Canvas participant Z as Zustand Store participant Mfst as layer_manifest participant Picker as AssetPicker participant API as PostgREST Edge participant CDN as CDN participant IDB as IndexedDB participant Edge as Edge Function participant Worker as Thumb Worker U->>R3F: pointerdown on mesh R3F->>R3F: raycaster intersectObjects R3F->>Mfst: 查 mesh_node_id -> item_id semantic_class Mfst-->>R3F: bed_001 semantic bed obb anchor R3F->>Z: setSelectedItem bed_001 Z->>R3F: Outline shader 高亮选中物 U->>Picker: 打开 AssetPicker 抽屉 Picker->>Z: 读 selectedItem.semantic_class.obb Picker->>API: GET assets kind furniture semantic bed bbox_filter API-->>Picker: 12 items 分页 U->>Picker: 选中目标资产 a91c Picker->>CDN: GET assets furniture modern_bed_oak glb CDN-->>Picker: glb bytes draco compressed Picker->>R3F: useGLTF.preload 完成 R3F->>R3F: alignAssetToOBB 计算 transform R3F->>R3F: scene clone 副本 apply transform R3F->>R3F: 隐藏原 furn_bed_001 visible false R3F->>Z: pushOp replace_furniture Z->>IDB: 写本地 draft overlay Z->>Z: debounce 3s Z->>Edge: PATCH remix-update overlay Edge-->>Z: 200 last_saved U->>R3F: 调 Y 旋转 gizmo R3F->>Z: updateOpTransform rotation_y_deg 15 U->>Edge: POST remix-publish Edge->>Worker: enqueue thumbnail viewState Worker->>CDN: PUT thumbnail webp Edge-->>U: 跳转 r remix_id ``` ### 2.1 关键时序约束 | # | 约束 | 出处 | |---|------|------| | C-1 | 第 2 步 raycaster 必须只命中 `selectable=true` 的 mesh(隐藏层 mesh 不可点) | §3.2 | | C-2 | 第 4 步查 manifest 是 **O(1)**:Worker 端已建好 `mesh_node_id → item_id` map([`01_data_schema.md`](01_data_schema.md) §3.4 layers 表 `manifest_node` 冗余存储) | [`04_web_app_plan.md`](04_web_app_plan.md) §3.2 | | C-3 | 第 11 步 AssetPicker 拉资产必须**带 `bbox_filter`**(按选中物 OBB ±20% 推荐,§4.2) | 本手册新增 | | C-4 | 第 16–19 步 transform + clone + 隐藏原节点 → push op **必须在一个 Zustand action 内原子提交**,否则 Undo 会断 | §7 / §10 | | C-5 | 第 21 步 debounce 3 秒由 Zustand middleware 实现,与 [`04_web_app_plan.md`](04_web_app_plan.md) §7.2 自动保存一致 | §10 | | C-6 | 第 24 步发布前必须先把所有 pending op flush 到服务端(防 publish 与 update 竞态) | §10 | --- ## 3. 选中机制(Raycaster + Node 高亮) ### 3.1 命中策略 Three.js Raycaster 在 R3F 中默认开启 `recursive=true`,但 CrowdRoom 需要额外约束: | 规则 | 实现 | |------|------| | **R-3-1** 仅 `selectable=true` 的 mesh 响应 | 在场景图构建时给每个 mesh 加 `userData.selectable: boolean`,由 manifest 中 `replaceable` 字段决定([`01_data_schema.md`](01_data_schema.md) §5.2)| | **R-3-2** 半透明墙体不阻挡背后家具点击 | Raycaster 命中后按 `intersect.distance` + `mesh.userData.opacity < 0.95 ? skip : pick` 二次过滤;首选项默认开启 | | **R-3-3** 已锁定层(`layers[kind].locked`)的 mesh 不可点 | 在 R3F `` 上设 `userData.locked`,raycaster pre-filter | | **R-3-4** 高亮**不修改** mesh material | 用 `@react-three/postprocessing` 的 `` 选中物入参,零副作用([`04_web_app_plan.md`](04_web_app_plan.md) §3.2) | | **R-3-5** Hover 与 Click 区分 | `pointermove` 节流 30ms,命中时仅染色(emissive 0.15 + Outline 弱);`pointerdown` 才进选中态 | ### 3.2 TSX 实现骨架(45 行) ```tsx // components/editor/useSelectableMesh.ts "use client"; import { useEffect, useRef } from "react"; import { useThree } from "@react-three/fiber"; import { Raycaster, Vector2, Mesh } from "three"; import { useEditorStore } from "@/stores/editor-store"; export function useSelectableMesh() { const { camera, scene, gl } = useThree(); const raycaster = useRef(new Raycaster()); const ndc = useRef(new Vector2()); const setSelection = useEditorStore((s) => s.setSelection); const toggleSelection = useEditorStore((s) => s.toggleSelection); const lookupItem = useEditorStore((s) => s.lookupItemByMeshNode); useEffect(() => { const canvas = gl.domElement; const onPointerDown = (e: PointerEvent) => { const rect = canvas.getBoundingClientRect(); ndc.current.set( ((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1, ); raycaster.current.setFromCamera(ndc.current, camera); const hits = raycaster.current.intersectObjects(scene.children, true); const picked = hits.find((h) => { const o = h.object as Mesh; return o.userData.selectable === true && !o.userData.locked && (o.userData.opacity ?? 1) >= 0.95; }); if (!picked) { if (!e.shiftKey) setSelection([]); return; } const item = lookupItem(picked.object.name); if (!item) return; if (e.shiftKey) toggleSelection(item.item_id); else setSelection([item.item_id]); }; canvas.addEventListener("pointerdown", onPointerDown); return () => canvas.removeEventListener("pointerdown", onPointerDown); }, [camera, scene, gl, setSelection, toggleSelection, lookupItem]); } ``` > 配套:`` 在 `` 内挂载,根据 `useEditorStore` 中的 `selectedItemIds → Object3D[]` 反查实现高亮。 ### 3.3 触屏 UX | 触发 | 行为 | |------|------| | **长按 500 ms** | 等效桌面端 `Shift+Click`,进入多选模式(haptic 提示) | | **双击 mesh** | 相机 fit-to-bbox 到该 mesh,0.6s 缓动([`04_web_app_plan.md`](04_web_app_plan.md) §3.5)| | **双指捏合** | 缩放相机,不触发选中 | | **三指拖** | 平移相机(绕过 OrbitControls 默认两指) | 实现:用 `@use-gesture/react` 的 `useDrag` + `useLongPress`;触屏 hitbox 在 §11 性能章再放大 1.5×。 --- ## 4. AssetPicker UI 与资产检索 ### 4.1 弹出形式决策 | 选项 | 优劣 | 决策 | |------|------|------| | **右抽屉**(drawer) | 不打断 3D 浏览、可与场景同时可见 | ✅ **采用** | | 模态对话框(modal) | 居中、聚焦感强 | ❌ 遮挡 3D 场景,无法实时预览 | | 浮窗(popover) | 轻量 | ❌ 列表/筛选信息密度承不下 | 抽屉宽度:桌面 420 px(与图层面板 320 px 共占右侧 740 px),平板 360 px,移动端折叠为底部 sheet(高度 65% 视口)。 ### 4.2 筛选维度(即时联动) | 维度 | 默认值 | 实现 | 来源 | |------|--------|------|------| | `semantic_class` | 自动锁定为选中物 `semantic_class` | Disabled chip,可点 `[+]` 放宽到"全部家具" | [`01_data_schema.md`](01_data_schema.md) §5.2 16 类 enum | | `style` tag | 全部 | 多选 chip:北欧/工业/中式/极简/复古/侘寂/包豪斯 | [`01_data_schema.md`](01_data_schema.md) §3.9 `assets.tags[]` | | `color` | 全部 | HSV 色环 picker(react-colorful),映射到最近 12 色 chip | 资产侧元数据 `dominant_color` | | `bbox_filter` | 选中物 OBB 体积 **±20%** | 切换开关;关闭后允许大尺寸不匹配,UI 给警告 | 本手册新增(建议增量) | | `license` | CC0 only(MVP 默认) | Toggle "包含 CC-BY" | [`01_data_schema.md`](01_data_schema.md) §3.9 `assets.license` | > 🔄 **建议增量**:[`02_api_contract.md`](02_api_contract.md) §2.1 PostgREST 端点应支持 `bbox_filter=0.8,1.2`、`style=北欧&style=极简`、`dominant_color=#a4b5c6&color_tol=0.15` 复合 query;后端用 `assets` 表新增 `volume_m3 numeric` 物化列 + GIN 索引 `tags`、新增 `dominant_color text`。落地需在下一版 02 文档追认。 ### 4.3 列表项展示 ``` ┌────────────────────────────────────┐ │ ┌──────┐ Modern Bed Oak │ │ │ │ semantic: bed │ │ │ thmb │ 4.2k tris · CC0 │ │ │ │ by @ikea_clone │ │ └──────┘ size 2.05x0.65x1.48 m │ └────────────────────────────────────┘ ``` 字段:缩略图(128×128 webp)/ 名称 / 协议 chip(CC0 绿色 / CC-BY 黄色)/ 三角面数 / 创作者 handle / OBB 尺寸(对比原物)。 ### 4.4 无限滚动 + 虚拟列表 - 用 `@tanstack/react-virtual` v3,每行高 96 px,overscan 5 - 分页:`?limit=24&offset={page*24}`;React Query `useInfiniteQuery` - 触发阈值:滚到列表底部 200 px 时预取下一页 - 总数 > 200 时顶部固定显示"共 N 件,按相关度排序" ### 4.5 资产卡片 TSX 骨架(22 行) ```tsx // components/editor/AssetCard.tsx import Image from "next/image"; import type { Asset } from "@/types/asset"; import { Badge } from "@/components/ui/badge"; export function AssetCard({ asset, onPick }: { asset: Asset; onPick: (a: Asset) => void }) { return ( ); } ``` --- ## 5. OBB 对齐算法(重点章节) 物品替换的算法核心:把一个新 asset(其几何来自 `assets.glb_path`)放到原家具 OBB 描述的位置/朝向/尺寸上,**默认无需用户调整**即视觉合理。 ### 5.1 输入数据约定(与 v0.2 schema 严格对齐) #### 5.1.1 原 mesh 侧 来自父房间 `layer_manifest.json` 的 `furnitureItem`([`01_data_schema.md`](01_data_schema.md) §5.2): ```typescript interface OrientedBoundingBox { center: [number, number, number]; // world meters extent: [number, number, number]; // **full** extent (NOT halfExtents); 总长宽高 quat: [number, number, number, number]; // [w, x, y, z],世界系朝向 } interface FurnitureItem { item_id: string; mesh_node_ids: string[]; obb: OrientedBoundingBox; anchor_point: [number, number, number]; // **world** position;通常是 OBB 底面中心 semantic_class: string; replaceable: boolean; } ``` > ⚠️ **与任务描述的术语调和**:任务描述里写 `halfExtents`,v0.2 schema 实际是 `extent`(full)。本手册一律以 v0.2 `extent` 为准,凡需 `halfExtent` 时显式 `extent[i]/2`。 #### 5.1.2 新 asset 侧 来自资产库([`01_data_schema.md`](01_data_schema.md) §3.9 `assets.pbr` jsonb,由子任务 10 落地): ```typescript interface AssetMeta { asset_id: string; glb_uri: string; anchor_local: [number, number, number]; // local meters,通常 = 底面中心 = [0, -ext_y/2, 0] forward_axis: "+X" | "-X" | "+Y" | "-Y" | "+Z" | "-Z"; // asset 朝外的轴 up_axis: "+X" | "-X" | "+Y" | "-Y" | "+Z" | "-Z"; // asset 朝上的轴(一般 +Y) bbox_local: { min: [number, number, number]; max: [number, number, number] }; } ``` ### 5.2 对齐公式(每一步显式写出) 设: - 原 OBB:`C_o ∈ ℝ³`(center, world)、`E_o ∈ ℝ³⁺`(full extent)、`Q_o ∈ ℍ`(orientation quaternion `[w,x,y,z]`) - 原 anchor:`A_o ∈ ℝ³`(world,通常 = OBB 底面中心,即 `C_o − Q_o · (0, E_o.y/2, 0)`) - 新 asset 局部 anchor:`A_a ∈ ℝ³`(local) - 新 asset 局部 bbox 尺寸:`E_a = bbox_local.max − bbox_local.min` - 新 asset forward 轴单位向量 `f_a ∈ ℝ³`(如 `+Z` → `(0,0,1)`) - asset 模型坐标系约定的"正向 forward" = `+Z`(家具工业惯例) **Step 1:缩放因子 `s`**(按 §5.3 模式选择) | 模式 | 公式 | |------|------| | `fit-volume`(默认) | `s = ((E_o.x · E_o.y · E_o.z) / (E_a.x · E_a.y · E_a.z))^(1/3)` | | `fit-floor` | `s = sqrt((E_o.x · E_o.z) / (E_a.x · E_a.z))`(底面投影面积,Y 不参与)| | `none` | `s = 1` | 边界 sanity:若 `s < 0.1` 或 `s > 10`,**警告并 clamp 到 [0.1, 10]**。 **Step 2:朝向四元数 `Q_final`** ``` Q_canonical = quaternionFromUnitVectors(f_a, (0,0,1)) // asset 模型空间 -> "+Z forward" 标准 Q_final = Q_o ⊗ Q_canonical // 四元数复合,非可交换 ``` `up_axis` 同理二次校正一次(防止 asset 倒置)。 **Step 3:位置 `P_world`** 让 asset 局部 anchor(经缩放与旋转后)落到原 anchor `A_o`: ``` A_a_rotated_scaled = Q_final · (s · A_a) // local 向量 -> world 方向(不含位移) P_world = A_o − A_a_rotated_scaled ``` **Step 4:写回 Three.js** ``` assetGroup.position.set(...P_world) assetGroup.quaternion.set(Q_final.x, Q_final.y, Q_final.z, Q_final.w) assetGroup.scale.set(s, s, s) assetGroup.updateMatrixWorld(true) ``` ### 5.3 三种缩放模式 | 模式 | 适用 | 视觉效果 | |------|------|---------| | **fit-volume**(默认) | 通用,沙发/床/柜 | 体积匹配;高瘦物可能略胖 | | **fit-floor** | 沙发/床/餐桌——"高度自由,占地匹配" | 底面与原物贴合,高度按 asset 原始 | | **none** | 灯具/电视/装饰物——"原尺寸即可" | 不缩放;用户必要时手动调 §6 | UI:AssetPicker 下方一个 segmented control,默认按 `semantic_class` 智能推荐: ``` bed / sofa / table / chair -> fit-floor storage / refrigerator / stove -> fit-volume television / fireplace / stairs -> none ``` ### 5.4 TypeScript 实现骨架(76 行) ```typescript // lib/editor/align-obb.ts import { Quaternion, Vector3 } from "three"; export type AlignmentMode = "fit-volume" | "fit-floor" | "none"; export interface OBB { center: [number, number, number]; extent: [number, number, number]; // full extent quat: [number, number, number, number]; // [w, x, y, z] } export interface AssetMeta { anchor_local: [number, number, number]; forward_axis: "+X" | "-X" | "+Y" | "-Y" | "+Z" | "-Z"; up_axis: "+X" | "-X" | "+Y" | "-Y" | "+Z" | "-Z"; bbox_local: { min: [number, number, number]; max: [number, number, number] }; } export interface AlignResult { position: [number, number, number]; quaternion: [number, number, number, number]; // [x, y, z, w] (Three.js 顺序) scale: [number, number, number]; warnings: string[]; } const AXIS_VEC: Record = { "+X": new Vector3( 1, 0, 0), "-X": new Vector3(-1, 0, 0), "+Y": new Vector3( 0, 1, 0), "-Y": new Vector3( 0,-1, 0), "+Z": new Vector3( 0, 0, 1), "-Z": new Vector3( 0, 0,-1), }; export function alignAssetToOBB( asset: AssetMeta, target: OBB, anchorWorld: [number, number, number], mode: AlignmentMode = "fit-volume", ): AlignResult { const warnings: string[] = []; const E_o = new Vector3(...target.extent); const E_a = new Vector3( asset.bbox_local.max[0] - asset.bbox_local.min[0], asset.bbox_local.max[1] - asset.bbox_local.min[1], asset.bbox_local.max[2] - asset.bbox_local.min[2], ); // Step 1: scale let s = 1; if (mode === "fit-volume") { s = Math.cbrt((E_o.x * E_o.y * E_o.z) / Math.max(1e-6, E_a.x * E_a.y * E_a.z)); } else if (mode === "fit-floor") { s = Math.sqrt((E_o.x * E_o.z) / Math.max(1e-6, E_a.x * E_a.z)); } if (s < 0.1 || s > 10) { warnings.push(`scale ${s.toFixed(2)}x clamped to [0.1, 10]`); s = Math.min(10, Math.max(0.1, s)); } // Step 2: orientation (wxyz -> xyzw for Three.js) const Q_o = new Quaternion(target.quat[1], target.quat[2], target.quat[3], target.quat[0]); const f_a = AXIS_VEC[asset.forward_axis].clone(); const Q_canonical = new Quaternion().setFromUnitVectors(f_a, new Vector3(0, 0, 1)); const Q_final = Q_o.clone().multiply(Q_canonical); // up_axis 二次校正:axis-pair lookup table(实现略;非 +Y 时叠加一次 90° 旋转) // Step 3: position const A_a = new Vector3(...asset.anchor_local).multiplyScalar(s).applyQuaternion(Q_final); const P = new Vector3(...anchorWorld).sub(A_a); return { position: [P.x, P.y, P.z], quaternion: [Q_final.x, Q_final.y, Q_final.z, Q_final.w], scale: [s, s, s], warnings, }; } ``` ### 5.5 失败兜底 | 情况 | 检测 | 兜底行为 | |------|------|---------| | asset 缺 `anchor_local` | `asset.anchor_local === undefined` | fallback 到 `bbox_local` 中心;UI toast "该资产未标注锚点,对齐结果可能偏移" | | asset 缺 `forward_axis` | 同上 | 默认 `+Z`;UI 显示"⚠ 朝向未知,可一键 180° 翻转" | | 原 OBB `extent.y < 0.01` | 扁平 mesh | 走 `fit-floor` 模式;记录 Sentry breadcrumb | | 朝向冲突(沙发背对墙) | 用户视觉判断 | UI 提供 **"⟲ 180° 翻转"** 按钮:`Q_final ← Q_final ⊗ quat_y(180°)` | | 缩放 clamp 命中 | `warnings.length > 0` | 红色 banner + 高亮 §6 gizmo 让用户手调 | --- ## 6. 4 自由度微调 UI ### 6.1 Gizmo 三态 | 模式 | 自由度 | 快捷键 | |------|--------|--------| | **Translate** | X / Y / Z 平移 | `T` | | **Rotate** | 仅绕 Y 轴(防"飘起来" / "贴墙穿模") | `R` | | **Scale** | 等比(uniform) | `S` | > ⚠️ 与 [`04_web_app_plan.md`](04_web_app_plan.md) §6.4 一致:**禁止任意 6DoF 旋转**和**非等比缩放**。 ### 6.2 TransformControls TSX 骨架(30 行) ```tsx // components/editor/EditGizmo.tsx "use client"; import { TransformControls } from "@react-three/drei"; import type { Object3D } from "three"; import { useEditorStore } from "@/stores/editor-store"; export function EditGizmo({ target }: { target: Object3D | null }) { const mode = useEditorStore((s) => s.gizmoMode); const snapEnabled = useEditorStore((s) => s.snapEnabled); const updateTransform = useEditorStore((s) => s.updateActiveOpTransform); if (!target) return null; const snap = { translate: snapEnabled ? 0.05 : null, rotate: snapEnabled ? Math.PI / 12 : null, // 15° scale: snapEnabled ? 0.05 : null, }; return ( updateTransform({ position: target.position.toArray() as [number, number, number], rotation_y_deg: (target.rotation.y * 180) / Math.PI, scale_uniform: target.scale.x, })} /> ); } ``` ### 6.3 数值输入面板(与 gizmo 双向绑定) 抽屉底部 4 列表单,**精度 0.01 m**: ``` X: [ -0.03 ] m Y: [ 0.00 ] m Z: [ +0.12 ] m Rot Y: [ 15.0 ] deg Scale: [ 1.04 ] x [ Reset ] [ Center ] ``` - `react-hook-form` 受控;防抖 200 ms 写 store - gizmo 拖动 → store 变更 → 表单 `setValue`(不触发 `onChange`,避免循环) - 表单输入 → store 变更 → `target.position.set(...)`(手动同步 Object3D) ### 6.4 Snap 策略 | 自由度 | 默认 snap | 修饰键 | |--------|----------|--------| | Translate | 0.05 m | `Shift` → 0.01 m;`Alt` → 关 snap | | Rotate Y | 15° | `Shift` → 5°;`Alt` → 自由 | | Scale | 0.05× | `Shift` → 0.01× | ### 6.5 Undo / Redo | 项 | 决策 | |----|------| | 库 | **`zundo`**(Zustand temporal middleware,10 KB) | | 栈深 | 50 步(与 [`04_web_app_plan.md`](04_web_app_plan.md) §7.3 一致) | | 粒度 | **每个用户动作 = 1 步**(拖 gizmo 全程算 1 步,松手时 commit) | | 快捷键 | `Cmd/Ctrl+Z` undo · `Cmd/Ctrl+Shift+Z` redo | | 批量操作 | §7 批量 N 个 op **包在 `temporal.pause()`...`resume()` 内**,算单步 | --- ## 7. 多个物品的批量替换("全屋换风格") ### 7.1 流程 1. 用户 `Shift+Click` 选中 ≥ 2 个家具 mesh(§3.2 已支持) 2. AssetPicker 顶部出现"批量替换 (N 个已选)"banner,按 `semantic_class` 多选模式过滤 3. 用户选风格 tag(如"工业风")→ 列表展示该风格下覆盖所有所选 semantic_class 的资产组合 4. 点击"应用到全部 N 个"→ Zustand action 原子提交 N 个 `replace_furniture` op 5. 单步 Undo 即可整体回滚 ### 7.2 原子提交 ```typescript // stores/editor-store.ts applyBatchReplacement: (selections: ItemId[], assets: AssetMeta[]) => { const { temporal } = get(); temporal.pause(); try { selections.forEach((itemId, i) => { const asset = assets[i]; const aligned = alignAssetToOBB(asset, getOBB(itemId), getAnchor(itemId)); get().pushOp({ op: "replace_furniture", target_item_id: itemId, asset_id: asset.asset_id, asset_glb_uri: asset.glb_uri, transform: aligned, snap_to_anchor: true, }); }); } finally { temporal.resume(); // 此时 50 步栈只多了 1 步 } } ``` ### 7.3 风格预设(P2 候选) MVP **不实现** "一键全屋北欧风" 的服务端预设;但本手册在 `remix_overlay.json` 中**预留字段** `style_preset_id?: string`(§8.3 类型定义已包含),落地路径见 [`ROADMAP.md`](ROADMAP.md) F-X 候选。 --- ## 8. 写入 `remix_overlay.json` ### 8.1 与 v0.2 既有 schema 的关系 [`02_api_contract.md`](02_api_contract.md) §4.2 已定义 `remix_overlay.json` 的整体形态与 5 种 op:`replace_furniture / replace_material / hide_layer / set_wall_color / add_decoration`。本手册**完全遵循**这套命名(任务描述里的 `swap_furniture / hide_node` 是同义别名,本手册一律用 v0.2 官方命名)。 ### 8.2 完整示例 ```json { "schema_version": "1.0.0", "parent_version_id": "c7e0d8f1-2a4d-6b9e-4f0e-8a7c3d2b1f5e", "parent_glb_uri": "rooms/8f1c.../v1/canonical.glb", "parent_manifest_uri": "rooms/8f1c.../v1/layer_manifest.json", "ops": [ { "op": "replace_furniture", "target_item_id": "bed_001", "asset_id": "a91c2b3e-...", "asset_glb_uri": "assets/furniture/modern_bed_oak.glb", "transform": { "translate": [0.12, 0.0, -0.45], "rotate_quat": [0.991, 0.0, 0.131, 0.0], "scale": [1.04, 1.04, 1.04] }, "snap_to_anchor": true, "alignment_mode": "fit-volume", "created_at": "2026-05-19T12:34:56Z" }, { "op": "replace_material", "target_slot_id": "mat_floor_wood", "asset_id": "a8e92c1f-...", "pbr_override": { "base_color_tex": "assets/materials/oak_dark/base.webp", "normal_tex": "assets/materials/oak_dark/normal.webp", "roughness": 0.55, "metallic": 0.0 }, "uv_scale": [2.0, 2.0] }, { "op": "hide_layer", "layer_kind": "furniture", "target_item_ids": ["tv_001"] }, { "op": "set_wall_color", "target_mesh_id": "wall_2", "base_color": [0.85, 0.78, 0.92, 1.0] } ], "style_preset_id": null, "camera_state": { "position": [2.4, 1.6, 3.0], "look_at": [0.0, 0.8, 0.0], "fov_deg": 55 } } ``` ### 8.3 TypeScript 完整类型定义 ```typescript // types/remix-overlay.ts export type LayerKind = "walls" | "floor" | "furniture" | "materials"; export type AlignmentMode = "fit-volume" | "fit-floor" | "none"; export interface OpReplaceFurniture { op: "replace_furniture"; target_item_id: string; asset_id: string; asset_glb_uri: string; transform: { translate: [number, number, number]; rotate_quat: [number, number, number, number]; // [w, x, y, z] scale: [number, number, number]; }; snap_to_anchor: boolean; alignment_mode?: AlignmentMode; created_at?: string; // ISO 8601 } export interface OpReplaceMaterial { op: "replace_material"; target_slot_id: string; asset_id: string; pbr_override: { base_color?: [number, number, number, number]; // RGBA 0-1 base_color_tex?: string; normal_tex?: string; roughness?: number; metallic?: number; ao_tex?: string; }; uv_scale?: [number, number]; } export interface OpHideLayer { op: "hide_layer"; layer_kind: LayerKind; target_item_ids?: string[]; // 缺省 = 整层;有值 = 仅隐藏部分 } export interface OpSetWallColor { op: "set_wall_color"; target_mesh_id: string; base_color: [number, number, number, number]; } export interface OpAddDecoration { op: "add_decoration"; asset_id: string; asset_glb_uri: string; world_obb: { center: [number, number, number]; extent: [number, number, number]; quat: [number, number, number, number]; }; } export type OverlayOp = | OpReplaceFurniture | OpReplaceMaterial | OpHideLayer | OpSetWallColor | OpAddDecoration; export interface RemixOverlay { schema_version: "1.0.0"; parent_version_id: string; parent_glb_uri: string; parent_manifest_uri: string; ops: OverlayOp[]; style_preset_id?: string | null; camera_state?: { position: [number, number, number]; look_at: [number, number, number]; fov_deg: number; }; } ``` ### 8.4 大小预算 | 单 op 类型 | 典型字节数 | |------------|-----------| | `replace_furniture` | ~360 B | | `replace_material` | ~280 B | | `hide_layer` | ~80 B | | `set_wall_color` | ~100 B | | `add_decoration` | ~280 B | **预算**:100 次操作 ≈ 100 × 平均 230 B ≈ **23 KB**,加 schema 包裹 ≤ **50 KB**(vs 父 `canonical.glb` 5–10 MB,**两个数量级压缩**)。超过 50 KB 触发警告 toast(§12 F-7);超过 1 MB 拒绝 PATCH。 ### 8.5 命名映射表(任务描述别名 → v0.2 实际字段) | 任务描述用词 | v0.2 实际字段 | 本手册使用 | |------------|--------------|-----------| | `swap_furniture` | `replace_furniture` | **`replace_furniture`** | | `hide_node` | `hide_layer` + `target_item_ids` | **`hide_layer`** | | `target_node_id` (家具) | `target_item_id` | **`target_item_id`** | | `target_node_id` (材质) | `target_slot_id` | **`target_slot_id`** | | `rotation_y_deg / scale_uniform` | `rotate_quat / scale` | 运行时 deg/uniform → 写入 overlay 时序列化为 `rotate_quat / scale` | --- ## 9. 实时合成(浏览器内,无服务端预合成) 呼应 [`02_api_contract.md`](02_api_contract.md) §8.2 Web-Y2 契约:**父 .glb + 父 manifest + overlay 在浏览器内合成**,不请求服务端预合成。 ### 9.1 应用顺序(关键) ``` 1. clone 父 scene graph(编辑器入口做 1 次;保证父几何永不被修改) 2. 顺序遍历 overlay.ops: 2.1 op = hide_layer -> 对应 group / 节点 visible = false 2.2 op = set_wall_color -> 找 mesh -> material.color.set(hex) 2.3 op = replace_material -> 找 slot.target_mesh -> material 替换 PBR 2.4 op = replace_furniture-> 隐藏原 item.mesh_node_ids -> load asset -> apply transform 2.5 op = add_decoration -> load asset -> 按 world_obb 摆放 3. 应用 camera_state 到 OrbitControls ``` > ✅ **强制约束**:父 scene graph 是 React 渲染缓存的引用,**必须**用 `scene.clone(true)` 拿到深拷贝再修改;否则用户"取消 Remix"时无法回退到父原貌。`useGLTF` 返回的 `gltf.scene` 是共享单例,不能直接改。 ### 9.2 完整 TypeScript 实现(55 行) ```typescript // lib/editor/apply-overlay.ts import { Group, Mesh, MeshStandardMaterial, Color, Quaternion, Vector3 } from "three"; import type { GLTF } from "three/examples/jsm/loaders/GLTFLoader.js"; import type { RemixOverlay } from "@/types/remix-overlay"; export interface OverlayContext { parentScene: Group; // 已 clone 的父 scene(不会被修改其源引用) manifestNodeMap: Map; // mesh_node_id -> Mesh,构建时一次性建好 itemMeshMap: Map; // item_id -> mesh_node_ids slotMeshMap: Map; // slot_id -> target_mesh_id loadGLTF: (uri: string) => Promise; // 注入 useGLTF.preload 或自定义 loader textureLoader: (uri: string) => Promise; } export async function applyOverlay(overlay: RemixOverlay, ctx: OverlayContext): Promise { for (const op of overlay.ops) { switch (op.op) { case "hide_layer": { if (op.target_item_ids?.length) { for (const itemId of op.target_item_ids) { for (const nodeId of ctx.itemMeshMap.get(itemId) ?? []) { const m = ctx.manifestNodeMap.get(nodeId); if (m) m.visible = false; } } } else { // 整层隐藏:父 group 上设置 visible const layerGroup = ctx.parentScene.getObjectByName(`layer_${op.layer_kind}`); if (layerGroup) layerGroup.visible = false; } break; } case "set_wall_color": { const m = ctx.manifestNodeMap.get(op.target_mesh_id); const mat = m?.material as MeshStandardMaterial | undefined; if (mat) mat.color = new Color(op.base_color[0], op.base_color[1], op.base_color[2]); break; } case "replace_material": { const meshId = ctx.slotMeshMap.get(op.target_slot_id); if (!meshId) break; const mesh = ctx.manifestNodeMap.get(meshId); if (!mesh) break; const mat = mesh.material as MeshStandardMaterial; if (op.pbr_override.base_color_tex) mat.map = await ctx.textureLoader(op.pbr_override.base_color_tex); if (op.pbr_override.normal_tex) mat.normalMap = await ctx.textureLoader(op.pbr_override.normal_tex); if (op.pbr_override.roughness !== undefined) mat.roughness = op.pbr_override.roughness; if (op.pbr_override.metallic !== undefined) mat.metalness = op.pbr_override.metallic; mat.needsUpdate = true; break; } case "replace_furniture": { for (const nodeId of ctx.itemMeshMap.get(op.target_item_id) ?? []) { const m = ctx.manifestNodeMap.get(nodeId); if (m) m.visible = false; } const gltf = await ctx.loadGLTF(op.asset_glb_uri); const inst = gltf.scene.clone(true); inst.position.set(...op.transform.translate); inst.quaternion.set(op.transform.rotate_quat[1], op.transform.rotate_quat[2], op.transform.rotate_quat[3], op.transform.rotate_quat[0]); inst.scale.set(...op.transform.scale); inst.name = `overlay_furniture_${op.target_item_id}`; ctx.parentScene.add(inst); break; } case "add_decoration": { const gltf = await ctx.loadGLTF(op.asset_glb_uri); const inst = gltf.scene.clone(true); inst.position.set(...op.world_obb.center); inst.quaternion.set(op.world_obb.quat[1], op.world_obb.quat[2], op.world_obb.quat[3], op.world_obb.quat[0]); ctx.parentScene.add(inst); break; } } } } ``` ### 9.3 性能目标 | 场景 | 目标 | 达成措施 | |------|------|---------| | 单 op 应用 | < 5 ms(隐藏 / 改色 / 改材质) | manifest map O(1) 查找;material 直接改属性不重建 mesh | | 单 `replace_furniture`(含 .glb 拉取)| < 1500 ms | useGLTF Suspense 预加载 + KTX2 纹理 + meshopt | | 100 次操作总耗时(无网络)| ≤ 50 ms(M1 Mac,Chrome 120) | 串行 await,但每 op 实际同步部分 < 0.5 ms | | 内存占用(100 次替换后)| 增量 < 200 MB | LRU 缓存(§11.2)+ 卸载隐藏节点的纹理 | ### 9.4 父几何不可变契约 | 规则 | 说明 | |------|------| | **N-9-1** `useGLTF(parentGlbUri).scene` 视为只读 | 进入编辑器立即 `scene.clone(true)` 一份给 store | | **N-9-2** 退出编辑器不调用 `dispose` | drei 缓存父 .glb;其它页面(详情页)也用 | | **N-9-3** 取消 Remix = 丢弃 clone 副本 + 重置 store | 父 ref 完整不变 | | **N-9-4** Hot reload 时父 .glb 不重拉 | 缓存键 = `parent_glb_uri` | --- ## 10. 自动保存与并发安全 ### 10.1 IndexedDB 草稿 库选用 [`idb`](https://github.com/jakearchibald/idb)(5 KB,Promise 化)。Schema: ```typescript // lib/editor/draft-store.ts import { openDB, DBSchema } from "idb"; interface CrowdRoomDB extends DBSchema { drafts: { key: string; // remix_id value: { remix_id: string; overlay_json: string; // 序列化的 RemixOverlay updated_at: number; // ms epoch synced_at: number | null; // 上次成功 PATCH 时间;null = 从未同步 parent_room_id: string; parent_version_id: string; }; indexes: { "by-synced": number; "by-updated": number }; }; } export const dbPromise = openDB("crowdroom-editor", 1, { upgrade(db) { const store = db.createObjectStore("drafts", { keyPath: "remix_id" }); store.createIndex("by-synced", "synced_at"); store.createIndex("by-updated", "updated_at"); }, }); ``` ### 10.2 防抖自动保存 ```typescript // stores/editor-store.ts (snippet) import debounce from "lodash.debounce"; const saveDraft = debounce(async (state: EditorState) => { const db = await dbPromise; await db.put("drafts", { remix_id: state.remixId, overlay_json: JSON.stringify(state.toOverlay()), updated_at: Date.now(), synced_at: state.synced_at, parent_room_id: state.parentRoomId, parent_version_id: state.parentVersionId, }); // 网络可用 → PATCH /functions/v1/remix-update if (navigator.onLine) { const r = await fetch(`/functions/v1/remix-update`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${state.jwt}` }, body: JSON.stringify({ remix_id: state.remixId, overlay: state.toOverlay() }), }); if (r.ok) await db.put("drafts", { ...(await db.get("drafts", state.remixId))!, synced_at: Date.now() }); } }, 3000); ``` > 🔄 **建议增量端点**:[`02_api_contract.md`](02_api_contract.md) §2.1 目前仅有 `E-11 remix-create`,未显式列 `remix-update`。本手册假设落地 **E-20 `PATCH /functions/v1/remix-update`**,入参 `{ remix_id, overlay }`,出参 `{ saved_at, etag }`;走 RLS owner-only。需在下一版 02 文档追认。 ### 10.3 离线编辑 | 场景 | 行为 | |------|------| | `navigator.onLine === false` | 仍写 IndexedDB;不调 PATCH;UI 顶部黄色 toast "已离线,本地草稿已保存" | | 恢复在线(`window.addEventListener('online', ...)`) | 立即把 `synced_at < updated_at` 的草稿 PATCH 上传,串行处理 | | 上传失败 | 指数退避(3s / 12s / 48s),3 次后红 toast | | 关闭页面 | `beforeunload` 拦截:若 `synced_at < updated_at`,弹"有未保存草稿,是否离开?" | ### 10.4 多 Tab 并发(BroadcastChannel) ```typescript const channel = new BroadcastChannel(`remix-${remixId}`); channel.postMessage({ type: "claim", tab_id: myTabId }); channel.onmessage = (e) => { if (e.data.type === "claim" && e.data.tab_id !== myTabId) { // 已在另一标签页打开 showDialog({ title: "该 Remix 已在另一个标签页编辑", body: "继续在此标签页编辑会覆盖另一处的未保存改动。", actions: ["接管编辑", "切换到那个标签页"], }); } }; ``` ### 10.5 父版本删除的兜底 呼应 [`02_api_contract.md`](02_api_contract.md) §7.1 错误码 `REMIX_PARENT_DELETED` 与 [`10_governance.md`](10_governance.md) §4 P-W-3 快照转移机制: ```typescript async function patchRemix(state: EditorState) { const r = await fetch(...); if (r.status === 410) { const { error } = await r.json(); if (error.code === "REMIX_PARENT_DELETED") { showDialog({ title: "父房间已被作者删除", body: "你的修改可保存为独立副本(平台会自动保留父几何快照)。", actions: [ { label: "保存为独立副本", onClick: () => promoteToStandalone(state) }, { label: "丢弃改动", onClick: () => discardDraft(state) }, ], }); } } } ``` `promoteToStandalone` 调用 E-17 `room-delete-with-snapshot` 已经准备好的 `parent_snapshot_path`([`01_data_schema.md`](01_data_schema.md) §3.6 v0.2 字段),把 Remix 的 `parent_glb_uri` 切换到 `remix-fallbacks/{room_id}/v{n}/canonical.glb`。 ### 10.6 乐观锁(防多设备覆盖) `remixes` 行加 `updated_at TIMESTAMPTZ` + `etag UUID`(建议增量;当前 v0.2 schema 已有 `updated_at`,etag 可由 trigger 自动维护);PATCH 入参带 `If-Match: {etag}`,冲突时 412 + 业务码 `REMIX_STALE_VERSION`,让客户端弹"另一设备已编辑"对话框。 --- ## 11. 性能与移动端优化 ### 11.1 资产懒加载 | 策略 | 实现 | |------|------| | AssetPicker 列表用 128×128 webp 预览图 | `assets.thumbnail_path` 已存在;CDN URL 走 Next/Image | | 点击卡片才 `useGLTF.preload(asset.glb_uri)` | 拉取与对齐计算并行 | | 鼠标悬停 600 ms 预拉 | 防"快速划过"过度抓取 | | KTX2 + Meshopt 纹理/几何压缩 | drei `useGLTF` 自动支持,加载体积降 50–80% | ### 11.2 LRU 缓存 ```typescript import { LRUCache } from "lru-cache"; const assetCache = new LRUCache({ max: 10, dispose: (gltf) => { gltf.scene.traverse((o: any) => { if (o.geometry) o.geometry.dispose(); if (o.material?.dispose) o.material.dispose(); }); }}); ``` 容量 10:典型用户一次会话替换 ≤ 30 个家具,命中率 70%+;超额淘汰时主动 dispose 几何与纹理,回收 GPU 内存。 ### 11.3 移动端 Safari 优化 | 项 | 措施 | |----|------| | `` hitbox | `scale={1.5}` 放大 gizmo handle | | iOS haptic | 按住 gizmo 时 `navigator.vibrate(10)`(仅 iOS 16+,Safari 部分支持) | | Touch raycaster 容差 | hit 范围用 `Raycaster.params.Line.threshold = 0.05`(默认 1 太大) | | dpr 锁 | `` 防 Retina 屏 GPU 过载 | ### 11.4 大场景"图层独显"模式 ```typescript // 进入"专注编辑某家具层"模式 setLayerSoloMode(true); // -> 其它 3 层 group.visible = false // -> 仅渲染当前编辑层 + 已 overlay 的新资产 // -> draw call 减少 ~50%(典型房间 30 draw call -> 15) ``` UI:图层面板每行右上角增加 "🎯 Solo" 按钮;solo 状态下顶部 banner 显示 "正在独显 Furniture 层 [退出]"。 ### 11.5 性能预算(M1 Mac, Chrome 120) | 指标 | 目标 | 关联 | |------|------|------| | 进入编辑器 LCP | < 3 s(含父 .glb 5 MB + manifest) | §11.1 | | 单次替换交互响应(点击 → 渲染完成)| < 3 s | §13 M1-3 | | 100 次操作连续替换内存增量 | < 200 MB | §11.2 LRU | | Idle 期帧率 | ≥ 60 fps(桌面)/ ≥ 30 fps(iOS Safari)| [`04_web_app_plan.md`](04_web_app_plan.md) §3.3 | --- ## 12. 失败模式与错误恢复 10 种失败情形 + 用户可见行为 + 内部错误码 + 自动恢复策略: | # | 失败情形 | 用户可见行为 | 内部错误码 | 自动恢复 | |---|---------|------------|-----------|----------| | F-1 | 资产 .glb 加载超时(> 10s) | 占位灰色 box + 红色"重试"按钮 | `ASSET_GLB_TIMEOUT`(前端) | 自动 1 次重试;仍失败标记该 asset 为本会话不可用 | | F-2 | 资产元数据缺 `anchor_local` | 顶部黄色 toast "该资产未标注锚点,建议手动微调";自动进入 §6 gizmo 模式 | `ASSET_META_INCOMPLETE`(前端) | 强制 gizmo 显示;不阻断使用 | | F-3 | 自动保存网络失败 | 红色 toast "云端同步失败,本地已保存";状态栏图标变红 | `REMIX_PATCH_FAILED`(HTTP 5xx) | 指数退避 3s/12s/48s;离线队列 | | F-4 | overlay 校验失败(ops 引用了不存在的 item_id) | Toast "本次操作未通过校验:{detail}";自动 undo 1 步 | `OVERLAY_INVALID`([`02_api_contract.md`](02_api_contract.md) §7.1) | 自动 undo + Sentry breadcrumb | | F-5 | asset 已下架(`ASSET_NOT_FOUND`) | AssetPicker 中该卡片置灰 "(已下架)",已选中则 toast 提醒更换 | `ASSET_NOT_FOUND`([`02_api_contract.md`](02_api_contract.md) §7.1) | 移出收藏夹;保留 overlay 中的引用直到用户主动替换 | | F-6 | 父房间被作者硬删 | 弹窗"父房间已删除,是否保存为独立副本?" | `REMIX_PARENT_DELETED`(HTTP 410) | §10.5 `promoteToStandalone` | | F-7 | overlay 大小 > 50 KB | 黄色 banner "改动已较多,建议精简";继续允许编辑 | `OVERLAY_SIZE_WARN`(前端) | 提示合并连续同 op;UI 列出 ops 频率统计 | | F-8 | overlay 大小 > 1 MB | 拒绝 PATCH;红色 banner "改动超过限制,请精简后再保存" | `OVERLAY_TOO_LARGE`(HTTP 413) | 自动卷起最近 N 个 op 让用户选删 | | F-9 | 并发同步冲突(多设备) | 弹窗"另一设备已编辑此 Remix,是否覆盖?" | `REMIX_STALE_VERSION`(HTTP 412,建议增量) | 用户选择"覆盖"重发;选择"放弃"则拉取远端 overlay 同步本地 | | F-10 | 缩放 clamp / 朝向冲突 | UI 红 banner + 高亮 §6 gizmo;提供 "⟲ 180° 翻转" 按钮 | `ALIGN_WARNING`(前端) | 用户手动微调后 warning 清除 | > 所有错误都送 Sentry breadcrumb,`tags: { remix_id, op_index }`,便于事后排障;HTTP 错误码与 [`02_api_contract.md`](02_api_contract.md) §7 严格对齐。 --- ## 13. M1 验收清单 > M1 = 物品替换能力的最小可发布版(与 [`04_web_app_plan.md`](04_web_app_plan.md) §11.1 MVP 范围对齐) - [ ] **A-1 选中覆盖率**:用户能点选 ≥ 90% 扫描得到的家具(按 RoomPlan `furniture_count` 抽样 30 个房间,命中 ≥ 27) - [ ] **A-2 对齐精度**:OBB 自动对齐误差 ≤ 5 cm(中心距)+ ≤ 10°(朝向),抽样 50 个 "原家具 → 同语义新资产" 的替换实例 - [ ] **A-3 单次替换响应**:替换 1 个沙发 < 3 s(从点击到渲染完成,含 .glb 拉取与对齐计算;M1 Mac Chrome 120) - [ ] **A-4 内存稳定性**:100 次连续替换后 DevTools Memory 增量 < 200 MB;GC 后回到基线 ±30 MB - [ ] **A-5 离线韧性**:IndexedDB 草稿在网络断开 5 min 后能完整恢复(关页 → 重开 → 改动仍在) - [ ] **A-6 Schema 校验**:`remix_overlay.json` 通过本手册 §8.3 类型 + JSON Schema 校验(Edge Function 拒收非法 op) - [ ] **A-7 跨浏览器**:iOS Safari 16+ / Chrome 120+ / Firefox 119+ 三浏览器 E2E 通过(Playwright + 视觉回归 1% 阈值) - [ ] **A-8 父删兜底**:父房间被删后,已发布 Remix 仍可正常浏览(走 `parent_snapshot_path`,[`10_governance.md`](10_governance.md) §4 P-W-3 快照转移) - [ ] **A-9 触屏体验**:移动端 Safari 上长按 500 ms 进入多选模式;gizmo handle 至少 32×32 CSS px - [ ] **A-10 性能预算**:进入编辑器 LCP < 3 s(父 .glb 5 MB);Idle 帧率 60 fps(桌面) > 共 **10 条**,QA 在 M1 RC 阶段全部 pass 才允许打 tag。 --- ## 14. 与未来 P2 能力的接口预留 ### 14.1 多人协同编辑(CRDT) `remix_overlay.json` 的 `ops: OverlayOp[]` 是一个**顺序追加**结构,天然契合 CRDT。未来用 [Yjs](https://github.com/yjs/yjs) 或 [Automerge](https://github.com/automerge/automerge) 包装时,本手册建议: ```typescript // 未来接口(P2 不在本手册实现,仅留 hook) interface RemixOverlayCRDT { doc: Y.Doc; ops: Y.Array; // 用 Y.Array 替换 plain array presence: Y.Map; // 谁在选中哪个 item / 哪个 op } ``` 迁移成本:现有 `Zustand store → ops[]` 改为 `Zustand store → ops.toArray()`,订阅 `ops.observe` 即可。无需改 §8 schema。 ### 14.2 AI 自动配色 / 风格推荐 预留 query 参数:`POST /functions/v1/remix-update?ai_suggest=true&suggest_kind=color|style|all`。Edge Function 在收到此参数时,**额外**调用大模型 API 返回建议 op 列表(不直接写库): ```typescript interface RemixUpdateResponse { saved_at: string; etag: string; ai_suggestions?: OverlayOp[]; // P2 才填充;MVP 始终为 undefined } ``` ### 14.3 AR 即时预览 `viewState` 增加 `ar_mode?: boolean` 字段([`04_web_app_plan.md`](04_web_app_plan.md) §3.5 已留 `` 挂载点)。客户端检测: ```typescript if (viewState.ar_mode && navigator.xr) { // 进入 @react-three/xr 的 immersive-ar session } else if (viewState.ar_mode && /* iOS Safari */) { // fallback 到 的 AR Quick Look } ``` 服务端:CDN 同时产出 `canonical_with_overlay.usdz`(合成后 USDZ;P2 才接入转码 Worker),让 iOS AR Quick Look 直接用。 --- ## 15. 给子任务 10([`11_asset_library.md`](11_asset_library.md))的接口要点 > 本手册的算法与体验**强依赖**资产库的元数据完备性。下表是资产库**必须保证**的契约,否则本手册对应章节无法工作。 | # | 资产库必须保证 | 本手册依赖章节 | 失效后果 | |---|---------------|--------------|---------| | **AL-1** | 所有 `kind='furniture'` 资产**必须**标注 `anchor_local: [x,y,z]` + `forward_axis` + `up_axis` + `bbox_local` | §5.1.2 + §5.2 + §5.4 | 缺一项 → §5.5 兜底进手动微调,对齐精度从 ≤ 5 cm 退化到 ≤ 20 cm,M1 A-2 验收失败 | | **AL-2** | 所有公开资产**必须**标注 `semantic_class`([`01_data_schema.md`](01_data_schema.md) §3.9 已有字段,子任务 10 需保证非空率 ≥ 99%) | §4.2 默认筛选 | semantic 缺失 → AssetPicker 无法按选中物语义过滤,所有家具混在一起,US-1 体验崩溃 | | **AL-3** | `assets` 表新增 `tags text[]` 必须包含**风格 tag**(北欧/工业/中式/极简/复古/侘寂/包豪斯 7 类),且每件资产至少 1 个 | §4.2 style 维度 + §7 批量风格化 | 缺失 → §7 US-2 批量风格化失效 | | **AL-4** | `assets` 表新增物化列 `volume_m3 numeric` + `dominant_color text`(hex);GIN 索引 `tags` | §4.2 `bbox_filter` / `color` 维度 | 缺失 → 检索退化到全表 scan,> 200 件时延迟 > 1 s | | **AL-5** | 资产 .glb 必须经 KTX2 + Meshopt 压缩,单件 ≤ 1 MB(家具) / ≤ 200 KB(材质贴图集) | §11.1 性能 | 超标 → §13 A-3 单次替换 < 3 s 失败 | | **AL-6** | 资产缩略图必须有 128×128 webp(CDN URL),生成 pipeline 与转码 Worker 一致 | §4.5 卡片 / §11.1 懒加载 | 缺失 → AssetPicker 列表加载主图 5 MB 网络成本爆炸 | | **AL-7** | 资产 license 必须二选一:`'CC0' | 'CC-BY'`;MVP 默认仅展示 CC0 | §4.2 license 维度 + [`04_web_app_plan.md`](04_web_app_plan.md) §5.4 R-Web-5 | 引入非 CC 许可 → 版权风险 | | **AL-8** | "材质"类资产的 `pbr` jsonb 必须含 `base_color_tex / normal_tex / roughness / metallic`([`01_data_schema.md`](01_data_schema.md) §3.9 已规约) | §8.3 `OpReplaceMaterial.pbr_override` | 字段不全 → 材质替换 fallback 到纯色,视觉退化 | | **AL-9** | 资产 ID 与 glb_path 之间 **不可重定向**(资产一旦发布即不可改 path) | §8.2 overlay 持久化 `asset_glb_uri` | 改动 path → 已发布 Remix 加载失败 → F-5 错误码触发率飙升 | | **AL-10** | 资产库提供 `GET /rest/v1/assets?bbox_filter=lo,hi&style=...&semantic=...` 复合筛选(本手册建议增量,§4.2 R-3-4) | §4.2 即时联动筛选 | 无此参数 → AssetPicker 拉全集后端筛 → 移动端 OOM | > **行动**:以上 10 条契约请子任务 10 在 [`11_asset_library.md`](11_asset_library.md) 中以"硬契约"形式承诺;本手册 §13 M1 验收 A-1 / A-2 / A-3 都是验证这 10 条的间接指标。 --- ## 16. 本章小结 | 关键产出 | 一句话 | |----------|--------| | **27 步数据流序列图(§2)** | 从 raycast 命中到发布 Remix 全链路,每步映射本手册章节 | | **5 个核心代码骨架** | useSelectableMesh §3 / AssetCard §4 / alignAssetToOBB §5 / RemixOverlay TS 类型 §8 / applyOverlay §9 | | **3 种 OBB 对齐缩放模式** | fit-volume / fit-floor / none,按 semantic_class 智能默认 | | **5 种 overlay op**(沿用 v0.2 既有命名) | replace_furniture / replace_material / hide_layer / set_wall_color / add_decoration | | **零服务端预合成** | 严格遵守 Web-Y2 契约,浏览器内 clone 父 scene 后顺序应用 | | **离线 + 多 Tab 安全** | IndexedDB 草稿 + BroadcastChannel + 父删 promoteToStandalone | | **10 条 M1 验收** | 覆盖选中率 / 对齐精度 / 响应 / 内存 / 离线 / 跨浏览器 / 父删 / 触屏 / 性能 | | **10 条移交给子任务 10 的硬契约** | 缺一即本手册 §5 / §11 / §13 失效 | 读完本手册你应能: - ✅ 直接开始写 `useSelectableMesh / alignAssetToOBB / applyOverlay` 等核心模块 - ✅ 不被任务描述与 v0.2 实际字段命名差异困扰(§8.5 映射表已收口) - ✅ 知道哪些"建议增量"需要在下一版 02 / 11 文档中追认 - ✅ 接手 QA 时知道 M1 要测的 10 条具体指标 --- **章节版本**:v0.2 · 草案(子任务 9) **关键收获**:物品替换不是单一功能,是 **raycaster + AssetPicker + OBB 对齐 + 4DoF gizmo + overlay schema + 浏览器实时合成 + 离线草稿** 七件套的合奏;任何一件套退化都让用户体验从"灵感生成器"退化到"3D 玩具"。