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