chore: initial commit — import worldmodel workspace (plans/, research/)

This commit is contained in:
gaojie
2026-05-20 21:43:57 +08:00
commit bec8a9a4a3
98 changed files with 44128 additions and 0 deletions
+884
View File
@@ -0,0 +1,884 @@
# CrowdRoom · Web 端设计(v0.2
> **版本**v0.22026-05-19
> **v0.2 修订**:回写 G-6(§1.1 路由表追加 `R-16 /me/embeds` 用户管理 iframe 嵌入配额与 Referer 白名单页)+ G-7(§1.1 路由表追加 `R-17 /admin/reports` 内部审核工作台,role=admin only)。源决策见 [`10_governance.md`](10_governance.md) §3.1reviewer 工具)与 §4 P-W-6iframe 限流落地)。
> 本章承接 [`00_overview.md`](00_overview.md) §4 架构图、[`01_data_schema.md`](01_data_schema.md) 的 9 张表与 `layer_manifest.json` Schema、[`02_api_contract.md`](02_api_contract.md) §2 的 19 个端点(v0.2,原 16 + 3 个新增 E-17/18/19)与 §8.2 的 6 条 Web 硬契约(Web-Y1 ~ Web-Y6)、以及 [`03_ios_app_plan.md`](03_ios_app_plan.md) §2.2 Flow B 的 Universal Link 入口,落地为一份**可直接交付给 Web 工程团队**的设计。
>
> **Web 端的使命**:把"扫房 → 上传"产出的 `canonical.glb + layer_manifest.json` 在浏览器里渲染成可玩、可分层、可换材质、可换家具、可 Remix、可分享的 3D 房间作品;这是 CrowdRoom 直接回应用户原始需求"**类 ArcGIS 分层 + 显示/隐藏 + 换材质 + 换其他家具**"的入口。
>
> 本章不重复隐私治理总章(由子任务 5 收口);本章亦不涉及 iOS 端 RoomPlan 采集、转码 Worker 实现(已在 03/02 中定义)。
---
## 1. Web 站点信息架构
### 1.1 路由表(Next.js App Router
CrowdRoom Web 选用 [Next.js 14 App Router](https://nextjs.org/docs/app),因为 SSR + OG 卡片 + SEO + Edge Runtime 都是消费级社区的硬需求。路由规划如下(≥10 条):
| # | 路由 | 渲染 | 主要数据 | 说明 |
|---|------|------|---------|------|
| R-01 | `/` | SSR + ISR (60s) | `E-06 GET rooms` 公开 feed 前 20 条 | 首页瀑布流,落地页 |
| R-02 | `/r/[room_id]` | SSROG meta 必须服务端拼) | `E-07 GET rooms + current_version` | 房间详情 3D 浏览器 |
| R-03 | `/r/[room_id]/edit?fork=1` | CSR only(编辑器太重) | `E-09` 父 manifest + 父 glb + 空 overlay | **Remix 编辑器**(核心) |
| R-04 | `/remix/[remix_id]` | SSR | `remixes` 行 + overlay JSON | 已发布的 remix 详情;引用父几何 |
| R-05 | `/remix/[remix_id]/edit` | CSR | 自己创建的 remix 才可进 | Remix 二次编辑(owner-only |
| R-06 | `/u/[username]` | SSR + ISR (120s) | `users` + 该用户的 rooms / remixes | 用户主页 |
| R-07 | `/search?q=...&tag=...&grade=...` | SSR | `E-08 search_rooms` RPC | 搜索结果页 |
| R-08 | `/assets?kind=material&class=wood` | SSR + ISR (300s) | `assets` 表过滤 | 公共资产库浏览(独立可逛) |
| R-09 | `/login` / `/signup` | CSRSupabase Auth | `auth.users` | 登录/注册;OAuth 回调 `/auth/callback` |
| R-10 | `/me` / `/me/rooms` / `/me/drafts` / `/me/quota` | CSR(需登录) | `E-15 quota`、个人 rooms | 个人控制台 |
| R-11 | `/notifications` | CSR + Realtime channel `user:{id}` | 评论 / 点赞 / Remix / 转码完成事件流 | 通知中心 |
| R-12 | `/embed/r/[room_id]` | CSR(极简 chrome | 同 R-02 | iframe 嵌入版(无导航条、无评论) |
| R-13 | `/about` / `/legal` / `/privacy` / `/terms` | SSG | 静态 MDX | 法务与说明,由子任务 5 写正文 |
| R-14 | `/api/og/r/[room_id]` | Edge FunctionVercel OG | manifest + viewState | **OG 卡片动态生成**,详见 §9 |
| R-15 | `/sitemap.xml` / `/robots.txt` | SSG | 公开 rooms 列表分页 | SEO 入口 |
| R-16 | `/me/embeds` | CSR(需登录) | `embed_settings` 表 + Referer 限流统计([`10_governance.md`](10_governance.md) §4 P-W-6 | **v0.2 / G-6**:用户管理「自家房间被 iframe 嵌入」的配额、Referer 白名单与黑名单;显示日访问量、可一键关闭嵌入或封禁某 Referer |
| R-17 | `/admin/reports` | CSR**role=admin** 才可进,否则 403 | `reports` 工单队列 + NSFW score + 敏感词命中 + 内容预览 | **v0.2 / G-7**:内部审核工作台(reviewer 用),对应 [`10_governance.md`](10_governance.md) §3.1 「兼职 Reviewer × 1」工具需求;MVP 用 Supabase Studio + 本路由组合 |
> 共 **17 条路由组**v0.2,原 15 + v0.2 / G-6 新增 R-16 + v0.2 / G-7 新增 R-17),覆盖 5 大场景:浏览(R-01/02/04/07/08)、创作(R-03/05/10)、社交(R-06/11)、基础设施(R-09/12/13/14/15)、**v0.2 治理 / 用户控制(R-16/17**。
> 🔄 **v0.2 — 回写自 G-6**R-16 `/me/embeds` 是 [`10_governance.md`](10_governance.md) §4 P-W-6「iframe 嵌入频次/速率限制」决策的用户侧落地点。页面内容:
>
> - **嵌入开关**(默认开 / 单房间粒度可关,关闭后 R-12 `/embed/r/{id}` 返回 403 + 业务码 `EMBED_FORBIDDEN`,详见 [`02_api_contract.md`](02_api_contract.md) §7
> - **Referer 列表**:所有曾经成功嵌入过自家房间的外部域名 + 日访问量 + 累计访问量;按访问量降序,最多展示前 100 条
> - **白名单登记**:用户可主动登记某个 Referer 域名进入「注册 Referer」档(30 req/min/Referer),未登记的走默认档(5 req/min/Referer),对应 P-W-6 表格的两档
> - **黑名单封禁**:点击某条 Referer → 「封禁此来源」→ 写入 `embed_blocked_referers` 表(service_role 维护)→ 该 Referer 立即收到 `EMBED_FORBIDDEN`
> - **配额展示**:当前账户档位(free 1 万 / creator 10 万 / pro 100 万 req/月)+ 已用 / 剩余 / 重置时间
> - **不计入 Storage 流量**:页面顶部固定文案「iframe 嵌入流量由平台兜底,不消耗你的 Storage 配额」(与 [`10_governance.md`](10_governance.md) §4 P-W-6「CDN 流量归属」条款对齐)
>
> 该路由在 §1.2 跳转图中归属 `/me/*` 子树,鉴权同 R-10。
>
> 🔄 **v0.2 — 回写自 G-7**R-17 `/admin/reports` 是 [`10_governance.md`](10_governance.md) §3.1「兼职 Reviewer × 1,每日 2 小时(约工单 30–50 条 / 日)」的工具承载页。页面内容:
>
> - **鉴权门控**:进入页面前 `middleware.ts` 读 `auth.users.app_metadata->>role` 判断是否 `'admin'`;非 admin 直接返回 403(不是 401,避免暴露路由存在)
> - **工单队列**:表格列 `report_id / target_type / target_id / reason / 自动信号(NSFW score、敏感词命中)/ 举报数累计 / SLA 剩余时间 / 状态`
> - **内容预览**:点击某行 → 右侧抽屉打开目标内容(房间 3D 预览 / 评论 / 用户档案)+ 既往违规记录
> - **判定按钮**`Approve(误报恢复)/ Reject(违规下架,选择处罚等级 L1-L4 或 WL 白名单越级)/ Defer(转 owner-team 终审)`,对应 [`10_governance.md`](10_governance.md) §6 处罚阶梯
> - **批量操作**:选中多行 → 批量 Approve / Reject(限同一 target_type
> - **审计落库**:每个判定写 `moderation_actions` 表(含 reviewer_id / 操作 / 时间 / 理由),用于 §8 申诉流程二次复核
> - **MVP 数据源**:直接 PostgREST 查 `reports` 表 + `comments / rooms / remixes` 关联;P1 起接 Hive Moderation 第三方审核分数
>
> 该路由**不**在 `/sitemap.xml` 也**不**在 `/robots.txt` 允许列表(默认 noindex,避免 SEO 误抓)。
### 1.2 核心页面跳转图(用户旅程)
```mermaid
graph LR
Home[Home /]
Search[Search /search]
Detail[RoomDetail /r/room_id]
RemixEdit[RemixEditor /r/room_id/edit fork=1]
RemixDetail[RemixDetail /remix/remix_id]
Assets[AssetLibrary /assets]
UserHome[UserHome /u/username]
Login[Login /login]
Me[Profile /me]
Embed[Embed /embed/r/room_id]
Home --> Detail
Home --> Search
Search --> Detail
Detail --> RemixEdit
Detail --> RemixDetail
Detail --> UserHome
Detail --> Embed
RemixEdit --> RemixDetail
RemixDetail --> RemixEdit
UserHome --> Detail
Home --> Assets
RemixEdit --> Assets
Login --> Me
Me --> Detail
```
> Remix 编辑器(`RemixEdit`)是整个 Web 的"重心页面"——所有"换材质/换家具/分层切换"的交互都在这里发生,对应用户原始需求。
---
## 2. 技术栈选型表
| 模块 | 推荐 | 备选 | 一行理由 |
|------|------|------|---------|
| Web 框架 | **Next.js 14 App Router + TypeScript** | Remix / SvelteKit | SSR + OG meta + Edge Function 一栈搞定;社区生态最厚 |
| 3D 渲染 | **Three.js r160+ · React-Three-Fiber v8 · `@react-three/drei`** | Babylon.js / PlayCanvas | R3F 让"图层切换/换家具"用 React 组件思维直接表达;与 Next.js SSR 兼容(动态 import + `ssr:false` |
| 模型加载 | **drei `useGLTF` + `KTX2Loader` + `MeshoptDecoder`** | three.js 原生 `GLTFLoader` | drei 内置缓存与 Suspense 集成;KTX2/Meshopt 都是 [`02_api_contract.md`](02_api_contract.md) §3.2 转码管线产物 |
| 状态管理 | **Zustand 4.x**(图层/相机/选中态/草稿) | Jotai / Redux Toolkit | Zustand 单 store + `subscribeWithSelector` 对 R3F 性能友好;不引入 Provider 树 |
| 服务端数据 | **`@supabase/ssr`**Server Component + Route Handler | `@supabase/auth-helpers-nextjs`(已废弃) | App Router 官方推荐;cookie 鉴权链路安全 |
| 浏览器数据 | **`@supabase/supabase-js` v2 + `@tanstack/react-query` v5** | SWR | React Query 的乐观更新 + 缓存失效控制对评论/点赞场景最合适 |
| 样式 | **Tailwind CSS v3 + shadcn/uiRadix UI 二次封装)** | CSS Modules / Stitches | shadcn 的 Dialog/DropdownMenu/Slider 直接拿来即用,A11y 已经做掉 |
| 图标 | **Lucide React** | Heroicons / Tabler | 与 shadcn 默认同款;树摇彻底 |
| 表单 | **React Hook Form + Zod** | Formik | Zod schema 可同时复用到 Edge Function 的入参校验 |
| 国际化 | **next-intl**(中英双语,`zh-CN` / `en-US` | next-i18next | App Router 友好;按路由段 `/[locale]/...` 切分 |
| 分析 | **PostHog Cloud**(自托管事件 + Session Replay 关闭以保护隐私) | Plausible | 不用 GA(合规风险 + 国内访问差);PostHog 提供 funnel 与 feature flag |
| 部署 | **Vercel**Edge Network + Image Optimization | Cloudflare Pages + Workers | Vercel 与 Next.js 14 集成最深;CN 访问后期可加 Cloudflare 镜像 |
| 错误监控 | **Sentry Browser SDK** | Datadog RUM | 与 Supabase 后端 / iOS 端共用一个 Sentry 项目,跨端联查 |
| 包管理 | **pnpm 8** + Turborepo(单仓多包) | npm / yarn | 与 iOS Worker 共享 schemas/ 包;pnpm 节省磁盘 |
| 测试 | **Vitest + Playwright** | Jest + Cypress | Vitest 与 Vite/Next 14 同栈;Playwright 跑 3D 截图 diff |
---
## 3. 3D 渲染架构
### 3.1 渲染层链路(从 URL 到画面)
> ✅ **契约 Web-Y1**:渲染入口**必须**先拉 `layer_manifest.json`,按 4 层固定 ID`walls / floor / furniture / materials`)切换可见性;**禁止**自己解析 .glb 节点树推断结构——见 [`02_api_contract.md`](02_api_contract.md) §8.2 Y1。
```mermaid
graph LR
URL[URL r room_id] --> Route[App Router]
Route --> Fetch1[CDN GET layer_manifest.json]
Route --> Fetch2[CDN GET canonical.glb]
Fetch1 --> Validate[Zod schema check 失败抛 MANIFEST_INVALID]
Validate --> BuildMap[构建 Map layerId nodeIds]
Fetch2 --> Cache[useGLTF 缓存]
BuildMap --> Scene[R3F Canvas Scene]
Cache --> Scene
Scene --> Groups[4 个 group layerRef]
Groups --> WallGroup[group walls]
Groups --> FloorGroup[group floor]
Groups --> FurnGroup[group furniture]
Groups --> MatSlots[material slots 注入到上述三层 mesh]
State[Zustand layerStore] --> Bind[group.visible 双向绑定]
Bind --> WallGroup
Bind --> FloorGroup
Bind --> FurnGroup
```
### 3.2 R3F 场景图组织原则
| 决策 | 拍板 | 理由 |
|------|------|------|
| 每层一个 `<group>` 而非用 mesh.visible 逐个 | **是** | 切层 = 1 次 React state 变更触发 1 次 group.visible 赋值;逐 mesh 切要 N 次,浪费 |
| 节点名约定 | **沿用 manifest 中 `mesh_node_ids[]`Worker 端已统一 `wall_* / floor_* / furn_*` 前缀** | Web 端通过 `scene.getObjectByName(nodeId)` O(1) 拿引用 |
| `<Suspense>` 边界 | **Canvas 内一层、AssetPicker 缩略图一层** | 渲染主场景与挑材质的网络等待互不阻塞 |
| 选中态高亮 | **额外注入 `<Outline>` (drei) post-processing,不修改 mesh material** | 防止"选中后退出忘了恢复"的副作用 |
| 物理 / 灯光 | **MVP 用 `<Environment preset="apartment">`,无物理引擎** | 真实光照成本不划算;apartment 预设对家居场景视觉够用 |
### 3.3 性能预算
| 设备档 | 帧率目标 | `.glb` 大小 | 三角形 | Draw Call | 纹理上限 |
|--------|---------|-------------|--------|-----------|---------|
| 桌面端(Chrome/Edge/Firefox | **≥ 60 fps** | ≤ 5 MB | ≤ 200 k | ≤ 30 | ≤ 16 张 1024² |
| 移动端 Safari iOS 16+ | **≥ 30 fps** | ≤ 2 MB | ≤ 80 k | ≤ 15 | ≤ 8 张 512² |
| 旧桌面(Intel 集显) | ≥ 30 fps | 同移动端预算 | 同上 | 同上 | 同上 |
**预算违反时的兜底**(在 `<Canvas>` 外部检测 `gpu.tier`,参考 `@react-three/drei``useDetectGPU`):
- Tier 1(低端)→ 自动启用 `dpr={[1, 1]}` + 关闭阴影 + Texture 自动降到 512²
- Tier 0(无 WebGL2)→ 退化到 `model-viewer` 静态预览组件(详情页给"3D 视图不可用"提示)
### 3.4 LOD 策略
**MVP 不做 LOD**(理由:单房间几何已经在 Worker 端走 Draco/Meshopt 压缩到 15 MB,移动端不掉帧;额外切多套 LOD 会让转码 Worker 跑得更慢)。
**何时引入**
- 单房间 `canonical.glb > 8 MB`(超过 creator 配额上限)
- 单页面同屏需展示 ≥ 2 个房间(如对比页 / 楼层拼接 P2)
- 移动端 90 分位首屏 TTI > 4 s
引入时方案:Worker 端额外产 `canonical_lod1.glb`30% 面数)与 `canonical_lod2.glb`10% 面数),manifest 增加 `lod_uris[]` 字段,Web 端按相机距离切换。
### 3.5 相机控制
| 场景 | 控制器 | 行为 |
|------|--------|------|
| 进入房间 | **OrbitControls + 自动 fit-to-bbox**(从 manifest `room_metrics` 反推 OBB | 1.2 s ease-out 缓动到房间斜上方 45° |
| 点击图层节点 | OrbitControls.target 飞到该 mesh OBB 中心 | 0.6 s 缓动 + 自动调整距离使物体撑满 60% 视口 |
| 全屏 / 嵌入 (`/embed`) | 同上,但移除右键面板 | 嵌入版给最简 UI |
| WebXRVR / AR | **MVP 不做**,预留 `<XR>` 组件挂载点 | drei `@react-three/xr` 已就绪 |
---
## 4. 类 ArcGIS 分层 UI 设计 🌟
> **本节直接回应用户原始需求"空间信息,类似 ArcGIS 的分层地图信息一样,选择显示、隐藏"。** 这是 Web 端最具辨识度的体验,必须做精。
### 4.1 图层面板(LayerPanel)整体布局
房间详情页的右侧抽屉(Desktop ≥ 1280 时常驻 320 px;移动端折叠为底部 sheet)展示**4 层固定结构**,与 [`01_data_schema.md`](01_data_schema.md) §5.1 的"4 层固定"约束严格对齐:
```
┌─────────────────────────────────┐
│ Layers [ ⊕ ] │ ← 顶部 "保存为视图" 按钮
├─────────────────────────────────┤
│ 👁 🔒 [████████░░] Walls ▾ │ ← 可见性 / 锁定 / 不透明度 / 展开
│ └─ wall_0 [缩略] 👁 │
│ └─ wall_1 [缩略] 👁 │
│ └─ wall_2 [缩略] 👁 │
├─────────────────────────────────┤
│ 👁 🔒 [████████░░] Floor ▸ │
├─────────────────────────────────┤
│ 👁 🔒 [██████░░░░] Furniture ▾ │
│ └─ 🛏 Bed [缩略] 👁 │ ← 家具层显示语义图标
│ └─ 🛋 Sofa [缩略] 👁 │
│ └─ 📺 TV [缩略] 👁 │
├─────────────────────────────────┤
│ 🎨 Materials ▾ │ ← 材质层独立形态(非几何)
│ └─ mat_wall_paint [色块] │
│ └─ mat_floor_wood [贴图] │
│ └─ mat_bed_fabric [色块] │
└─────────────────────────────────┘
```
### 4.2 每层提供的操作
| 操作 | 图标 | 行为 | 状态键(Zustand |
|------|------|------|------------------|
| **可见性 toggle** | 👁 / 🚫 | 整层 `group.visible` 切换 → 直接对应"显示/隐藏"用户需求 | `layers[kind].visible: bool` |
| **锁定** | 🔒 / 🔓 | 编辑模式下防止误操作;锁定后该层节点不响应点击/拖拽 | `layers[kind].locked: bool` |
| **不透明度滑块** | 0100 | 整层 `material.transparent = true; .opacity = v/100` | `layers[kind].opacity: 0..1` |
| **展开 / 折叠** | ▾ / ▸ | 展开后列出该层 mesh 节点(家具显示语义标签 + 缩略图) | UI 局部 state |
| **单节点 toggle** | 👁 | 仅隐藏某一个 mesh(家具层尤其常用:藏掉电视看墙) | `layers.furniture.hiddenItems: Set<itemId>` |
| **保存为视图** | ⊕ | 把当前 4 层可见性 + 相机状态打包成 "Named View",可分享/收藏 | 写入 `viewState`(见 §9 |
### 4.3 交互细节
| 触发 | 反馈 |
|------|------|
| 鼠标 hover 节点行 | 3D 场景中对应 mesh 加 `<Outline>` 高亮(淡黄色)+ 浮层显示尺寸/语义 |
| 点击节点行 | 相机飞到该 mesh + 在 3D 场景中长亮(橙色)+ 右侧弹出"材质/家具替换"面板(§5/§6) |
| 双击层标题 | 仅显示该层(其它 3 层临时隐藏),再双击恢复 |
| 拖拽不透明度滑块 | 实时(每帧)应用;松手时写入 store 触发自动保存 |
| 右键节点行 | 上下文菜单:复制 nodeId / 在新标签打开材质资产 / 报错(送 Sentry breadcrumb |
### 4.4 "图层组合"功能(Named Views
致敬 ArcGIS 的"图层组"概念,CrowdRoom 把"4 层可见性 + 相机 + 已应用的 overlay"打包成一个可分享、可命名的视图:
| 字段 | 例 |
|------|-----|
| `name` | "白模视角"、"只看家具"、"完成版" |
| `layer_toggles` | `{walls:true, floor:true, furniture:false, materials:true}` |
| `camera` | `{position:[2.4,1.6,3.0], look_at:[0,0.8,0], fov_deg:55}` |
| `overlay_id?` | 关联到某个 remix(可选) |
实现走 [`02_api_contract.md`](02_api_contract.md) E-10 `/functions/v1/view-state`token 在 URL 上:`/r/{room_id}?vs={token}`
> ✅ **契约 Web-Y4(原契约)**:解码 `vs=` 时**必须**对未知字段宽容(前向兼容),schema 升级时旧 token 不应失效。
>
> ✅ **契约 Web-Y4(本子任务新增声明)**viewState token 必须**确定性解码**gzip+base64url,无服务器随机种子),以便 `/api/og/r/[room_id]?vs=...` 的 headless Chromium 在 SSR 时**像素级复现**同一画面用于 OG 卡片;详见 §9。
### 4.5 LayerPanel React 组件骨架
```tsx
// components/layer-panel/LayerPanel.tsx
"use client";
import { useLayerStore } from "@/stores/layer-store";
import { Eye, EyeOff, Lock, Unlock, ChevronDown, ChevronRight } from "lucide-react";
import { Slider } from "@/components/ui/slider";
import type { LayerKind } from "@/types/manifest";
const LAYERS: LayerKind[] = ["walls", "floor", "furniture", "materials"];
export function LayerPanel() {
const layers = useLayerStore((s) => s.layers);
const toggle = useLayerStore((s) => s.toggleLayerVisible);
const lock = useLayerStore((s) => s.toggleLayerLocked);
const setOpacity = useLayerStore((s) => s.setLayerOpacity);
const expand = useLayerStore((s) => s.toggleExpanded);
return (
<aside className="w-80 border-l h-full overflow-y-auto bg-background">
<header className="p-3 flex items-center justify-between border-b">
<h2 className="text-sm font-semibold">Layers</h2>
<SaveViewButton />
</header>
<ul>
{LAYERS.map((kind) => {
const L = layers[kind];
const Expand = L.expanded ? ChevronDown : ChevronRight;
return (
<li key={kind} className="border-b">
<div className="flex items-center gap-2 p-2">
<button onClick={() => toggle(kind)} aria-label={`Toggle ${kind} visibility`}>
{L.visible ? <Eye size={16} /> : <EyeOff size={16} className="opacity-40" />}
</button>
<button onClick={() => lock(kind)} aria-label={`Lock ${kind}`}>
{L.locked ? <Lock size={16} /> : <Unlock size={16} className="opacity-40" />}
</button>
<Slider
className="flex-1"
value={[L.opacity * 100]}
onValueChange={([v]) => setOpacity(kind, v / 100)}
min={0} max={100} step={1}
aria-label={`${kind} opacity`}
/>
<button onClick={() => expand(kind)} aria-label={`Expand ${kind}`}>
<Expand size={16} />
</button>
</div>
{L.expanded && <LayerNodes kind={kind} nodes={L.nodes} />}
</li>
);
})}
</ul>
</aside>
);
}
```
`LayerNodes` 子组件渲染每个 mesh 的小行(含语义图标 + 缩略图 + 单节点 👁),代码同构。整个面板约 60 行 TSX 加上 `Slider` / `SaveViewButton` 共 ~120 行——shadcn/ui 已经把 A11y 做好,键盘 Tab/Space 即可操作所有按钮(呼应 §10)。
---
## 5. 材质替换 UX 🌟
> **本节直接回应用户原始需求"更换材质"。** 材质替换不改几何,是最轻量的 Remix 形态,必须做得"所见即所得"。
>
> ✅ **契约 Web-Y2**Remix 必须**浏览器内实时合成**(父 glb + 父 manifest + overlay),不请求服务端预合成。材质替换天然只改 `material.map`/`material.color`,完全可在 Web 端用 Three.js 一次性替换 PBR 槽位即可——这是 Web-Y2 落地的最佳证据。
### 5.1 进入材质替换的入口
| 入口 | 行为 |
|------|------|
| 在 3D 场景中点击墙/地/家具 mesh | 右侧抽屉切到 **"Material" tab**,展示该 mesh 当前 PBR 槽位(base_color / normal / roughness / metallic / AO |
| 在图层面板 Materials 层点击某个 `slot_id` | 同上 |
| 命令面板(`Cmd+K`)输入 "material" | 列出所有 slot,键盘选择 |
### 5.2 材质槽位面板(MaterialSlotPanel
```
┌─────────────────────────────────────┐
│ Material · floor_0 ▾ │
├─────────────────────────────────────┤
│ Current: │
│ [base_color preview] Oak Natural │
│ [normal preview] │
│ roughness ▓▓▓▓▓▓░░░░ 0.62 │
│ metallic ░░░░░░░░░░ 0.00 │
├─────────────────────────────────────┤
│ Replace with: │
│ [Wood] [Tile] [Fabric] [Metal] │
│ [Paint] [Wallpaper] [Favorites] │
│ ┌────┬────┬────┬────┐ │
│ │ 🪵 │ 🪵 │ 🪵 │ 🪵 │ ← 资产网格 │
│ └────┴────┴────┴────┘ │
│ [Load more] │
└─────────────────────────────────────┘
```
### 5.3 实时预览(不重载 .glb
替换流程**全部在浏览器内**完成,无网络往返(除资产纹理 GET):
```ts
// 伪代码:从 assets 表挑选新材质后
const newAsset = await fetchAsset(assetId); // GET /rest/v1/assets?id=eq.{id}
const tex = await ktx2Loader.loadAsync(newAsset.pbr.base_color_tex);
const targetMesh = scene.getObjectByName(slot.target_mesh_id) as Mesh;
const mat = targetMesh.material as MeshStandardMaterial;
mat.map = tex;
mat.roughness = newAsset.pbr.roughness;
mat.metallic = newAsset.pbr.metallic;
mat.needsUpdate = true;
// 写入 overlay 草稿(防抖 3 s 自动保存,见 §7)
overlayDraft.push({
op: "replace_material",
target_slot_id: slot.slot_id,
asset_id: assetId,
pbr_override: { ...newAsset.pbr },
});
```
### 5.4 资产库面板(AssetPickerMaterial
| 元素 | 设计 |
|------|------|
| 分类 Tab | 木材 / 瓷砖 / 布料 / 金属 / 油漆 / 壁纸 / 收藏夹(按 `assets.tags` 过滤;与 [`01_data_schema.md`](01_data_schema.md) §3.9 `assets.kind='material'` 对齐) |
| 网格视图 | 默认 4 列,每格 96×96,悬浮显示名称 + 来源 + 协议(CC0/CC-BY |
| 收藏夹 | 浏览器 `localStorage``favorite_asset_ids[]`;登录后写入 `users.favorites` 表(P2 加表) |
| 搜索 | 在分类内全文 `name + tags`;走 PostgREST `assets?or=(name.ilike.*q*,tags.cs.{q})` |
| 拖拽 | 支持把缩略图直接拖到 3D 场景中目标 mesh 上(HTML5 drag + raycaster 命中检测) |
| 协议筛选 | 顶部固定开关"仅显示 CC0"——MVP 默认开启,规避版权 |
### 5.5 保存为 Remixmaterial_overrides
材质替换写入 `remix_overlay.json``ops[]` 中([`02_api_contract.md`](02_api_contract.md) §4.2 已定义 `op: replace_material`):
```json
{
"op": "replace_material",
"target_slot_id": "mat_floor_wood",
"asset_id": "a8e92...",
"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
}
}
```
**校验**Edge Function 在 `remix-publish` 时跑):
- `target_slot_id` 必须在父 manifest `materials.slots[]` 中存在且 `replaceable=true`
- `asset_id` 必须存在于 `assets` 表(`E-11` 失败时抛 `ASSET_NOT_FOUND`
- 失败 → 客户端回滚最近一次操作并 Toast 提示
### 5.6 单墙改色快捷操作(`set_wall_color`
不想拖整套贴图、只想试色时,提供"色环 picker"快捷入口:
- 点击墙 mesh → MaterialSlotPanel 顶部多一个 "Quick Color" 区
- 用 react-colorful 选色 → 直接 `material.color.set(hex)`
- 写入 overlay 的 `op: set_wall_color`[`02_api_contract.md`](02_api_contract.md) §4.3 已定义)
---
## 6. 家具替换 UX 🌟
> **本节直接回应用户原始需求"更换其他家具"。** 家具替换比材质替换复杂——要换几何 + 要对齐位置/朝向,对齐方案直接利用 [`01_data_schema.md`](01_data_schema.md) §5.2 中家具层强制保留的 `obb` 与 `anchor_point` 字段。
>
> ✅ **契约 Web-Y2 落地**:家具替换同样在浏览器内合成——隐藏父几何的 furniture 节点 + GLTFLoader 加载新 asset .glb,无需服务端介入。
### 6.1 进入家具替换的入口
| 入口 | 行为 |
|------|------|
| 在 3D 场景中点击家具 mesh | 右侧抽屉切到 **"Furniture" tab** + 自动按家具的 `semantic_class` 过滤资产库 |
| 在图层面板 Furniture 层点击某个 `item_id` | 同上 |
| 在资产库 `/assets?kind=furniture` 浏览时点 "Try in a room" | 进入 Remix 编辑器并默认锁定该 asset 为下一次点击的替换目标 |
### 6.2 替换面板(FurnitureSwapPanel
```
┌─────────────────────────────────────┐
│ Replace · bed_001 (semantic: bed) │
├─────────────────────────────────────┤
│ Original: │
│ OBB extent 2.00 × 0.60 × 1.50 │
│ anchor_point [-0.5, 0.0, -1.4] │
├─────────────────────────────────────┤
│ Suggested ("bed" assets): │
│ ┌────┬────┬────┬────┐ │
│ │ 🛏 │ 🛏 │ 🛏 │ 🛏 │ │
│ └────┴────┴────┴────┘ │
│ ☑ Snap to anchor (auto-align) │
├─────────────────────────────────────┤
│ Fine-tune (after select): │
│ X ▓░░░ +0.00 m │
│ Y ░░░░ +0.00 m │
│ Z ░░░░ +0.00 m │
│ Rot Y ⟳ 0° │
│ Scale ▓▓░░ 1.00x │
└─────────────────────────────────────┘
```
### 6.3 自动对齐(OBB-based
新家具的 anchor 与原家具 OBB 对齐遵循以下规则:
| 步骤 | 公式 / 行为 |
|------|------------|
| 1. 拉取新 asset 的 `anchor_point`(资产侧元数据,运营录入) | `assets.pbr.anchor_point` 或默认底面中心 `[0, -extent_y/2, 0]` |
| 2. 计算变换矩阵 `T` | `T = translate(original.anchor_point) · quat(original.obb.quat) · translate(-new_asset.anchor_point)` |
| 3. 应用到新 asset 的 Group | `assetGroup.matrix.copy(T); assetGroup.matrixAutoUpdate = false;` |
| 4. 若资产 OBB extent 与原 extent 比超过 1.5× | 给警告 toast "新家具明显大于原家具,可能溢出墙面" |
| 5. 隐藏原 furniture 节点 | `scene.getObjectByName(item.mesh_node_ids[0]).visible = false` |
> **关键设计动机回顾**[`01_data_schema.md`](01_data_schema.md) §5.2 让 `furnitureItem` 强制包含 `obb` 与 `anchor_point` 两个字段,正是为了让 Web 端能"零网络往返"实现自动对齐——这里是该设计的直接消费方。
### 6.4 手动微调
自动对齐之后,用户仍可在 X/Y/Z 平移 + Y 轴旋转 + 等比缩放四个自由度上微调(不开放任意 6DoF 自由度,避免家具"飘起来"或"贴墙穿模"):
| 自由度 | 范围 | UI |
|--------|------|------|
| 平移 X/Y/Z | ±0.5 m | 三个滑块 |
| 旋转 Y | 0360° | 圆形旋钮 |
| 缩放 | 0.7×–1.3× | 滑块;等比,禁止非等比避免视觉怪异 |
3D 场景中同时显示 Three.js `<TransformControls>`gizmo),与滑块双向绑定。
### 6.5 隐藏原家具 vs 删除原家具
> ✅ 关键决策:**永远是"隐藏 + 叠加新 asset",不删除原几何**。
理由:
- **Remix 可回退**:用户取消替换 → 让原 furniture 节点 `visible=true` 即可,无需重新下载 `canonical.glb`
- **存储零成本**overlay 只是 5 行 JSON,不复制几何
- **审计可追**:父房间作者能在自己的 dashboard 看到"我的房间被 N 个 remix 替换了 bed 这件家具"
- **不破坏 OBB 校验**:保留原节点意味着 manifest 始终自洽,未来若加"对比模式"(原版 vs Remix 并排)零改造
写入 overlay
```json
{
"op": "replace_furniture",
"target_item_id": "bed_001",
"asset_id": "a91c2...",
"asset_glb_uri": "assets/furniture/modern_bed_oak.glb",
"transform": {
"translate": [0.02, 0, -0.05],
"rotate_quat": [0.999, 0, 0.044, 0],
"scale": [1, 1, 1]
},
"snap_to_anchor": true
}
```
### 6.6 整体隐藏(`hide_layer`
如果用户想"看清房屋骨架",可在图层面板 furniture 层点 👁,写入 `op: hide_layer, layer_kind: furniture` 即可。这是"显示/隐藏"用户需求在批量场景下的快捷形态。
---
## 7. Remix 完整流程(Web-Y2 落地)
### 7.1 端到端序列图
> ✅ **契约 Web-Y2**:Remix 编辑器在浏览器内实时合成 = 父 .glb + 父 manifest + 本地 overlay 草稿,三者都不经过服务端预合成。
```mermaid
sequenceDiagram
autonumber
participant U as 用户
participant Web as Web Client
participant CDN as CDN
participant Edge as Edge Function
participant DB as Postgres
participant Shot as Headless Screenshot Worker
U->>Web: 在 /r/room_id 点 Remix
Web->>Edge: POST functions v1 remix-create parent_version_id title 空 overlay
Edge->>DB: insert remixes overlay 空 author_id auth.uid
Edge-->>Web: remix_id overlay_path
Web->>U: 跳转 /r/room_id/edit fork 1 携带 remix_id
Web->>CDN: GET canonical.glb 父版本
Web->>CDN: GET layer_manifest.json 父版本
CDN-->>Web: 两份文件 useGLTF 缓存命中即复用
loop 编辑会话
U->>Web: 切层 换材质 换家具 调相机
Web->>Web: 修改 overlayDraft Zustand
Web->>Web: 即时应用到 Three.js 场景
Note over Web: 防抖 3 秒
Web->>Edge: PATCH functions v1 remix-update overlay
Edge->>DB: update remixes overlay updated_at
Edge-->>Web: 200 ok last_saved
end
U->>Web: 点击 Publish
Web->>Edge: POST functions v1 remix-publish remix_id viewState
Edge->>DB: update remixes is_public true
Edge->>Shot: enqueue thumbnail job remix_id viewState
Shot->>CDN: GET canonical.glb 父
Shot->>Shot: headless Chromium 渲染 重放 viewState
Shot->>CDN: PUT thumbnail.webp
Edge-->>Web: published thumbnail_path
Web->>U: 跳转 /remix/remix_id 展示发布版
```
### 7.2 自动保存与冲突解决
| 场景 | 策略 |
|------|------|
| 单用户单设备编辑 | 防抖 3 s + 失焦时立即保存 + 关闭页签前 `beforeunload` 拦截 + 浏览器 `localStorage` 双重备份 |
| 同一用户多设备 | 后开的标签拿到更新的 `updated_at` 时,给"该 Remix 已在另一处被编辑"提示,让用户选"覆盖本地" / "丢弃本地" |
| 离线编辑 | overlay 草稿写 IndexedDB(用 `idb` 库),重新联网时尝试 PATCH;若返回 `REMIX_PARENT_DELETED` → 走下面的兜底 |
| 父房间被原作者硬删 | 抓 `REMIX_PARENT_DELETED`[`02_api_contract.md`](02_api_contract.md) §7.1 已定义)→ 弹窗:"父房间已被作者删除。你的修改可保存为独立副本(自动 fork 上一个已知 ready 的父快照)";提供"保存为独立副本"与"丢弃"两个按钮 |
| 父版本未 ready | 抓 `REMIX_PARENT_NOT_READY` → 跳回 `/r/{parent_room_id}` 并显示转码进度 |
| Overlay schema 升级 | overlay 顶部带 `schema_version`;旧客户端遇到新版字段时,对未知 `op` 跳过并 warning(前向兼容) |
### 7.3 操作历史与撤销
| 元素 | 设计 |
|------|------|
| 撤销栈 | Zustand `temporal` middleware;最多 50 步 |
| 快捷键 | `Cmd/Ctrl+Z` 撤销、`Cmd/Ctrl+Shift+Z` 重做 |
| 历史面板 | 顶部"History"抽屉,列出 op 类型 + 时间戳;点任一行回到该状态 |
| 草稿版本 | 每次自动保存视为一个"快照";用户可在历史面板 fork 出某个早期快照为新 remix |
### 7.4 错误码与 UI 映射
| 业务码(来自 [`02_api_contract.md`](02_api_contract.md) §7) | 用户文案 | 行为 |
|---|---|---|
| `REMIX_PARENT_DELETED` | "原房间已被删除,是否保存为独立副本?" | 双按钮选择 |
| `REMIX_PARENT_NOT_READY` | "原房间正在处理中,请稍后再来 Remix" | 跳父详情显示进度 |
| `OVERLAY_INVALID` | "本次操作未通过校验:{detail}" | 回滚最后 1 op + 上报 Sentry |
| `ASSET_NOT_FOUND` | "该资产已下架,请选择其他材质/家具" | 资产卡片置灰 + 移出收藏 |
| `QUOTA_EXCEEDED` | "本月 Remix 配额已用完,下月 1 号重置" | 跳 `/me/quota` |
| `RATE_LIMITED` | "操作太快了,请稍后再试" | 30 s 倒计时 |
---
## 8. 浏览 / 搜索 / 发现 UX
### 8.1 首页瀑布流(`/`
致敬 Pinterest,但偏 3D 场景的"展柜感"
| 元素 | 设计 |
|------|------|
| 列数 | 桌面 4 列、平板 3 列、移动 2 列;用 CSS `column-count` + `break-inside: avoid` |
| 卡片宽高比 | 16:9(与缩略图 1280×720 对齐) |
| 卡片元素 | ① 缩略图(hover 时切到 `preview.mp4` 自动播放 5 s)② 标题 ③ 作者头像 + handle ④ ❤ 数 ⑤ 🔄 Remix 数 ⑥ 标签 chips(最多 3 个) |
| 列表分页 | 无限滚动 + `IntersectionObserver`;每页 20React Query infinite query |
| 排序切换 | 顶部 "Latest / Trending / Most Remixed / Following"Trending 走 `like_count / age^1.5` 衰减公式 |
| 筛选 | 顶部 Pill:户型 / 风格 / 城市 / 质量分(与 iOS 端 `quality_grade` 联动) |
| 空状态 | "还没有公开作品" + 引导上传按钮(仅登录用户可见) |
### 8.2 房间详情页(`/r/[room_id]`)布局
```
┌──────────────────────────────────────────────────────────┐
│ ← Home / Rooms / "我的客厅" ❤ 23 🔄 5 ⋯ │ ← 顶栏 + 操作
├──────────────────────────────────────────────────────────┤
│ │ Layers │
│ ┌──────────────────┐ │ 👁 Walls │
│ │ │ │ 👁 Floor │
│ │ 3D Canvas │ │ 👁 Furniture │
│ │ │ │ 🎨 Materials │
│ └──────────────────┘ │ │
│ │ [Save view] │
│ by @alice · 2 days ago · 阳台、北欧风 │ │
├──────────────────────────────────────────────────────────┤
│ Description ... │
│ Tags: 北欧 · 客厅 · 18m² │
├──────────────────────────────────────────────────────────┤
│ Comments (12) │
│ └─ @bob: 好看! │
│ └─ @carol: 那个沙发是哪里买的? │
└──────────────────────────────────────────────────────────┘
```
### 8.3 顶栏操作
| 按钮 | 行为 | 走的 API |
|------|------|--------|
| ❤ 点赞 | toggle + 数字本地 +1/-1 + 防抖 600 ms | `E-12 like-toggle`Web-Y3 |
| 🔄 Remix | 走 §7.1 序列图第 1 步 | `E-11 remix-create` |
| 📤 分享 | 弹分享面板(§8.4) | `E-10 view-state` |
| ⚠ 举报 | 举报弹层(reason 选项) | `E-14 report` |
| ⋯ 更多 | 嵌入代码 / 在新标签打开 / 复制 nodeId(开发者用) | 客户端 |
> ✅ **契约 Web-Y3**:点赞**必须**走 `/functions/v1/like-toggle`(幂等 + 防刷 5/s),禁止直接 INSERT/DELETE `likes` 表——见 [`02_api_contract.md`](02_api_contract.md) §8.2 Y3。本节顶栏 ❤ 按钮的实现严格走 E-12,且乐观更新 UI(先 +1,请求失败回滚)。
### 8.4 分享面板(SharePanel
| 元素 | 行为 |
|------|------|
| 复制链接 | `/r/{room_id}?vs={current_viewState_token}`,含当前图层组合 |
| 微博 / Twitter | 直链跳官方分享 endpoint,预填标题 + 链接 |
| 微信扫码 | 用 `qrcode` 库前端生成 PNG 二维码,提示"用微信扫一扫"(解决 iOS Safari → 微信深链难题) |
| 嵌入代码 | `<iframe src="https://crowdroom.app/embed/r/{room_id}?vs={token}" width="640" height="360" frameborder="0"></iframe>` 一键复制 |
| 下载缩略图 | 直接 GET `thumbnail@2x.webp`CDN 公开 URL |
### 8.5 评论区
| 元素 | 设计 |
|------|------|
| 列表 | 平铺时间序(最新在上),每条含头像/handle/正文/时间/回复按钮 |
| 回复 | 一级回复(`comments.reply_to`),不做多级嵌套(控制 UI 复杂度) |
| 提交 | 走 PostgREST `POST /rest/v1/comments`RLS 校验作者 = `auth.uid()`);乐观更新 |
| 长度限制 | 1000 字符上限,剩余字数实时提示;超出按钮置灰 |
| 删除 | 评论作者或房间 owner 可删(与 [`01_data_schema.md`](01_data_schema.md) §3.7 RLS 一致) |
> ✅ **契约 Web-Y6**[`02_api_contract.md`](02_api_contract.md) §8.2 已定义):评论提交后**乐观更新** UI,失败时回滚——`RLS_DENIED` 表示用户已被该房间作者拉黑,提示"暂无评论权限"。
### 8.6 资产库独立浏览页(`/assets`
为不想 Remix、只想"逛素材"的用户提供一个独立入口:
| 元素 | 设计 |
|------|------|
| 顶部 Tab | Furniture / Material |
| 侧栏筛选 | semantic_class(家具)/ tags(材质)/ licenseCC0 only 默认) |
| 卡片 | 缩略图 + 名称 + 协议 + 来源 + "Try in a room"(跳 Remix |
| 详情弹窗 | 3D 单品预览(`<model-viewer>` 即可,不上 R3F+ 元数据 |
---
## 9. SSR / SEO / OG 卡片(Web-Y4 落地)
> ✅ **契约 Web-Y4(强化版)**viewState `?vs=` token 必须**确定性解码**且**可被 SSR OG 截图服务复现**——即 `/api/og/r/[room_id]?vs={token}` 用 headless Chromium 渲染时,产出的 OG 图与用户当前浏览器画面像素级一致。这是 Web-Y4 在本子任务里的最终形态:前向兼容(原契约)+ SSR 可复现(本子任务新增)两件事一起绑死。
### 9.1 SSR meta 标签结构
每个 `/r/[room_id]` SSR 时输出:
```html
<meta property="og:title" content="我的客厅 by @alice · CrowdRoom" />
<meta property="og:description" content="北欧风 · 18.4 m² · 6 件家具 · 23 ❤" />
<meta property="og:image" content="https://crowdroom.app/api/og/r/{room_id}?vs={token}" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://crowdroom.app/r/{room_id}?vs={token}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://crowdroom.app/api/og/r/{room_id}?vs={token}" />
<link rel="canonical" href="https://crowdroom.app/r/{room_id}" />
```
### 9.2 OG 卡片动态生成 `/api/og/r/[room_id]`
| 阶段 | 行为 |
|------|------|
| 解析 query | 解码 `?vs={token}`gzip+base64url)得到 `{layer_toggles, camera, overlay_id?}` |
| 拉取数据 | CDN GET 父 `canonical.glb` + 父 `layer_manifest.json` + 可选 overlay |
| 渲染 | Vercel Edge Function 内 headless Chromium`@vercel/og` + `three.js` worker)以 1200×630 渲染一帧 |
| 缓存 | `Cache-Control: public, s-maxage=86400, stale-while-revalidate=604800`;缓存键 = `room_id + version_no + viewState_hash` |
| 失败兜底 | 渲染超时 → 返回 `thumbnail@2x.webp` 静态缩略图(仍是合格 OG 图) |
> **为什么把 OG 生成放在 Edge 而不是转码 Worker**:转码 Worker 一次只跑一份 `canonical thumbnail`,无法为每个 viewState 变体单独生成;Edge 按需 + 长缓存最划算。MVP 可暂时只生成"默认视图"的 OG 图(即忽略 `?vs=` 时直接返回 `thumbnail@2x.webp`),把动态 OG 列入 P1。
### 9.3 viewState token 编码契约(确定性)
| 字段 | 序列化规则 |
|------|--------|
| 排列顺序 | 固定 alphabetical`camera` < `layer_toggles` < `overlay_id`),保证哈希稳定 |
| 浮点精度 | 相机位置/look_at 保留 3 位小数;fov 保留 1 位 |
| 编码 | `gzip``base64url`(无 padding |
| 版本 | 头部 1 字节 magic `0x01` 标识 schema_version |
| 解码宽容 | 未知字段忽略 + warningWeb-Y4 原契约) |
> 该编码契约同步给 `view-state` Edge Function[`02_api_contract.md`](02_api_contract.md) E-10)——服务端与客户端使用**同一份** TypeScript 库(`packages/viewstate-codec`Turborepo 共享包),保证编码一致。
### 9.4 robots.txt 与 sitemap
| 资源 | 规则 |
|------|------|
| `robots.txt` | 允许爬 `/``/r/*``/u/*``/assets``/about`;禁爬 `/r/*/edit``/me/*``/notifications` |
| `sitemap.xml` | 每天定时(pg_cron)生成;含所有 `visibility='public'` 的 rooms 与 remixes |
| Hreflang | `<link rel="alternate" hreflang="zh-CN"...>``hreflang="en-US"` 配对 |
| 结构化数据 | 房间详情页输出 schema.org `3DModel` JSON-LD(提升 Google rich result |
### 9.5 CDN 与 JWT 严格分离
> ✅ **契约 Web-Y5**:缩略图与 `.glb`/`.json` 一律走 CDN 公开 URL`/storage/v1/object/public/...`),**不要**发起带 JWT 的请求——见 [`02_api_contract.md`](02_api_contract.md) §8.2 Y5。
实现要点:
- Supabase JS Client 拉公开资源时使用 `getPublicUrl(path)`**禁止**走 `download(path)`(后者会带 JWT 命中私有 bucket 路径)
- 公开 CDN URL 写入 manifest 的 `glb_uri` 时已是相对路径,前端拼前缀 `process.env.NEXT_PUBLIC_CDN_BASE` 即可
- Sentry breadcrumb 监控所有对 `/storage/v1/` 的请求,若 header 带 `Authorization` 直接抛 dev 警告
---
## 10. 性能与可访问性
### 10.1 性能优化
| 项 | 措施 |
|----|------|
| Code splitting | Three.js + R3F 在 `/r/*` 路由动态 `import('@react-three/fiber')`;首页 bundle ≤ 180 KB gzip |
| 图片 | Next/Image + AVIF/WebP 自动协商;缩略图主图 priority + lazy 同屏 |
| 字体 | next/font 本地内联,subset 仅含中英常用字符 |
| 关键资源 preload | `<link rel="preload" as="fetch" href="...layer_manifest.json">`(详情页 SSR 时输出) |
| 路由预取 | `<Link prefetch>` 默认开(卡片 hover 预拉 manifest |
| Service Worker | MVP 不上 PWAP1 可加(offline 缓存 manifest + 资产) |
| Web Vitals 目标 | 首页 LCP < 2.5 s / 详情页 LCP < 3 s(不含 3D 渲染)/ INP < 200 ms |
| 监控 | Vercel Analytics + PostHog 自定义事件 `3d_first_frame_ms` |
### 10.2 可访问性(A11y
| 项 | 措施 |
|----|------|
| 键盘导航 | 所有按钮 / 滑块 / 列表 Tab 可达;Esc 取消当前选中 mesh / 关闭面板 |
| 3D Canvas 不可达 fallback | 提供"图层 + 房间元数据"的纯 HTML 视图(`<details>` 列出 manifest 内容),ARIA `role="img" aria-label="3D 房间预览"` |
| 颜色对比 | WCAG AA 级;图层面板的"已隐藏"状态用 0.4 opacity + 图标双重提示,不只靠颜色 |
| 屏幕阅读 | LayerPanel 每行有 `aria-label="Walls layer, visible, opacity 80%"` |
| 动效 | 尊重 `prefers-reduced-motion`,关闭相机飞行缓动 |
| 国际化 | `zh-CN` / `en-US` 双语,路由前缀 `/zh` / `/en`(默认 `/zh` |
### 10.3 浏览器兼容矩阵
| 浏览器 | 最低版本 | 3D 体验 | 备注 |
|--------|---------|---------|------|
| Chrome / Edge | 110+ | 全功能 | 主目标 |
| Firefox | 110+ | 全功能 | KTX2 需要启用 WebGL2(默认开) |
| Safari (macOS) | 16+ | 全功能 | 关掉阴影避免卡顿 |
| Safari (iOS) | 16+ | 30fps 限速 | 同 §3.3 移动端预算 |
| 微信内嵌 X5 | 部分 | iOS 走 WKWebView 同 SafariAndroid X5 fallback 到 `<model-viewer>` | 详情页 banner 提示"用浏览器打开体验更佳" |
---
## 11. MVP 范围与不做项
### 11.1 MVP8 周内交付,与 [`00_overview.md`](00_overview.md) §7.1 对齐)
| 模块 | 交付物 |
|------|--------|
| 路由骨架 | R-01 / R-02 / R-03 / R-04 / R-06 / R-07 / R-09 / R-10 / R-13 / R-15 共 10 条 |
| 3D 渲染 | manifest 4 层 group + OrbitControls + fit-to-bbox + Outline 高亮 |
| 图层面板 | §4 全部交付(4 层 + 显示/隐藏 + 锁定 + 不透明度 + 单节点 toggle + Save View |
| 材质替换 | §5 全部(资产库筛选 + 实时预览 + 写入 overlay + 单墙改色) |
| 家具替换 | §6 全部(同语义筛选 + OBB 自动对齐 + 手动微调 + 隐藏原节点) |
| Remix 编辑 | §7.1 全流程 + §7.2 单设备自动保存 + §7.4 6 条错误码兜底 |
| 浏览 / 搜索 | §8.1 / §8.2 / §8.3 / §8.5(评论、点赞)+ §8.4 复制链接与嵌入代码 |
| 登录 | Supabase Auth:邮箱 + Sign in with Apple + Sign in with Google |
| 个人主页 | `/u/[handle]` + `/me`(含配额展示 §E-15 |
| SEO 基本盘 | SSR + 静态 OG(用 `thumbnail@2x.webp`+ sitemap + robots |
| i18n | 中文/英文双语切换 |
| 监控 | Sentry + PostHog event funnel |
### 11.2 明确不做(MVP 外)
-**实时多人协同编辑**Remix 走 fork,与 iOS Flow B 决策一致)
-**VR / AR 模式**drei `@react-three/xr` 留口子,P2 再上)
-**AI 自动配色 / 风格推荐**(成本与争议都大)
-**电商导流 / 家具购买链接**(合规审查负担,P2 才考虑)
-**付费贴图市场 / 创作者分成**(社区先长内容再谈商业化)
-**动态 OG 卡片**(按 viewState 像素级复现的 OG 列入 P1,MVP 用静态缩略图)
-**PWA / 离线模式**
-**3D 编辑器加新几何**(如新增装饰墙、新增门窗)——MVP 仅允许"换"与"隐藏",不允许"加几何"
-**多人评论实时推送**(评论提交后用 React Query 失效缓存重拉,不接 Realtime channel
-**历史快照 fork**(§7.3 提到的"从某早期快照 fork 新 remix"列入 P1
---
## 12. 风险与开放问题
| # | 风险 / 开放问题 | 当前判断 | 待后续讨论 |
|---|---------------|---------|-----------|
| **R-Web-1** | **移动端 Safari 上 Three.js 性能下限**iPhone 13 Mini / SE 等设备在大场景(>3 MB)可能跌到 20 fps | §3.3 已定移动预算 ≤ 2 MB;若 95 分位仍掉帧,则 Tier 1 自动降到 `<model-viewer>` 静态预览 | 是否需要后端转码 Worker 额外产 `canonical_mobile.glb`(更激进压缩)? |
| **R-Web-2** | **大场景首屏 TTI**>20 MB `.glb`(实际有用户扫整套别墅)会让 LCP > 6 s,OG 截图 Edge 函数也会超时 | 上传配额已限 single .glb ≤ 15 MB[`02_api_contract.md`](02_api_contract.md) §6 creator 档),但 100 MB 房间已在用户调研中出现 | 是否要在转码阶段强制拒收超 50 MB 的 .glb?或者 P1 引入 LOD |
| **R-Web-3** | **自动对齐 OBB 朝向不一致**RoomPlan 导出的 `obb.quat` 偶尔与新 asset 的 `anchor_point` 朝向相差 90°(如沙发朝向墙的方向) | §6.3 给出"超 1.5×"的告警,但**朝向**没有自动校正 | 是否在资产侧元数据中加 `facing_direction`front/back/left/right),用启发式对齐? |
| **R-Web-4** | **离线 Remix 草稿与父版本删除的冲突**:用户离线 1 周编辑,期间父房间被作者硬删 | §7.2 抓 `REMIX_PARENT_DELETED` → 提示保存独立副本;但"独立副本"是否承诺保留父几何快照?涉及版权与法务 | 子任务 5 隐私治理章节需明确:父房间被作者删除后,其几何快照能否被未发表的 remix 继承 |
| **R-Web-5** | **公共资产库的版权审查负担**:MVP 只用 CC0,但用户社区可能要求引入 CC-BY 甚至付费素材 | 暂只接 CC0;前端硬过滤所有非 CC0 的 asset | 何时上"创作者上传素材"通道?需要审核流水,子任务 5 共同规划 |
| **R-Web-6** | **Vercel Edge Function 不支持 WebGL**headless Chromium 在 Edge 上的 GPU 不可用) | §9.2 的"动态 OG"实际需放在容器(Fly.io)而不是 Vercel Edge | 是否引入专门的"screenshot worker"容器?还是 MVP 完全跳过动态 OG? |
---
## 13. 给子任务 5(隐私治理)的契约要点
Web 端在分享面板(§8.4)、OG 卡片(§9.2)、Remix 失败兜底(§7.2 父被删后的独立副本)、资产库版权(R-Web-5)处与隐私治理章节有强耦合。子任务 5 撰写隐私治理总章时**必须保证**以下要点:
| # | 治理章节必须保证 | 与本章对应 |
|---|------------------|-----------|
| **P-W-1** | 明确"viewState 分享链接"能否泄露隐藏图层的内容——即第三方拿到 `?vs=...` 后能否反向取消隐藏看到原始几何 | §4.4 + §9.3viewState 只记录"可见性"开关,**不持有几何**,因此天然不泄露隐藏几何;治理章应文字明确这一点 |
| **P-W-2** | 明确 OG 卡片中"作者头像 + 房间标题"是否对外公开——`unlisted` 房间是否允许 OG 图被搜索引擎抓取 | §9.1 + §9.4:`unlisted` 房间应在 robots.txt 阻止 OG endpoint,仅持链访问 |
| **P-W-3** | 明确 Remix 父房间被作者硬删后,已发表的 remix 是否可继续承载父几何快照(涉及"作者撤回权 vs Remixer 既得权"冲突) | §7.2 + R-Web-4 |
| **P-W-4** | 明确公共资产库的协议白名单——是否在 P1 开放 CC-BY?是否要求二次创作 attribution | §5.4 + R-Web-5 |
| **P-W-5** | 明确举报通道(E-14)在 Web 端的可达性——是否要求所有公开页面都有"举报"按钮 | §8.3 ⚠ 举报按钮 |
| **P-W-6** | 明确"嵌入代码"iframe)的频次/速率限制——iframe 嵌入到广告联盟时是否计入原房间作者的流量计费 | §8.4 嵌入代码 |
| **P-W-7** | 明确视图分享 `?vs=` 是否可被 Realtime 服务用作"用户偏好"画像(合规风险) | §9.3 viewState 编码契约 |
---
## 14. 本章小结
| 关键产出 | 一句话 |
|----------|--------|
| **15 条路由 / 10 项技术栈** | Next.js 14 App Router + R3F + Zustand + Supabase SSR + Tailwind/shadcn,部署 Vercel |
| **3D 渲染契约** | manifest → 4 层 group → Zustand 双向绑定;桌面 60 fps / 移动 30 fpsMVP 不做 LOD |
| **类 ArcGIS 图层面板** | 4 层固定 ID + 👁/🔒/不透明度/单节点 toggle + Named Views 分享;直接回应用户原始需求"显示/隐藏" |
| **材质替换 UX** | 浏览器内 `material.map` 替换 + 资产库 CC0 默认 + overlay 5 行 JSON;直接回应"换材质" |
| **家具替换 UX** | OBB 自动对齐 + 4 自由度微调 + 隐藏原节点不删除;直接回应"换其他家具" |
| **Remix 浏览器实时合成** | 父 .glb + 父 manifest + 本地 overlay 三件套,零服务端预合成;防抖 3 s 自动保存 + 6 条错误码兜底 |
| **OG 卡片可复现 Web-Y4** | viewState gzip+base64url 确定性编码;Edge 截图按需缓存 24 h |
| **6 条契约落地** | Web-Y1 §3 / Web-Y2 §5 §6 §7 / Web-Y3 §8.3 / Web-Y4 §4.4 §9 / Web-Y5 §9.5 / Web-Y6 §8.5 |
| **7 条治理移交** | viewState 隐私 / unlisted OG / Remix 父删除 / 资产协议白名单 / 举报可达 / 嵌入流量计费 / vs token 画像 |
读完本章你应能:
- ✅ 给 Web 工程师一份 8 周内可交付的功能清单与路由蓝图
- ✅ 评审"换材质 / 换家具 / 图层切换"三大核心交互的端到端可行性
- ✅ 知道 Web-Y1~Y6 每条契约具体落在哪一节
- ✅ 接手子任务 5 时知道隐私治理章节要回答 Web 侧的 7 个问题
---
**章节版本**v0.1 · 草案
**关键收获**Web 端是 CrowdRoom 直接面向"灵感党 + Remixer"的体验门面——用 Next.js + R3F 把 [`01_data_schema.md`](01_data_schema.md) 的 `layer_manifest.json` 和 [`02_api_contract.md`](02_api_contract.md) §4 的 `remix_overlay.json` 翻译成"类 ArcGIS 分层 + 拖拽换材质换家具"的消费级体验;所有 Remix 合成都发生在浏览器,服务端只承担鉴权、存储、防刷与可选 OG 截图。