Files
worldmodel/plans/CrowdRoom/05_object_replacement_handbook.md
T
gaojie 1ea74b46da
Sync to site1 / sync (push) Has been cancelled
chore: update CrowdRoom categories from worldmodel to CrowdRoom
2026-05-21 02:20:22 +08:00

1195 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "CrowdRoom · 物品替换实施手册(v0.2"
date: 2026-05-20
draft: false
tags: ["CrowdRoom", "众包", "3D 重建", "导航", "API", "iOS"]
categories: ["CrowdRoom"]
---
# CrowdRoom · 物品替换实施手册(v0.2)
> **版本**v0.22026-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 | 第 1619 步 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 `<group>` 上设 `userData.locked`raycaster pre-filter |
| **R-3-4** 高亮**不修改** mesh material | 用 `@react-three/postprocessing``<Outline>` 选中物入参,零副作用([`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]);
}
```
> 配套:`<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`](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 色环 pickerreact-colorful),映射到最近 12 色 chip | 资产侧元数据 `dominant_color` |
| `bbox_filter` | 选中物 OBB 体积 **±20%** | 切换开关;关闭后允许大尺寸不匹配,UI 给警告 | 本手册新增(建议增量) |
| `license` | CC0 onlyMVP 默认) | 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/ 名称 / 协议 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 行)
```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 (
<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`](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 |
UIAssetPicker 下方一个 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<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`](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 (
<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 middleware10 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<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 草稿
库选用 [`idb`](https://github.com/jakearchibald/idb)5 KBPromise 化)。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<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 防抖自动保存
```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;不调 PATCHUI 顶部黄色 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<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 大场景"图层独显"模式
```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 fpsiOS 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 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_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<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 列表(不直接写库):
```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 已留 `<XR>` 挂载点)。客户端检测:
```typescript
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`](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 cmM1 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 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`](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 玩具"。