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

55 KiB
Raw Blame History

title, date, draft, tags, categories
title date draft tags categories
CrowdRoom · Web 端设计(v0.2 2026-05-20 false
CrowdRoom
众包
3D 重建
机器人
导航
隐私
CrowdRoom

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 §3.1reviewer 工具)与 §4 P-W-6iframe 限流落地)。

本章承接 00_overview.md §4 架构图、01_data_schema.md 的 9 张表与 layer_manifest.json Schema、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] 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 §4 P-W-6 v0.2 / G-6:用户管理「自家房间被 iframe 嵌入」的配额、Referer 白名单与黑名单;显示日访问量、可一键关闭嵌入或封禁某 Referer
R-17 /admin/reports CSRrole=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-6R-16 /me/embeds10_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-7R-17 /admin/reports10_governance.md §3.1「兼职 Reviewer × 1,每日 2 小时(约工单 30–50 条 / 日)」的工具承载页。页面内容:

  • 鉴权门控:进入页面前 middleware.tsauth.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/ssrServer 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
部署 VercelEdge 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 层固定 IDwalls / 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/dreiuseDetectGPU):

  • 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.glb30% 面数)与 canonical_lod2.glb10% 面数),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 §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 E-10 /functions/v1/view-statetoken 在 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-Y2Remix 必须浏览器内实时合成(父 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
收藏夹 浏览器 localStoragefavorite_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.jsonops[] 中(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 必须在父 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_color02_api_contract.md §4.3 已定义)

6. 家具替换 UX 🌟

本节直接回应用户原始需求"更换其他家具"。 家具替换比材质替换复杂——要换几何 + 要对齐位置/朝向,对齐方案直接利用 01_data_schema.md §5.2 中家具层强制保留的 obbanchor_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 强制包含 obbanchor_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

{
  "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_DELETED02_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;每页 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-toggleWeb-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 §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.webpCDN 公开 URL

8.5 评论区

元素 设计
列表 平铺时间序(最新在上),每条含头像/handle/正文/时间/回复按钮
回复 一级回复(comments.reply_to),不做多级嵌套(控制 UI 复杂度)
提交 走 PostgREST POST /rest/v1/commentsRLS 校验作者 = auth.uid());乐观更新
长度限制 1000 字符上限,剩余字数实时提示;超出按钮置灰
删除 评论作者或房间 owner 可删(与 01_data_schema.md §3.7 RLS 一致)

契约 Web-Y602_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 时输出:

<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 编码契约(确定性)

字段 序列化规则
排列顺序 固定 alphabeticalcamera < layer_toggles < overlay_id),保证哈希稳定
浮点精度 相机位置/look_at 保留 3 位小数;fov 保留 1 位
编码 gzipbase64url(无 padding
版本 头部 1 字节 magic 0x01 标识 schema_version
解码宽容 未知字段忽略 + warningWeb-Y4 原契约)

该编码契约同步给 view-state Edge Function02_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 §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 §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 MB02_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_directionfront/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 不支持 WebGLheadless 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.4unlisted 房间应在 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.mdlayer_manifest.json02_api_contract.md §4 的 remix_overlay.json 翻译成"类 ArcGIS 分层 + 拖拽换材质换家具"的消费级体验;所有 Remix 合成都发生在浏览器,服务端只承担鉴权、存储、防刷与可选 OG 截图。