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

0 阅读4分钟

起因

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

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

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

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

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


整体架构

项目分两层:

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

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


核心库重写(src/)

1. 废弃 API 迁移

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

Geometry → BufferGeometry

原版:

const geometry = new THREE.Geometry()
geometry.vertices.push(new THREE.Vector3(...))
geometry.faces.push(new THREE.Face3(0, 1, 2))

新版:

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:

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 控制动画时序:

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吸顶:灯具

加载流程:

// 每个家具有一个 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 图:

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

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

吸附逻辑:

// 新 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 实现, 无需任何后端,无需账号:

// 保存户型
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 RuntimeReact 19
样式Tailwind CSS v4
组件shadcn/ui + Radix UI
状态Zustand
动画Framer Motion
i18nnext-intl(en / zh / tw)

踩坑记录

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

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

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

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 强制指向同一份:

config.resolve.alias['three/examples/jsm'] = 
  path.resolve(__dirname, '../node_modules/three/examples/jsm')

坑 3:Canvas ref 初始化时序

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

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 相关的问题。


相关链接