54 KiB
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§3.1(reviewer 工具)与 §4 P-W-6(iframe 限流落地)。
本章承接
00_overview.md§4 架构图、01_data_schema.md的 9 张表与layer_manifest.jsonSchema、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§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,因为 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 §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 §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§4 P-W-6「iframe 嵌入频次/速率限制」决策的用户侧落地点。页面内容:
- 嵌入开关(默认开 / 单房间粒度可关,关闭后 R-12
/embed/r/{id}返回 403 + 业务码EMBED_FORBIDDEN,详见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§4 P-W-6「CDN 流量归属」条款对齐)该路由在 §1.2 跳转图中归属
/me/*子树,鉴权同 R-10。🔄 v0.2 — 回写自 G-7:R-17
/admin/reports是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§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 核心页面跳转图(用户旅程)
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 §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§8.2 Y1。
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 压缩到 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 不做,预留 <XR> 组件挂载点 |
drei @react-three/xr 已就绪 |
4. 类 ArcGIS 分层 UI 设计 🌟
本节直接回应用户原始需求"空间信息,类似 ArcGIS 的分层地图信息一样,选择显示、隐藏"。 这是 Web 端最具辨识度的体验,必须做精。
4.1 图层面板(LayerPanel)整体布局
房间详情页的右侧抽屉(Desktop ≥ 1280 时常驻 320 px;移动端折叠为底部 sheet)展示4 层固定结构,与 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<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 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 组件骨架
// 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):
// 伪代码:从 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 §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 §4.2 已定义 op: replace_material):
{
"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必须在父 manifestmaterials.slots[]中存在且replaceable=trueasset_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§4.3 已定义)
6. 家具替换 UX 🌟
本节直接回应用户原始需求"更换其他家具"。 家具替换比材质替换复杂——要换几何 + 要对齐位置/朝向,对齐方案直接利用
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§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 <TransformControls>(gizmo),与滑块双向绑定。
6.5 隐藏原家具 vs 删除原家具
✅ 关键决策:永远是"隐藏 + 叠加新 asset",不删除原几何。
理由:
- Remix 可回退:用户取消替换 → 让原 furniture 节点
visible=true即可,无需重新下载canonical.glb - 存储零成本:overlay 只是 5 行 JSON,不复制几何
- 审计可追:父房间作者能在自己的 dashboard 看到"我的房间被 N 个 remix 替换了 bed 这件家具"
- 不破坏 OBB 校验:保留原节点意味着 manifest 始终自洽,未来若加"对比模式"(原版 vs Remix 并排)零改造
写入 overlay:
{
"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 草稿,三者都不经过服务端预合成。
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 §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 §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/DELETElikes表——见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 §3.7 RLS 一致) |
✅ 契约 Web-Y6(
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 单品预览(<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 时输出:
<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 |
| 解码宽容 | 未知字段忽略 + warning(Web-Y4 原契约) |
该编码契约同步给
view-stateEdge Function(02_api_contract.mdE-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§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 不上 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 视图(<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 同 Safari;Android X5 fallback 到 <model-viewer> |
详情页 banner 提示"用浏览器打开体验更佳" |
11. MVP 范围与不做项
11.1 MVP(8 周内交付,与 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 §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 的 layer_manifest.json 和 02_api_contract.md §4 的 remix_overlay.json 翻译成"类 ArcGIS 分层 + 拖拽换材质换家具"的消费级体验;所有 Remix 合成都发生在浏览器,服务端只承担鉴权、存储、防刷与可选 OG 截图。