重写一个「年久失修」的开源项目:把 jQuery + CoffeeScript 的 3D 户型图工具迁移到 TypeScript + Three.js r181

起因

前段时间做项目需要一个「能在浏览器里跑的 3D 平面图编辑器」。调研了一圈, 发现市面上几乎没有现代化的开源方案。

倒是找到了一个叫 blueprint3d 的项目,功能设计得挺完整:2D 平面图编辑 + 3D 实时预览 + 家具拖拽摆放 + 墙面纹理。但打开一看------

  • 构建工具:Grunt + Bower
  • 语言:CoffeeScript
  • DOM 操作:jQuery
  • Three.js:使用的是早已废弃的 Geometry / Face3 API
  • 最近一次有效维护:好几年前

直接用是不可能的,改也很难改。于是决定从头重写一遍,顺手开源出来。

重写版地址:github.com/charmlinn/b...


整体架构

项目分两层:

bash 复制代码
blueprint3d-modern/
├── src/         # 核心库:纯 TypeScript ES Module,不依赖任何框架
└── app/         # Demo 应用:Next.js 15,消费核心库

这种分层的好处是核心库可以独立使用,不绑定 React 或任何框架。


核心库重写(src/)

1. 废弃 API 迁移

原版大量使用了 Three.js 的旧 API,迁移的主要工作量在这里。

Geometry → BufferGeometry

原版:

javascript 复制代码
const geometry = new THREE.Geometry()
geometry.vertices.push(new THREE.Vector3(...))
geometry.faces.push(new THREE.Face3(0, 1, 2))

新版:

typescript 复制代码
const geometry = new THREE.BufferGeometry()
const vertices = new Float32Array([...])
geometry.setAttribute('position', 
  new THREE.BufferAttribute(vertices, 3))

BufferGeometry 直接操作类型化数组,内存布局更紧凑,GPU 上传效率更高。 对于户型图这种需要频繁更新墙体几何体的场景,性能差异是可感知的。

贴图 UV 映射

墙面和地板的纹理 UV 在迁移后需要重新计算,原来基于 Face3 的 faceVertexUvs 全部改成了 BufferGeometry 的 uv attribute:

typescript 复制代码
geometry.setAttribute('uv',
  new THREE.BufferAttribute(new Float32Array(uvs), 2))

2. 动画:tween.js → anime.js v4

原版用 tween.js 做相机切换动画。anime.js v4 的 API 更现代, 支持 Promise,方便配合 async/await 控制动画时序:

typescript 复制代码
import { animate } from 'animejs'

await animate(camera.position, {
  x: target.x,
  y: target.y, 
  z: target.z,
  duration: 600,
  easing: 'easeInOutQuad'
})

3. Item 系统:Factory 模式加载 OBJ 模型

家具 Item 按放置位置分为几种子类型:

类型 说明
FloorItem 落地家具:床、沙发、桌子
WallItem 挂墙家具:挂画、电视
InWallItem 嵌墙物件:门、窗
CeilingItem 吸顶:灯具

加载流程:

typescript 复制代码
// 每个家具有一个 metadata JSON
{
  "itemName": "Single Bed",
  "itemType": 1,        // 1 = FloorItem
  "modelUrl": "/models/bed.obj",
  "materialUrl": "/models/bed.mtl"
}

// Factory 根据 itemType 实例化对应子类
class Factory {
  static async loadItem(metadata: ItemMetadata, scene: THREE.Scene) {
    const obj = await loadOBJMTL(metadata.modelUrl, metadata.materialUrl)
    switch (metadata.itemType) {
      case ItemType.FloorItem: return new FloorItem(obj, scene)
      case ItemType.WallItem:  return new WallItem(obj, scene)
      // ...
    }
  }
}

Raycasting 选中逻辑也按子类型做了区分,WallItem 只响应打在墙面上的 射线,FloorItem 只响应打在地面上的,避免误选。


4. 2D 平面图编辑器

这部分是纯 Canvas 2D,不用 Three.js,独立于 3D 渲染器。

核心数据结构是 Corner-Wall 图:

typescript 复制代码
class Floorplan {
  corners: Corner[]   // 墙角顶点
  walls: Wall[]       // 连接两个 Corner 的边
  rooms: Room[]       // 由 Wall 围合而成的面
}

Wall 从两个 Corner 派生出几何体,Room 用多边形填充算法从封闭的 Wall 环 中计算得出。每次 Corner 移动,相关的 Wall 和 Room 会自动重新计算。

吸附逻辑:

typescript 复制代码
// 新 Corner 在一定距离内自动吸附到已有 Corner
const SNAP_DISTANCE = 20 // px
const nearest = findNearestCorner(mousePos, floorplan.corners)
if (distance(mousePos, nearest) < SNAP_DISTANCE) {
  return nearest.position
}

5. IndexedDB 持久化

原版完全没有存档功能。新版用浏览器原生 IndexedDB 实现, 无需任何后端,无需账号:

typescript 复制代码
// 保存户型
const plan = await blueprintStorage.create({
  name: 'My Bedroom',
  roomType: 'bedroom',
  layoutData: blueprint.model.exportSerialized(), // JSON 序列化
  thumbnailBase64: canvas.toDataURL()             // canvas 截图作为缩略图
})

// 读取列表
const plans = await blueprintStorage.list()

// 加载
const plan = await blueprintStorage.get(id)
blueprint.model.loadSerialized(plan.layoutData)

Demo 应用(app/)

核心库搞定之后,Demo 应用主要是 UI 工程。

技术栈:

技术
框架 Next.js 15 App Router
UI Runtime React 19
样式 Tailwind CSS v4
组件 shadcn/ui + Radix UI
状态 Zustand
动画 Framer Motion
i18n next-intl(en / zh / tw)

踩坑记录

坑 1:Three.js 不能跑在服务端

App Router 下组件默认是 Server Component,Three.js 依赖 window / document,直接 import 会在 SSR 阶段报错。

解法:顶层组件用 dynamic 包裹:

typescript 复制代码
const Blueprint3DApp = dynamic(
  () => import('./Blueprint3DAppBase'),
  { ssr: false }
)

坑 2:monorepo 下 Three.js ESM 路径解析错误

核心库在 src/,Demo 在 app/,两个目录各有自己的 node_modulesthree/examples/jsm 的 import 会解析到错误的版本,导致类型不匹配。

解法:在 next.config.js 里加 webpack alias 强制指向同一份:

javascript 复制代码
config.resolve.alias['three/examples/jsm'] = 
  path.resolve(__dirname, '../node_modules/three/examples/jsm')

坑 3:Canvas ref 初始化时序

Three.js 需要拿到真实 DOM 的 canvas 元素才能初始化。 在 React 里必须在 useEffect 里做,不能在渲染阶段:

typescript 复制代码
useEffect(() => {
  if (!canvasRef.current) return
  const blueprint = new Blueprint3d({
    threeContainer: canvasRef.current,
    floorplanContainer: floorplanRef.current,
    textureDir: '/textures/'
  })
  blueprintRef.current = blueprint
  return () => blueprint.destroy()
}, []) // 空依赖,只跑一次

还没做的

  • GLB / GLTF 模型支持(目前只有 OBJ/MTL)
  • 撤销 / 重做历史栈
  • 核心库发布到 npm
  • 墙体厚度独立配置
  • 多房间 / 多楼层

欢迎 PR,也欢迎在评论区讨论 Three.js 相关的问题。


相关链接

相关推荐
做前端的娜娜子8 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅8 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni9 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学10 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端
赵庆明老师10 小时前
Vben精讲:21-详解web-antd:tsconfig.json
前端·json·vim
wc8810 小时前
微软EDGE浏览器功能学习
前端·学习·edge
Csvn11 小时前
🎯 原生 `<dialog>` 元素:终于可以扔掉一半的自定义弹窗组件了?
前端
Csvn11 小时前
🎨 CSS @layer:用「层叠优先级」终结样式打架的世纪难题
前端
濮水大叔12 小时前
不必把 Vue3 写成“麻花”:从状态碎片到对象协作,重新理解 Zova 的前端心智模型
前端·typescript·vue3·ioc·tsx·zova