- 处理: 74 个 .md 文件 - 跳过: 0 个(无已存在的 front matter) - 异常: 3 个(H1 缺失,用文件名兜底) - plans/PRISM/.research/readmes/3d-llm.md - plans/PRISM/.research/readmes/openmask3d.md - plans/PRISM/.research/readmes/openscene.md
54 KiB
title, date, draft, tags, categories
| title | date | draft | tags | categories | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| CrowdRoom · 物品替换实施手册(v0.2) | 2026-05-20 | false |
|
|
CrowdRoom · 物品替换实施手册(v0.2)
版本:v0.2(2026-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 继续 fork:Eve 在 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 map(01_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 | 第 16–19 步 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-1 仅 selectable=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.locked,raycaster 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 到该 mesh,0.6s 缓动(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 §5.2 16 类 enum |
style tag |
全部 | 多选 chip:北欧/工业/中式/极简/复古/侘寂/包豪斯 | 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 §3.9 assets.license |
🔄 建议增量:
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-virtualv3,每行高 96 px,overscan 5 - 分页:
?limit=24&offset={page*24};React QueryuseInfiniteQuery - 触发阈值:滚到列表底部 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.json 的 furnitureItem(01_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;
}
⚠️ 与任务描述的术语调和:任务描述里写
halfExtents,v0.2 schema 实际是extent(full)。本手册一律以 v0.2extent为准,凡需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 对齐公式(每一步显式写出)
设:
- 原 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 行)
// 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 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 §7.3 一致) |
| 粒度 | 每个用户动作 = 1 步(拖 gizmo 全程算 1 步,松手时 commit) |
| 快捷键 | Cmd/Ctrl+Z undo · Cmd/Ctrl+Shift+Z redo |
| 批量操作 | §7 批量 N 个 op 包在 temporal.pause()...resume() 内,算单步 |
7. 多个物品的批量替换("全屋换风格")
7.1 流程
- 用户
Shift+Click选中 ≥ 2 个家具 mesh(§3.2 已支持) - AssetPicker 顶部出现"批量替换 (N 个已选)"banner,按
semantic_class多选模式过滤 - 用户选风格 tag(如"工业风")→ 列表展示该风格下覆盖所有所选 semantic_class 的资产组合
- 点击"应用到全部 N 个"→ Zustand action 原子提交 N 个
replace_furnitureop - 单步 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 种 op:replace_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 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 §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 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(5 KB,Promise 化)。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-20PATCH /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)
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_DELETED 与 10_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_path(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 缓存
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 fps(iOS 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_FAILED(HTTP 5xx) |
指数退避 3s/12s/48s;离线队列 |
| F-4 | overlay 校验失败(ops 引用了不存在的 item_id) | Toast "本次操作未通过校验:{detail}";自动 undo 1 步 | OVERLAY_INVALID(02_api_contract.md §7.1) |
自动 undo + Sentry breadcrumb |
| F-5 | asset 已下架(ASSET_NOT_FOUND) |
AssetPicker 中该卡片置灰 "(已下架)",已选中则 toast 提醒更换 | ASSET_NOT_FOUND(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§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 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§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 或 Automerge 包装时,本手册建议:
// 未来接口(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. 给子任务 10(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 §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 §5.4 R-Web-5 |
| AL-8 | "材质"类资产的 pbr jsonb 必须含 base_color_tex / normal_tex / roughness / metallic(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中以"硬契约"形式承诺;本手册 §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 玩具"。