--- title: "CrowdRoom · Web 端设计(v0.2)" date: 2026-05-20 draft: false tags: ["CrowdRoom", "众包", "3D 重建", "机器人", "导航", "隐私"] categories: ["CrowdRoom"] --- # CrowdRoom · Web 端设计(v0.2) > **版本**:v0.2(2026-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.1(reviewer 工具)与 §4 P-W-6(iframe 限流落地)。 > 本章承接 [`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]` | SSR(OG 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` | CSR(Supabase 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 Function(Vercel 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/ui(Radix 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 场景图组织原则 | 决策 | 拍板 | 理由 | |------|------|------| | 每层一个 `` 而非用 mesh.visible 逐个 | **是** | 切层 = 1 次 React state 变更触发 1 次 group.visible 赋值;逐 mesh 切要 N 次,浪费 | | 节点名约定 | **沿用 manifest 中 `mesh_node_ids[]`,Worker 端已统一 `wall_* / floor_* / furn_*` 前缀** | Web 端通过 `scene.getObjectByName(nodeId)` O(1) 拿引用 | | `` 边界 | **Canvas 内一层、AssetPicker 缩略图一层** | 渲染主场景与挑材质的网络等待互不阻塞 | | 选中态高亮 | **额外注入 `` (drei) post-processing,不修改 mesh material** | 防止"选中后退出忘了恢复"的副作用 | | 物理 / 灯光 | **MVP 用 ``,无物理引擎** | 真实光照成本不划算;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 | 同移动端预算 | 同上 | 同上 | 同上 | **预算违反时的兜底**(在 `` 外部检测 `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 压缩到 1–5 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 | | WebXR(VR / AR) | **MVP 不做**,预留 `` 组件挂载点 | 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` | | **不透明度滑块** | 0–100 | 整层 `material.transparent = true; .opacity = v/100` | `layers[kind].opacity: 0..1` | | **展开 / 折叠** | ▾ / ▸ | 展开后列出该层 mesh 节点(家具显示语义标签 + 缩略图) | UI 局部 state | | **单节点 toggle** | 👁 | 仅隐藏某一个 mesh(家具层尤其常用:藏掉电视看墙) | `layers.furniture.hiddenItems: Set` | | **保存为视图** | ⊕ | 把当前 4 层可见性 + 相机状态打包成 "Named View",可分享/收藏 | 写入 `viewState`(见 §9) | ### 4.3 交互细节 | 触发 | 反馈 | |------|------| | 鼠标 hover 节点行 | 3D 场景中对应 mesh 加 `` 高亮(淡黄色)+ 浮层显示尺寸/语义 | | 点击节点行 | 相机飞到该 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 ( ); } ``` `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 保存为 Remix(material_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 | 0–360° | 圆形旋钮 | | 缩放 | 0.7×–1.3× | 滑块;等比,禁止非等比避免视觉怪异 | 3D 场景中同时显示 Three.js ``(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`;每页 20,React 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 → 微信深链难题) | | 嵌入代码 | `` 一键复制 | | 下载缩略图 | 直接 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(材质)/ license(CC0 only 默认) | | 卡片 | 缩略图 + 名称 + 协议 + 来源 + "Try in a room"(跳 Remix) | | 详情弹窗 | 3D 单品预览(`` 即可,不上 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 ``` ### 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 | | 解码宽容 | 未知字段忽略 + warning(Web-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 | `` 与 `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 | ``(详情页 SSR 时输出) | | 路由预取 | `` 默认开(卡片 hover 预拉 manifest) | | Service Worker | MVP 不上 PWA;P1 可加(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 视图(`
` 列出 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 同 Safari;Android X5 fallback 到 `` | 详情页 banner 提示"用浏览器打开体验更佳" | --- ## 11. MVP 范围与不做项 ### 11.1 MVP(8 周内交付,与 [`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 自动降到 `` 静态预览 | 是否需要后端转码 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.3:viewState 只记录"可见性"开关,**不持有几何**,因此天然不泄露隐藏几何;治理章应文字明确这一点 | | **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 fps;MVP 不做 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 截图。