Files
worldmodel/plans/CrowdRoom/05_object_replacement_handbook.md
T

54 KiB
Raw Blame History

CrowdRoom · 物品替换实施手册(v0.2)

版本v0.22026-05-19)· 子任务 9 产出 定位:把分散在 04_web_app_plan.md §5–§7、01_data_schema.md §5、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 §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 继续 forkEve 在 Bob 的工业风 Remix 上再 fork → 把椅子又换成藤编 overlay 的 op 是天然可叠加的

1.3 与 v0.2 架构对照表

本手册章节 v0.2 文档对应 实现关系
§1 总览 04_web_app_plan.md §6 家具替换 + §5 材质替换 直接引用
§2 数据流 04_web_app_plan.md §7.1 Remix 端到端序列图 深化补充(从 9 步扩到 27 步,纳入 raycaster/AssetPicker/对齐)
§3 选中机制 04_web_app_plan.md §3.2 选中态高亮 + §4.3 交互细节 深化补充(补 raycaster TSX 实现、触屏长按)
§4 AssetPicker 04_web_app_plan.md §5.4 / §8.6 资产库 深化补充(补无限滚动 + bbox_filter 检索参数)
§5 OBB 对齐 04_web_app_plan.md §6.3 自动对齐 + 01_data_schema.md §5.2 furnitureItem.obb/anchor_point 深化补充(补完整数学 + 3 种缩放模式 + 朝向兜底)
§6 4DoF 微调 04_web_app_plan.md §6.4 深化补充(补 TransformControls TSX + Snap 策略 + Undo
§7 批量替换 04_web_app_plan.md §6.6 整体隐藏 新增
§8 写 overlay 02_api_contract.md §4.2 remix_overlay.json schema 直接引用 + 补 TS 类型
§9 applyOverlay 04_web_app_plan.md §5.3 / §6.3 实时预览伪代码 深化补充(补完整 switch + clone 副本策略)
§10 自动保存 / 冲突 04_web_app_plan.md §7.2 自动保存与冲突 深化补充(补 IndexedDB schema + BroadcastChannel + 父删兜底)
§11 性能 04_web_app_plan.md §3.3 / §10.1 深化补充(补 LRU + 图层独显)
§12 失败模式 02_api_contract.md §7 错误码 + 04_web_app_plan.md §7.4 深化补充10 种失败情形表)
§13 M1 验收 04_web_app_plan.md §11.1 MVP 新增
§14 P2 预留 04_web_app_plan.md §11.2 不做项 新增CRDT / AI / AR 三条接口 hook

全表 13 行,全部能在 v0.2 现有文档找到锚点——本手册不引入任何字段级破坏性改动。


2. 数据流总览(最关键章节)

下面这张 sequenceDiagram 共 27 个步骤,串联从"用户点击 mesh"到"跳转 Remix 详情页"的完整链路,是本手册其它章节的总纲。

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 map01_data_schema.md §3.4 layers 表 manifest_node 冗余存储) 04_web_app_plan.md §3.2
C-3 第 11 步 AssetPicker 拉资产必须bbox_filter(按选中物 OBB ±20% 推荐,§4.2) 本手册新增
C-4 第 1619 步 transform + clone + 隐藏原节点 → push op 必须在一个 Zustand action 内原子提交,否则 Undo 会断 §7 / §10
C-5 第 21 步 debounce 3 秒由 Zustand middleware 实现,与 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-1selectable=true 的 mesh 响应 在场景图构建时给每个 mesh 加 userData.selectable: boolean,由 manifest 中 replaceable 字段决定(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 <group> 上设 userData.lockedraycaster pre-filter
R-3-4 高亮不修改 mesh material @react-three/postprocessing<Outline> 选中物入参,零副作用(04_web_app_plan.md §3.2
R-3-5 Hover 与 Click 区分 pointermove 节流 30ms,命中时仅染色(emissive 0.15 + Outline 弱);pointerdown 才进选中态

3.2 TSX 实现骨架(45 行)

// 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]);
}

配套:<EffectComposer><Outline selection={selectedObject3Ds} edgeStrength={3} /></EffectComposer><Canvas> 内挂载,根据 useEditorStore 中的 selectedItemIds → Object3D[] 反查实现高亮。

3.3 触屏 UX

触发 行为
长按 500 ms 等效桌面端 Shift+Click,进入多选模式(haptic 提示)
双击 mesh 相机 fit-to-bbox 到该 mesh0.6s 缓动(04_web_app_plan.md §3.5
双指捏合 缩放相机,不触发选中
三指拖 平移相机(绕过 OrbitControls 默认两指)

实现:用 @use-gesture/reactuseDrag + 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 §5.2 16 类 enum
style tag 全部 多选 chip:北欧/工业/中式/极简/复古/侘寂/包豪斯 01_data_schema.md §3.9 assets.tags[]
color 全部 HSV 色环 pickerreact-colorful),映射到最近 12 色 chip 资产侧元数据 dominant_color
bbox_filter 选中物 OBB 体积 ±20% 切换开关;关闭后允许大尺寸不匹配,UI 给警告 本手册新增(建议增量)
license CC0 onlyMVP 默认) Toggle "包含 CC-BY" 01_data_schema.md §3.9 assets.license

🔄 建议增量02_api_contract.md §2.1 PostgREST 端点应支持 bbox_filter=0.8,1.2style=北欧&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/ 名称 / 协议 chipCC0 绿色 / CC-BY 黄色)/ 三角面数 / 创作者 handle / OBB 尺寸(对比原物)。

4.4 无限滚动 + 虚拟列表

  • @tanstack/react-virtual v3,每行高 96 pxoverscan 5
  • 分页:?limit=24&offset={page*24}React Query useInfiniteQuery
  • 触发阈值:滚到列表底部 200 px 时预取下一页
  • 总数 > 200 时顶部固定显示"共 N 件,按相关度排序"

4.5 资产卡片 TSX 骨架(22 行)

// 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 (
    <button
      onClick={() => onPick(asset)}
      className="flex gap-3 p-2 w-full hover:bg-muted rounded-md text-left"
      aria-label={`Pick ${asset.name}`}
    >
      <Image src={asset.thumbnail_url} alt="" width={96} height={96} className="rounded" />
      <div className="flex-1 min-w-0">
        <div className="font-medium truncate">{asset.name}</div>
        <div className="text-xs text-muted-foreground">{asset.semantic_class}</div>
        <div className="flex gap-1 mt-1">
          <Badge variant={asset.license === "CC0" ? "default" : "secondary"}>{asset.license}</Badge>
          <Badge variant="outline">{(asset.tri_count / 1000).toFixed(1)}k tris</Badge>
        </div>
      </div>
    </button>
  );
}

5. OBB 对齐算法(重点章节)

物品替换的算法核心:把一个新 asset(其几何来自 assets.glb_path)放到原家具 OBB 描述的位置/朝向/尺寸上,默认无需用户调整即视觉合理。

5.1 输入数据约定(与 v0.2 schema 严格对齐)

5.1.1 原 mesh 侧

来自父房间 layer_manifest.jsonfurnitureItem01_data_schema.md §5.2):

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;
}

⚠️ 与任务描述的术语调和:任务描述里写 halfExtentsv0.2 schema 实际是 extentfull)。本手册一律以 v0.2 extent 为准,凡需 halfExtent 时显式 extent[i]/2

5.1.2 新 asset 侧

来自资产库(01_data_schema.md §3.9 assets.pbr jsonb,由子任务 10 落地):

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 对齐公式(每一步显式写出)

设:

  • 原 OBBC_o ∈ ℝ³center, world)、E_o ∈ ℝ³⁺full extent)、Q_o ∈ orientation quaternion [w,x,y,z]
  • 原 anchorA_o ∈ ℝ³world,通常 = OBB 底面中心,即 C_o Q_o · (0, E_o.y/2, 0)
  • 新 asset 局部 anchorA_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.1s > 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

UIAssetPicker 下方一个 segmented control,默认按 semantic_class 智能推荐:

bed / sofa / table / chair       -> fit-floor
storage / refrigerator / stove   -> fit-volume
television / fireplace / stairs  -> none

5.4 TypeScript 实现骨架(76 行)

// 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<AssetMeta["forward_axis"], Vector3> = {
  "+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 §6.4 一致:禁止任意 6DoF 旋转非等比缩放

6.2 TransformControls TSX 骨架(30 行)

// 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 (
    <TransformControls
      object={target}
      mode={mode}
      translationSnap={snap.translate}
      rotationSnap={snap.rotate}
      scaleSnap={snap.scale}
      showX={mode !== "rotate"} showZ={mode !== "rotate"}
      showY={mode === "rotate" || mode === "translate"}
      onObjectChange={() => 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 mAlt → 关 snap
Rotate Y 15° Shift → 5°;Alt → 自由
Scale 0.05× Shift → 0.01×

6.5 Undo / Redo

决策
zundoZustand temporal middleware10 KB
栈深 50 步(与 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 原子提交

// 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 F-X 候选。


8. 写入 remix_overlay.json

8.1 与 v0.2 既有 schema 的关系

02_api_contract.md §4.2 已定义 remix_overlay.json 的整体形态与 5 种 opreplace_furniture / replace_material / hide_layer / set_wall_color / add_decoration。本手册完全遵循这套命名(任务描述里的 swap_furniture / hide_node 是同义别名,本手册一律用 v0.2 官方命名)。

8.2 完整示例

{
  "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 完整类型定义

// 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 KBvs 父 canonical.glb 510 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 §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 行)

// 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<string, Mesh>;       // mesh_node_id -> Mesh,构建时一次性建好
  itemMeshMap: Map<string, string[]>;       // item_id -> mesh_node_ids
  slotMeshMap: Map<string, string>;         // slot_id  -> target_mesh_id
  loadGLTF: (uri: string) => Promise<GLTF>; // 注入 useGLTF.preload 或自定义 loader
  textureLoader: (uri: string) => Promise<THREE.Texture>;
}

export async function applyOverlay(overlay: RemixOverlay, ctx: OverlayContext): Promise<void> {
  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 msM1 MacChrome 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 草稿

库选用 idb5 KBPromise 化)。Schema

// 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<CrowdRoomDB>("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 防抖自动保存

// 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 §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;不调 PATCHUI 顶部黄色 toast "已离线,本地草稿已保存"
恢复在线(window.addEventListener('online', ...) 立即把 synced_at < updated_at 的草稿 PATCH 上传,串行处理
上传失败 指数退避(3s / 12s / 48s),3 次后红 toast
关闭页面 beforeunload 拦截:若 synced_at < updated_at,弹"有未保存草稿,是否离开?"

10.4 多 Tab 并发(BroadcastChannel

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 §7.1 错误码 REMIX_PARENT_DELETED10_governance.md §4 P-W-3 快照转移机制:

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_path01_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_atetag 可由 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 自动支持,加载体积降 5080%

11.2 LRU 缓存

import { LRUCache } from "lru-cache";
const assetCache = new LRUCache<string, GLTF>({ 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 优化

措施
<TransformControls> 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 锁 <Canvas dpr={[1, 1.5]}> 防 Retina 屏 GPU 过载

11.4 大场景"图层独显"模式

// 进入"专注编辑某家具层"模式
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 fpsiOS Safari 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_FAILEDHTTP 5xx 指数退避 3s/12s/48s;离线队列
F-4 overlay 校验失败(ops 引用了不存在的 item_id Toast "本次操作未通过校验:{detail}";自动 undo 1 步 OVERLAY_INVALID02_api_contract.md §7.1 自动 undo + Sentry breadcrumb
F-5 asset 已下架(ASSET_NOT_FOUND AssetPicker 中该卡片置灰 "(已下架)",已选中则 toast 提醒更换 ASSET_NOT_FOUND02_api_contract.md §7.1 移出收藏夹;保留 overlay 中的引用直到用户主动替换
F-6 父房间被作者硬删 弹窗"父房间已删除,是否保存为独立副本?" REMIX_PARENT_DELETEDHTTP 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_LARGEHTTP 413 自动卷起最近 N 个 op 让用户选删
F-9 并发同步冲突(多设备) 弹窗"另一设备已编辑此 Remix,是否覆盖?" REMIX_STALE_VERSIONHTTP 412,建议增量) 用户选择"覆盖"重发;选择"放弃"则拉取远端 overlay 同步本地
F-10 缩放 clamp / 朝向冲突 UI 红 banner + 高亮 §6 gizmo;提供 "⟲ 180° 翻转" 按钮 ALIGN_WARNING(前端) 用户手动微调后 warning 清除

所有错误都送 Sentry breadcrumbtags: { remix_id, op_index },便于事后排障;HTTP 错误码与 02_api_contract.md §7 严格对齐。


13. M1 验收清单

M1 = 物品替换能力的最小可发布版(与 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 MBGC 后回到基线 ±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_path10_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.jsonops: OverlayOp[] 是一个顺序追加结构,天然契合 CRDT。未来用 YjsAutomerge 包装时,本手册建议:

// 未来接口(P2 不在本手册实现,仅留 hook)
interface RemixOverlayCRDT {
  doc: Y.Doc;
  ops: Y.Array<OverlayOp>;   // 用 Y.Array 替换 plain array
  presence: Y.Map<UserPresence>; // 谁在选中哪个 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 列表(不直接写库):

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 §3.5 已留 <XR> 挂载点)。客户端检测:

if (viewState.ar_mode && navigator.xr) {
  // 进入 @react-three/xr 的 immersive-ar session
} else if (viewState.ar_mode && /* iOS Safari */) {
  // fallback 到 <model-viewer> 的 AR Quick Look
}

服务端:CDN 同时产出 canonical_with_overlay.usdz(合成后 USDZ;P2 才接入转码 Worker),让 iOS AR Quick Look 直接用。


15. 给子任务 1011_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 cmM1 A-2 验收失败
AL-2 所有公开资产必须标注 semantic_class01_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 texthex);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 webpCDN 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 §5.4 R-Web-5
AL-8 "材质"类资产的 pbr jsonb 必须含 base_color_tex / normal_tex / roughness / metallic01_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 中以"硬契约"形式承诺;本手册 §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 玩具"。