一个 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 交互。

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 基础几何体在内存中程序化组装而成。

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 米)两种墙体形态切换。
建筑挤出算法不仅生成四壁,更细致地考虑了建筑结构规范:
- 窗洞与飘窗洞:根据窗台高度(
sill,普通窗 0.9m,飘窗 0.45m,高窗 1.4m)与窗顶过梁(head = 2.4m)分别生成窗下墙与过梁墙体,中间嵌入双面半透物理材质玻璃(MeshPhysicalMaterial)与金属分格窗框; - 门上过梁(Lintels):当视口高度大于门高(2.1m 或 2.4m)时,自动补充门洞上方的过梁实体,在剖切模式下则自动隐藏以露出开阔视线;
- 踢脚线与棱线增强:进入漫游模式时,自动激活墙脚踢脚线(
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 适用场景与边界思考
- 极度适合的场景:
- 敏捷概念验证(PoC)、房产户型销售轻量级展示;
- 严苛受限的内网离线环境、涉密网络下的无安装审查交付;
- 教学与图形学底层原型研究,无需排查复杂的框架中间件 Bug。
- 需要演进的场景:
- 若户型由固定单套扩展为多楼层、自由绘制任意异形弧形墙体时,纯手工数学计算的维护成本会急剧增加,需要引入专业的二维半约束求解器(2D Constraint Solver)与剪裁库(如 ClipperLib);
- 当家具精细度要求达到高定工业制造(CAM)标准时,仍需借助具备 LOD 与网格压缩(Draco / Meshopt)的标准化 glTF 资产管线。
8. 总结
floorplan-3d 用极具说服力的代码证明了一件事:在图形学与交互工程的底层,深刻的几何数学理解与纯粹的语言掌握力,永远比堆砌厚重的框架更有生命力。
从 2D 的高斯鞋带算量、四向贴墙吸附,到 3D 的视口对齐逆解、程序化几何组装、门体铰链碰撞与昼夜光影,这不仅仅是一个精巧的户型设计工具,更是一份凝聚了扎实图形学功底与极简工程美学的开源艺术品。
原文链接与参考资料
- 项目仓库:floorplan-3d on GitHub (by @wy51ai)
- 作者推特:@akokoi1 on X
- 核心图形学库:Three.js 官方文档与案例
- CommonMark 规范:CommonMark Spec for Delimiter Flanking