用 React Three Fiber 做一栋能编辑的教学楼数字孪生

0 阅读7分钟

用 React Three Fiber 做一栋能编辑的教学楼数字孪生

image.png

这套数字孪生不是把一份 BIM 模型丢进浏览器转一圈。楼体按楼层规格程序化生成,设备、图层、天气和视角都是可编辑状态。改完保存后,编辑器和可视化大屏读的是同一份场景。

楼体本身是固定构件,不能拖走、不能删。能改的是挂在楼层上的设备:温湿度、CO₂、烟感、摄像机、空调机组、配电柜、门禁、车位等。每个设备带位置、旋转、缩放,以及一份尚未接平台的物联网绑定。视图上可以按专业图层开关、楼层展开、剖切、单层直达,也能按能耗、温度、CO₂ 给楼层着色。

技术栈是 React 19、React Three Fiber、Three.js 和 Zustand。编辑器与大屏都挂同一个 BuildingScene。

实现原理

数据只有一份,放在 Zustand 的 useEditor 里。楼层规格、设备列表、图层显隐、展开系数、剖切层、热力模式、灯光和天气都在这里。三维场景只订阅这些字段,不自己存一份业务数据。

楼是算出来的。makeFloorPlan 按教学层数生成地下车库、一层大厅、标准层和屋顶机房。stackFloors 再把每层的 baseY 叠上去:地下层埋在 ±0 以下,地上层从 0 往上累加层高。Tower 按这份列表渲染每一层。

设备和楼必须用同一套位移规则,否则展开或单层直达时设备会留在原高度。楼层展开时,地上第 level 层抬升 explode * (level - 1) * 4.5。剖切时隐藏该层以上的楼板和设备。单层直达时只留目标层,地下层再上抬到地面以上,否则会被地面挡住。

交互也走状态。点选写入 selectedId。拖拽用射线打到设备所在高度的水平面,把交点写回 position。搜索定位不直接改相机,而是发一条带时间戳的 focusReq,场景里的相机动画接到后再飞过去。这样面板、快捷键和大屏都能复用同一条定位链路。

物联网目前是空槽。设备上的 iot 预留了协议、地址、点位。热力着色用楼层 ID 和指标名做确定性哈希,生成稳定的演示值。平台接入后,只要把 heatValue 换成真实测点,楼层色带不用改。

持久化是 GET/PUT /api/dt/scene,保存设备和已摆放的特效。后端不可用时写入本地,下次启动再回读。编辑器和 /screen 大屏挂载时都调用 initFromServer,所以两边看到的是同一份场景。

数据流可以概括成:

楼层规格 / 设备 / 图层 / 视图状态
        │
        ▼
   useEditor(Zustand)
        │
        ├── Tower / FloorGroup     程序化楼体、展开、剖切、热力
        ├── SceneDevice            设备网格、拖拽、选中
        └── 大屏图表               同一份 objects
        │
        ▼
   /api/dt/scene  ←→  本地备份

1. 设备是数据,不是场景里的临时网格

一种设备类型对应系统图层、提示文案和模型名。新建时带上空的物联网绑定,等平台接入后再填协议和测点。

export interface IotBinding {
  protocol: ProtocolKey   // none | mqtt | modbus | opcua | http
  endpoint: string
  tag: string
  points: IotPoint[]
}

export const emptyIot = (): IotBinding => ({
  protocol: 'none', endpoint: '', tag: '', points: [],
})

export function makeDevice(
  type: DeviceType,
  floorId: string,
  position: [number, number, number],
  opts?: { code?: string; name?: string },
): DeviceObj {
  const meta = DEVICE_META[type]
  return {
    id: nid(),
    code: opts?.code?.trim() || `DT-${type.toUpperCase().slice(0, 4)}-001`,
    name: opts?.name?.trim() || meta.label,
    type,
    system: meta.system,   // 弱电 / 暖通 / 消防 / 摄像机 …
    floorId,
    position,
    rotation: [0, 0, 0],
    scale: [1, 1, 1],
    opacity: 1,
    clickable: true,
    model: meta.model,
    iot: emptyIot(),
  }
}

出厂布点也是同一份结构:一层门禁和大厅摄像机、标准层的温湿度与风机盘管、地下车库的水泵和车位、屋顶的空调机组。场景重置时直接回到 defaultDevices()。

2. 楼层标高先算清,几何跟着标高走

教学楼进深和面宽固定。改层高或增减标准层时,先重算每层 baseY,再让网格和设备一起挪。

export function stackFloors(floors: FloorSpec[]): FloorSpec[] {
  let y = 0
  return floors.map((f) => {
    if (f.level < 0) return { ...f, baseY: -f.height }
    const next = { ...f, baseY: y }
    y += f.height
    return next
  })
}

export function makeFloorPlan(officeCount: number, prev: FloorSpec[] = []): FloorSpec[] {
  const n = Math.max(1, Math.min(16, Math.round(officeCount)))
  const floors: FloorSpec[] = [
    { id: 'B1', level: -1, name: 'B1 地下车库', height: 4.2, kind: 'basement', /* … */ baseY: 0 },
    { id: 'F1', level: 1,  name: 'F1 一层大厅', height: 5,   kind: 'lobby',    /* … */ baseY: 0 },
  ]
  for (let i = 2; i <= n + 1; i++) {
    floors.push({ id: `F${i}`, level: i, height: 4, kind: 'office', /* … */ baseY: 0 })
  }
  floors.push({ id: 'RF', level: n + 2, name: 'RF 屋顶机房', height: 4, kind: 'roof', /* … */ baseY: 0 })
  return stackFloors(floors)
}

某一层改了面宽、进深或层高后,该层设备的 XZ 按缩放比跟着变,所有层的设备 Y 再加上标高差。设备和楼板就不会错层。

const sx = next.width / prev.width
const sz = next.depth / prev.depth
const objects = s.objects.map((o) => {
  const dy = rise.get(o.floorId) ?? 0
  const ox = o.floorId === id ? sx : 1
  const oz = o.floorId === id ? sz : 1
  if (dy === 0 && ox === 1 && oz === 1) return o
  return {
    ...o,
    position: [o.position[0] * ox, o.position[1] + dy, o.position[2] * oz],
  }
})

Tower 只负责把楼层列表映射成组件:

export function Tower() {
  const soloFloorId = useEditor((s) => s.soloFloorId)
  const floors = useEditor((s) => s.floors)
  return (
    <group>
      {floors.map((f) => (
        <FloorGroup key={f.id} f={f} solo={soloFloorId} />
      ))}
    </group>
  )
}

每一层内部再用图层决定画什么:结构层画楼板和柱网,建筑层画立面,暖通层画屋顶机组。图层关掉或透明度变化时,对应网格直接不渲染或乘上透明度。

3. 展开、剖切、热力都是同一层上的视图状态

FloorGroup 同时处理这三件事。剖切和单层直达直接不渲染;展开只改这一层的 Y;热力是铺在楼板上的半透明色块。

function FloorGroup({ f, solo }: { f: FloorSpec; solo: string | null }) {
  const explode = useEditor((s) => s.explode)
  const clipLevel = useEditor((s) => s.clipLevel)
  const heatMode = useEditor((s) => s.heatMode)

  if (clipLevel !== null && f.level > clipLevel) return null
  if (solo !== null && f.id !== solo) return null
  if (f.kind === 'roof' && explode > 0.05) return null

  const offset = f.level >= 1 ? explode * (f.level - 1) * 4.5 : 0
  const hv = heatValue(f.id, heatMode)
  const t = (hv - min) / (max - min)

  return (
    <group position={[0, f.baseY + offset, 0]}>
      {/* 楼板、柱网、立面… */}
      {heatMode !== 'none' && (
        <mesh position={[0, 0.05, 0]} rotation={[-Math.PI / 2, 0, 0]}>
          <planeGeometry args={[W - 1, D - 1]} />
          <meshStandardMaterial
            color={heatColor(t)}
            emissive={heatColor(t)}
            emissiveIntensity={0.55}
            transparent
            opacity={0.5}
          />
        </mesh>
      )}
    </group>
  )
}

演示热力不依赖随机数,同一层同一指标每次颜色都一样,方便对照。接入真实数据时替换 heatValue 即可。

export function heatValue(floorId: string, mode: HeatKey): number {
  if (mode === 'none') return 0
  const def = HEAT_MODES.find((m) => m.key === mode)!
  let h = 2166136261
  const s = floorId + mode
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i)
    h = Math.imul(h, 16777619)
  }
  const r = ((h >>> 0) % 1000) / 999
  return Math.round((def.min + r * (def.max - def.min)) * 10) / 10
}

色带从蓝到绿、橙、红:#409eff → #67c23a → #e6a23c → #f56c6c。

4. 设备跟着楼层走,拖拽落在所在高度平面上

设备组件读自己的 floorId,套用和楼层相同的展开抬升、剖切隐藏、图层显隐。相机拉远超过约 46 米就跳过室内设备,选中或正在看的那一层始终保留。

拖拽时关掉轨道控制器,用指针的屏幕坐标射线求与水平面的交点。平面高度就是设备当前的世界 Y,所以只能在本层平移,不会被拖到别的标高。

const offY = (f.level >= 1 ? explode * (f.level - 1) * 4.5 : 0) + soloLift

const hitDragPlane = (cx: number, cy: number) => {
  dragPlane.constant = -(obj.position[1] + offY)
  const rect = glDom.getBoundingClientRect()
  ndc.set(
    ((cx - rect.left) / rect.width) * 2 - 1,
    -((cy - rect.top) / rect.height) * 2 + 1,
  )
  raycaster.setFromCamera(ndc, camera)
  return raycaster.ray.intersectPlane(dragPlane, hitPoint)
}

// pointermove 里写回 store,撤销栈在第一次移动时打快照
updateObject(obj.id, {
  position: [round2(q.x + off.dx), obj.position[1], round2(q.z + off.dz)],
})

从左侧面板拖一个设备进来时,同样用射线打到地面,把落点交给新建表单。编码校验通过后才写入 objects。旋转和缩放交给 TransformControls,松手时把矩阵四舍五入到两位小数再提交。

定位也是一条状态:

focusDevice: (id) => {
  const o = get().objects.find((d) => d.id === id)
  if (!o) return
  const lift = get().soloFloorId === o.floorId ? floorSoloLift(floorById(o.floorId)) : 0
  set({
    selectedId: id,
    focusReq: {
      pos: [o.position[0], o.position[1] + lift, o.position[2]],
      dist: 15,
      ts: Date.now(),
    },
  })
}

CameraRig 监听 focusReq.ts,在当前相机位置和目标点之间做插值。搜索框、楼层按钮、大屏点选走的都是这个入口。

5. 编辑器和大屏保存同一份场景

const API_BASE = 'http://127.0.0.1:9100'

export async function saveScene(objects: any[], fxItems: any[] = []) {
  const res = await fetch(`${API_BASE}/api/dt/scene`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ objects, fxItems }),
  })
  const json = await res.json()
  return json.code === 0
}

save: async () => {
  const { objects, fxItems } = get()
  writeLocalScene(objects, fxItems)      // 本地始终留一份
  return saveScene(objects, fxItems)     // 后端通了返回 true
}

initFromServer: async () => {
  const remote = await fetchScene()
  if (remote?.objects?.length || remote?.fxItems?.length) {
    set({
      objects: remote.objects ?? [],
      fxItems: remote.fxItems ?? [],
    })
    return
  }
  const local = readLocalScene()
  if (local) set({ objects: local.objects, fxItems: local.fxItems })
}

大屏路由同样在挂载时调用 initFromServer,中央三维区域直接渲染 BuildingScene。天空、灯光、天气、材质、楼层展开和设备点位因此和编辑器一致,左右两侧的图表只是把这份状态再画成能耗、环境和告警。

小结

这套孪生的核心是「规格驱动几何,状态驱动视图」:

  • 楼层规格决定标高和体量,柱网、楼板、立面由程序生成。
  • 设备是带楼层和物联网槽位的数据,网格只是它的视图。
  • 展开、剖切、单层、图层、热力都写成 store 字段,楼和设备订阅同一套规则。
  • 编辑器和大屏共用场景组件和同一份存档,改动保存后两边一起生效。

物联网协议位已经留在设备上。下一步把 MQTT 或 Modbus 的测点填进 iot.points,用实时值替换 heatValue,热力、设备卡片和大屏曲线就会从演示数据切到现场数据,三维侧不用再改交互。