一个 index.html 搞定 CAD 与 3D 漫游:floorplan-3d 的原生 WebGL 架构与程序化建模实录

一个 index.html 搞定 CAD 与 3D 漫游:floorplan-3d 的原生 WebGL 架构与程序化建模实录

在前端工程动辄被 Webpack、Vite、数以万计的 npm 依赖与庞大运行时框架层层包裹的今天,你是否还记得最初打开一个纯 HTML 文件即可在浏览器运行复杂交互应用的纯粹与震撼?开源项目 floorplan-3d 给出了一个教科书级别的范例:全站仅由一个 185 KB 的单体 index.html 构成,没有多余的构建流水线与外部依赖包,却完整实现了一套毫厘级 2D CAD 户型设计器与逼真沉浸的 Three.js 3D 第一人称漫游引擎。

从 2D 矢量平面布置、家具贴墙磁性吸附、非承重墙拆改、高斯多边形算量到 3D 剖切建筑挤出、程序化家具参数建模、日照光影与动态门体碰撞,本文将深度剖析其架构设计精髓与底层图形学实现细节。


1. 架构总览:单文件如何支撑专业级 CAD 与 WebGL?

现代前端工程习惯了“模块拆分、状态库管理、构建打包”的标准化三板斧,然而在面对特定交付场景(如内网轻量化部署、单机离线评审、极速免安装原型验证)时,这种重装流水线往往会带来沉重的维护与部署心智负担。

floorplan-3d 采用了极具黑客精神的架构分层:代码分为结构样式区、2D 交互引擎区(基于 SVG 与原生 DOM)以及 3D 渲染管线区(基于 Three.js ES Module)。两者围绕一套极其精炼的单向核心状态树(state)运转。

flowchart TD
    subgraph CoreState ["核心状态驱动层 (Plain JS Object & LocalStorage)"]
        StateRooms["rooms: 房间属性与地面材质"]
        StateFurn["furniture: 家具位置、尺寸、角度与材质"]
        StateDemo["demolished: 被拆除非承重墙索引"]
        UndoRedo["undoStack / redoStack: 快照历史状态"]
    end

    subgraph TwoDEngine ["2D 矢量 CAD 引擎 (原生 SVG)"]
        ViewTx["视口变换矩阵 (view.tx, view.ty, view.s)"]
        SVGRender["renderAll(): 尺寸链 / 墙体 / 房间 / 家具图例"]
        SnapAlg["贴墙投影吸附与正交锁定算法"]
        AreaShoelace["高斯鞋带公式算量与 BOM 材料造价"]
    end

    subgraph ThreeDEngine ["3D WebGL 渲染管线 (Three.js r160)"]
        CamSync["视口对齐逆解 (planPose) 与球坐标过渡 (camTween)"]
        ArchExtrude["建筑剖切挤出 (ShapeGeometry / Box) 与过梁构建"]
        ProceduralGeo["零外链资产:60+ 种程序化参数复合建模"]
        SunSim["太阳赤纬角/高度角与昼夜色温系统"]
        WalkSys["第一人称漫游 (PointerLock / 虚拟摇杆) 与门体碰撞"]
    end

    CoreState -->|数据单向驱动| TwoDEngine
    CoreState -->|跨维投影同步| ThreeDEngine
    TwoDEngine -->|2D 拖拽/拆改| CoreState
    ThreeDEngine -->|3D 射线拾取/拖动| CoreState

2D 与 3D 模块并非割裂的独立画布,而是通过视口对齐逆解算法(planPose)与生长插值器(applyGrow)实现了在同一坐标系下的无缝升维与降维切换。


2. 2D CAD 引擎的几何学精粹

在 2D 平面模式下,用户操作的是一个标准 1:60 比例的毫米级建筑平面图。没有引入 Fabric.js 或 Konva 等重型 2D Canvas 库,作者完全依靠原生 SVG DOM 与数学矩阵完成了专业级的 CAD 交互。

2D CAD 户型平面布置与材料造价估算

2.1 毫米级坐标系与视口变换

建筑图纸的标准单位是毫米(mm),而浏览器渲染依赖 CSS 像素。系统维护了一个简洁的 view 对象:

  • view.x0, view.y0:视口左上角对应的世界坐标(mm);
  • view.s:缩放因子(像素 / mm)。

在 SVG 根节点上,所有的矢量路径无需反复计算屏幕坐标,而是直接利用 SVG 的 viewBox 与分组 transform 完成硬加速视口投影:

// 屏幕物理像素转世界坐标 (mm)
function screenToWorld(sx, sy) {
  return {
    x: view.x0 + sx / view.s,
    y: view.y0 + sy / view.s
  };
}

2.2 旋转包围盒(AABB)与贴墙磁性吸附算法

在 CAD 摆放家具时,最繁琐的痛点莫过于手动微调使其紧靠墙面。floorplan-3d 实现了一套兼顾网格吸附与动态贴墙投影的高性能吸附算法:

// 任意旋转角度下的轴对齐外接矩形半宽与半高
function aabb(f) {
  const a = f.rot * Math.PI / 180;
  const c = Math.abs(Math.cos(a));
  const s = Math.abs(Math.sin(a));
  return {
    hw: f.w / 2 * c + f.d / 2 * s,
    hh: f.w / 2 * s + f.d / 2 * c
  };
}

// 贴墙吸附判定与坐标校准
function snapMove(f, cx, cy) {
  let nx = Math.round(cx / 10) * 10;
  let ny = Math.round(cy / 10) * 10;
  if (!ui.layers.wallSnap) return [nx, ny];

  const { hw, hh } = aabb(f);
  const tol = 10 / view.s; // 保持屏幕像素级一致容差
  let bx = tol, by = tol;

  // 遍历所有存活墙体与窗洞矩形
  for (const r of snapRects()) {
    // 检查 Y 轴重叠,吸附 X 轴内/外边缘
    if (!(r[3] < cy - hh - tol || r[1] > cy + hh + tol)) {
      for (const ex of [r[0], r[2]]) {
        for (const c of [ex + hw, ex - hw]) {
          if (Math.abs(c - cx) < bx) {
            bx = Math.abs(c - cx);
            nx = c;
          }
        }
      }
    }
    // 检查 X 轴重叠,吸附 Y 轴内/外边缘
    if (!(r[2] < cx - hw - tol || r[0] > cx + hw + tol)) {
      for (const ey of [r[1], r[3]]) {
        for (const c of [ey + hh, ey - hh]) {
          if (Math.abs(c - cy) < by) {
            by = Math.abs(c - cy);
            ny = c;
          }
        }
      }
    }
  }
  return [nx, ny];
}

无论家具如何自由旋转,系统首先求出其真实几何投影在 X/Y 轴的半宽 hw 与半高 hh,接着向有效墙体边界(ex、ey)投射。若偏差在屏幕容差 tol 范围内,便自动将家具边缘锁定至墙面外皮或内皮,且吸附容差与屏幕缩放比例 view.s 动态反比绑定,保证了在不同缩放层级下的吸附手感始终精准一致。

2.3 面积鞋带公式与材料工程量估算

每个房间多边形由闭合点集 [[x0, y0], [x1, y1], ...] 构成。系统通过离散几何学中的高斯鞋带公式(Shoelace Formula)在纳秒级完成真实室内净面积计算:

// 高斯鞋带公式:单位从 mm² 换算至 m²
const area = poly =>
  Math.abs(
    poly.reduce((a, p, i) => {
      const q = poly[(i + 1) % poly.length];
      return a + p[0] * q[1] - q[0] * p[1];
    }, 0)
  ) / 2 / 1e6;

右侧的造价面板实时汇总房间地面材料(橡木地板、地砖、大理石等),并自动加上 5% 施工损耗 计算工程量预算。这不仅是设计图纸,更直接具备了轻量级建筑信息模型(BIM)的算量雏形。


3. WebGL 三维世界重塑:0 资产文件的程序化建模

传统 Web3D 户型应用通常需要加载数十甚至数百兆字节的 .gltf / .obj 3D 模型文件,极易遭受 CDN 波动、加载卡顿与材质贴图丢失的困扰。

floorplan-3d 最令人惊叹的创举,在于整站没有加载任何一个外部 3D 资产文件!全站 60 多种逼真的家具模型与建筑结构,全部由 Three.js 基础几何体在内存中程序化组装而成。

Three.js 剖切三维空间鸟瞰渲染

3.1 复合参数化建模几何工具箱

在脚本内部,作者封装了一套小巧而强大的程序化几何工具箱:

  • rbox(w, h, d, m, x, y, z, r):基于 RoundedBoxGeometry 的倒角立方体,用于塑造没有尖锐 CG 感的真实家具圆角;
  • lathe(pts, m, x, y, z):根据截面曲线旋转成型的旋转体,用于花瓶、杯具、圆桌立柱;
  • rod(a, b, r0, m, r1):连接三维两点的渐变圆杆,四条倾斜收分的北欧风实木椅腿与斜撑顷刻成型;
  • shell(w, d, h, t, r, m):具有壁厚的中空圆角框,用于水槽凹槽、一体化亚克力浴缸与椅背镂空结构;
  • fronts(g, x, y, w, h, z, nx, ny, m, type):自动细分门板分缝、抽屉间隙并生成金属一字拉手(pull)或圆形旋钮(knob)。

以双人床(bed)为例,其不仅有床垫,更分层构建了内缩踢脚、木质床箱、带有竖向深浅软包折痕的床头靠背、带自然折边的翻边被褥,以及两组根据倾角微微后仰的白色蓬松枕头与撞色抱枕:

case 'bed': {
  const fr = woodM('#8d7258'), fab = fabric(c), fh = .3, mt = .22, top = fh + mt;
  const n = Math.max(3, Math.round(w / .28)), sw = (w - .04) / n;
  // 1. 内缩踢脚 + 床箱
  g.add(box(w - .12, .06, d - .14, '#4a3e33', 0, 0, .03));
  g.add(rbox(w, fh - .06, d - .08, fr, 0, .06, .04, .015));
  // 2. 床头板与竖向软包分块
  g.add(rbox(w, 1.08, .06, fr, 0, 0, bz + .03, .012));
  for (let i = 0; i < n; i++) {
    g.add(rbox(sw - .006, .62, .06, fabric(darker(c, .8)), -w/2 + .02 + sw*(i + .5), .42, bz + .08, .025));
  }
  // 3. 仿真实木与织物材质、床垫、被褥翻边与床尾巾
  const md = d - .13, dd = md * .66, dz = d/2 - .015 - dd/2;
  g.add(rbox(w - .06, mt, md, '#f6f3ee', 0, fh, bz + .11 + md/2, .07));
  g.add(rbox(w + .02, .27, dd, fab, 0, top - .2, dz, .04));
  g.add(rbox(w + .024, .06, .22, fabric('#fbfaf7'), 0, top + .025, dz - dd/2 + .11, .025));
  // 4. 自然后仰角度的枕头与靠垫
  const np = w >= 1.3 ? 2 : 1, pw = (w - .16 - (np - 1) * .06) / np;
  for (let i = 0; i < np; i++) {
    const x = -w/2 + .08 + pw/2 + i*(pw + .06);
    g.add(rot(rbox(pw, .15, .42, fabric('#ffffff'), x, top - .01, bz + .34, .07), -.28));
  }
  break;
}

这种完全基于数学尺寸生成的模型,体积极小、解析开销为零,而且能够根据 2D 户型图上拉伸的宽深数据动态重算,彻底解决了传统 3D 资产尺寸拉伸时模型失真的顽疾。

3.2 动态剖切墙体与门窗过梁挤出

在建筑三维呈现中,全高封闭墙体会严重阻挡视线。系统提供了全高(2.8 米)与剖切截断(1.1 米)两种墙体形态切换。

建筑挤出算法不仅生成四壁,更细致地考虑了建筑结构规范:

  1. 窗洞与飘窗洞:根据窗台高度(sill,普通窗 0.9m,飘窗 0.45m,高窗 1.4m)与窗顶过梁(head = 2.4m)分别生成窗下墙与过梁墙体,中间嵌入双面半透物理材质玻璃(MeshPhysicalMaterial)与金属分格窗框;
  2. 门上过梁(Lintels):当视口高度大于门高(2.1m 或 2.4m)时,自动补充门洞上方的过梁实体,在剖切模式下则自动隐藏以露出开阔视线;
  3. 踢脚线与棱线增强:进入漫游模式时,自动激活墙脚踢脚线(skirtMat)与棱边线框(edgeMat),使得白色墙面在室内仰视漫游时能够清晰分辨墙角与转折。

4. 空间漫游与实时物理交互

当用户点击“漫游”或按下 T 键切换到第一人称视角时,应用立刻从 CAD 设计软件转变为一款逼真的室内 3D 漫游游戏。

第一人称沉浸式漫游与门窗交互

4.1 跨端漫游控制适配(PointerLock vs. 触屏虚拟摇杆)

  • 桌面端:依托 PointerLockControls 实现无边界鼠标视角旋转,配合 WASD / 方向键控制前进后退,Shift 加速快走,Esc 退出锁定;
  • 移动端与平板设备:由于 iOS Safari 等移动浏览器对鼠标指针锁定限制严格,作者在左下角手写了基于 Pointer Capture 的平滑虚拟摇杆(joy),右侧屏幕直接支持单指触控滑动转向。

4.2 动态门体铰链旋转与实时碰撞检测

漫游时的阻挡碰撞检测极其精巧。传统游戏引擎往往需要引入 PhysX 或 Cannon.js,而这里仅用一段数十行的代数算法(blocked 函数)便实现了完备的人物防穿墙与动态门阻挡:

function blocked(x, z, r = 0.22) {
  // 1. 静态墙体与窗洞 AABB 碰撞
  for (const [x0, z0, x1, z1] of colliders) {
    if (x > x0 - r && x < x1 + r && z > z0 - r && z < z1 + r) return true;
  }
  // 2. 动态旋转门体碰撞:将门叶视为随铰链转动的线段
  for (const d of doors) {
    const a = d.pivot.rotation.y;
    const px = d.pivot.position.x, pz = d.pivot.position.z;
    const ex = px + Math.cos(a) * 0.9, ez = pz - Math.sin(a) * 0.9;
    // 计算玩家坐标点到门体线段的最近投影参数 t
    const t = clamp01(((x - px)*(ex - px) + (z - pz)*(ez - pz)) / ((ex - px)**2 + (ez - pz)**2));
    // 判定距离是否侵入玩家包围球半径
    if (Math.hypot(x - (px + t*(ex - px)), z - (pz + t*(ez - pz))) < r * 0.8) {
      return true;
    }
  }
  return false;
}

当玩家靠近门扇(距离小于 2.5 米)按下 E 键或点击门把手时,门体会围绕其铰链轴(pivot)展开平滑旋转动画。门体状态切换的同时,线段端点坐标(ex, ez)随角度实时更新,原本受阻的通道瞬间通行无阻!


5. 跨维交互:2D 与 3D 的数学对齐与状态双向同步

从 2D 到 3D 的过渡并非冷冰冰的场景销毁与重建,而是一场经过严密三角几何学推导的视觉盛宴。

sequenceDiagram
    autonumber
    actor User as 用户操作
    participant UI as 视口控制器
    participant Cam as Three.js 相机 (PerspectiveCamera)
    participant Scene as 3D 网格生长层 (archUp / furnG)
    participant State as 核心数据源 (state)

    User->>UI: 点击切换 3D / 按键 T
    Note over UI,Cam: 求解 2D 与 3D 视野重合点 (planPose)
    UI->>Cam: 将相机置于 (cx, dist, cz) 垂直向下俯视
    UI->>Scene: 初始化 grow = 0, furnGrow = 0 (垂直高度压平为 0)
    UI->>UI: 触发 CSS 交叉淡入 (is3d 容器显示)
    Note over UI: 此刻 3D 渲染画面与 2D SVG 像素级严格对齐,零跳变!
    
    rect rgb(240, 248, 255)
        Note over Cam,Scene: 运行 1.7 秒复合过渡插值动画
        UI->>Cam: camTween: 球坐标系球面插值过渡到等轴测倾角 (isoFrom)
        UI->>Scene: applyGrow: 墙体从地面拔地而起 (grow: 0 -> 1)
        UI->>Scene: applyGrow: 家具从地面随之升起 (furnGrow: 0 -> 1)
    end

    Note over User,Scene: 场景就绪:支持直接在 3D 视口拖动家具
    User->>Scene: 3D 射线拾取家具并拖拽
    Scene->>UI: 捕获阶段拦截 OrbitControls 事件
    Scene->>State: 调用 snapMove 计算贴墙吸附并回写 2D 坐标
    State-->>UI: 触发 2D 方案静默同步

5.1 严丝合缝的 2D / 3D 视口对齐算法(planPose)

要想在切换的瞬间让 2D SVG 与 3D WebGL 画布没有任何跳动,3D 透视相机在正俯视时的视野宽高,必须在数学上严格等于当前 2D 视口的缩放覆盖范围:

function planPose() {
  // 计算当前 2D 视口中心对应的世界米制坐标
  const cx = view.x0 + SW() / 2 / view.s;
  const cy = view.y0 + SH() / 2 / view.s;
  const visH = SH() / view.s / 1000; // 屏幕可视高度对应的三维世界米数

  // 核心三角恒等式:视场角 FOV 与视距 dist 的对准
  const dist = visH / 2 / Math.tan(FOV / 2 * Math.PI / 180);
  const t = new THREE.Vector3(wx(cx), 0, wz(cy));

  return pose(t, new THREE.Vector3(t.x, dist, t.z + 1e-4));
}

在切换开始的瞬间,3D 相机被放置在距离地面恰好为 dist 的正上方,所有 3D 墙体与家具的高度缩放被压制为 0(grow = 0)。此时 3D 画面的投影投射与 2D SVG 完全重合,随后通过 camTween 球坐标插值让镜头以平滑弧线倾斜飞入,同时墙体拔地而起、家具逐一立起,科技感与沉浸感拉满。

5.2 3D 视口中的直接摆放与事件拦截

很多 3D 家居设计软件仅支持在 2D 视图排布后进 3D 预览,无法在三维下直观微调。而在 floorplan-3d 中,用户可以在 3D 视角直接拖动家具:

// 在父容器上使用捕获阶段监听指针,抢在 OrbitControls 之前接管事件
host.addEventListener('pointerdown', e => {
  if (e.target !== cv || anim || opt.mode !== 'orbit' || ui.sel?.kind !== 'furn') return;
  const h = pick(e);
  const f = h?.fid === ui.sel.id && getF(h.fid);
  const g = f && groundAt(e.clientX, e.clientY);
  if (!g) return;

  // 接管指针拖动,临时关闭镜头轨道旋转
  fdrag = { id: f.id, pid: e.pointerId, ox: g.x - f.cx, oy: g.y - f.cy, before: snap() };
  orbit.enabled = false;
  cv.setPointerCapture(e.pointerId);
}, true);

指针移动时,射线与地面平面的交点坐标直接传入 2D 的 snapMove 贴墙算法,计算吸附后的世界坐标并实时更新家具模型矩阵。松开指针后,快照入栈支持 Ctrl+Z 撤销,2D 平面图也同步更新。


6. 环境仿真:真实日照与夜景照明

除了白天光照,项目还包含了一套基于时间滑块(06:00 ~ 18:00)的动态日光模拟与一键夜景系统。

夜景氛围照明与点光源渲染

6.1 昼夜太阳轨迹与色温公式

系统根据当前时刻模拟太阳在天空中的天顶角与赤纬角,同时动态计算早晚的“黄金时刻”(Golden Hour)暖色衰减:

function applyLight() {
  const t = (opt.hour - 6) / 12; // 归一化白天时间 [0, 1]
  const az = Math.PI * (0.15 + t * 0.7); // 方位角 (Azimuth)
  const el = Math.sin(Math.PI * t) * 1.05 + 0.15; // 高度角 (Elevation)
  const warm = 1 - Math.sin(Math.PI * t); // 清晨与傍晚暖色加成

  // 太阳光源坐标与定向光投射
  sun.position.set(Math.cos(az) * 18, Math.sin(el) * 20 + 3, -Math.sin(az) * 10 + 8);
  // 基于 HSL 调整色温:中午冷白,早晚偏金橙红
  sun.color.setHSL(0.09, 0.5 + warm * 0.4, 0.92 - warm * 0.12);
  sun.intensity = opt.night ? 0.05 : 1.4 + Math.sin(Math.PI * t) * 1.6;

  // 夜景模式下点亮全屋筒灯与吊灯点光源
  lampG.children.forEach(o => {
    if (o.isPointLight) o.intensity = opt.night ? 6 : 0;
    else o.material.emissiveIntensity = opt.night ? 2 : 0.3;
  });
}

开启“夜景”后,太阳光源熄灭,背景切换为静谧的夜空深蓝(#1c2130),每个房间顶部的筒灯发出暖黄色的局部点光源(PointLight),材质粗糙度与微弱环境反射交相辉映,展现出强烈的居室温馨氛围。


7. 前端架构启示与工程权衡

纵观 floorplan-3d 的 2,674 行代码,它给现代 Web 开发者带来了深刻的架构思考与工程启示:

维度 传统现代化大型工程方案 floorplan-3d 极简原生实现方案
构建链路 Node.js + Vite + TypeScript + Babel + Sass 零构建:标准 HTML5 + 原生 ESM Importmap
依赖体积 数百兆 node_modules,海量外部 runtime 零本地依赖:仅通过 CDN 引入核心 Three.js 单库
资产网络开销 数十兆 GLTF / 纹理贴图模型包 零外部模型资产:纯代码参数化几何组装
渲染选型 React-Three-Fiber / Heavy Canvas 库封装 原生 SVG + Three.js 原生 API,直控渲染流水线
部署与分发 容器化部署、静态构建 CI/CD 管线 单文件即分发物:双击即可运行,易于嵌入任意宿主

7.1 适用场景与边界思考

  1. 极度适合的场景:
    • 敏捷概念验证(PoC)、房产户型销售轻量级展示;
    • 严苛受限的内网离线环境、涉密网络下的无安装审查交付;
    • 教学与图形学底层原型研究,无需排查复杂的框架中间件 Bug。
  2. 需要演进的场景:
    • 若户型由固定单套扩展为多楼层、自由绘制任意异形弧形墙体时,纯手工数学计算的维护成本会急剧增加,需要引入专业的二维半约束求解器(2D Constraint Solver)与剪裁库(如 ClipperLib);
    • 当家具精细度要求达到高定工业制造(CAM)标准时,仍需借助具备 LOD 与网格压缩(Draco / Meshopt)的标准化 glTF 资产管线。

8. 总结

floorplan-3d 用极具说服力的代码证明了一件事:在图形学与交互工程的底层,深刻的几何数学理解与纯粹的语言掌握力,永远比堆砌厚重的框架更有生命力。

从 2D 的高斯鞋带算量、四向贴墙吸附,到 3D 的视口对齐逆解、程序化几何组装、门体铰链碰撞与昼夜光影,这不仅仅是一个精巧的户型设计工具,更是一份凝聚了扎实图形学功底与极简工程美学的开源艺术品。


原文链接与参考资料