跪拜 Guibai
← Back to the summary

Spec-Driven Geometry, State-Driven Views: An Editable Digital Twin in React Three Fiber

Building an Editable Teaching Building Digital Twin with React Three Fiber

This digital twin is not a BIM model dropped into a browser and spun around. The building is generated programmatically from floor specifications; devices, layers, weather, and views are all editable states. After saving changes, the editor and the visualization screen (大屏) read the same scene.

The building itself is fixed structure; it cannot be dragged or deleted. What can be edited are the devices attached to floors: temperature/humidity sensors, CO₂, smoke detectors, cameras, air conditioning units, distribution cabinets, access control, parking spaces, and more. Each device carries position, rotation, scale, and an IoT binding that has not yet been connected to a platform. Views support toggling by professional layer, floor explosion, section clipping, single-floor focus, and coloring floors by energy consumption, temperature, or CO₂.

Tech stack: React 19, React Three Fiber, Three.js, and Zustand. Both the editor and the screen mount the same BuildingScene.

Implementation Principles

There is only one copy of data, kept in Zustand's useEditor. Floor specifications, device list, layer visibility, explode factor, clip level, heat mode, lights, and weather all live there. The 3D scene only subscribes to these fields; it does not keep its own copy of business data.

The building is computed. makeFloorPlan generates the underground garage, ground-floor lobby, standard floors, and roof mechanical room based on the teaching floor count. stackFloors then stacks each floor's baseY: basement floors sit below ±0, above-ground floors accumulate height upward from 0. Tower renders each floor from this list.

Devices and floors must use the same displacement rules, otherwise devices would stay at their original heights during explode or single-floor focus. When floors explode, the above-ground level floor rises by explode * (level - 1) * 4.5. When clipping, floors and devices above the clip level are hidden. In single-floor mode, only the target floor remains, and basement floors are lifted above ground level, otherwise the ground would block them.

Interaction also flows through state. Clicking writes selectedId. Dragging uses a ray cast to the horizontal plane at the device's height and writes the intersection point back to position. Search focus does not move the camera directly; it emits a timestamped focusReq, and the camera animation in the scene flies there once it receives it. This way the panel, keyboard shortcuts, and the screen reuse the same focus chain.

IoT is currently an empty slot. The iot field on devices pre-allocates protocol, endpoint, and points. Heat visualization uses a deterministic hash of floor ID and metric name to generate stable demo values. Once the platform is connected, swapping heatValue for real measurement points requires no change to the floor heat bands.

Persistence is GET/PUT /api/dt/scene, saving devices and placed effects. When the backend is unavailable, writes fall back to local storage and are read back on next startup. Both the editor and the /screen view call initFromServer on mount, so both see the same scene.

The data flow can be summarized as:

Floor specs / devices / layers / view state
        │
        ▼
   useEditor (Zustand)
        │
        ├── Tower / FloorGroup     procedural building, explode, clip, heat
        ├── SceneDevice            device meshes, drag, selection
        └── Screen charts          same objects
        │
        ▼
   /api/dt/scene  ←→  local backup

1. Devices are data, not temporary meshes in the scene

One device type maps to a system layer, tooltip text, and model name. New devices carry an empty IoT binding that gets filled with protocol and measurement points once the platform is connected.

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(),
  }
}

The factory default layout uses the same structure: ground-floor access control and lobby cameras, standard-floor temperature/humidity sensors and fan-coil units, basement water pumps and parking spaces, roof air-conditioning units. A scene reset returns to defaultDevices().

2. Floor elevations are computed first; geometry follows elevations

The teaching building's depth and width are fixed. When changing floor height or adding/removing standard floors, each floor's baseY is recomputed first, then meshes and devices move together.

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)
}

When a floor's width, depth, or height changes, that floor's device XZ coordinates scale proportionally, and all floors' device Y values add the elevation difference. Devices and floor slabs never misalign.

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 only maps the floor list into components:

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>
  )
}

Within each floor, layers decide what is drawn: the structure layer draws floor slabs and the column grid, the architecture layer draws facades, the HVAC layer draws roof units. When a layer is off or its opacity changes, the corresponding meshes skip rendering or multiply by the opacity.

3. Explode, clip, and heat are all view states on the same layer

FloorGroup handles all three. Clipping and single-floor mode simply skip rendering; explode only changes that floor's Y; heat is a semi-transparent color plane laid on the floor slab.

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]}>
      {/* floor slabs, column grid, facade… */}
      {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>
  )
}

Demo heat does not rely on random numbers; the same floor and metric always produce the same color, which is convenient for comparison. When connecting real data, replace 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
}

The color scale goes from blue to green, orange, red: #409eff → #67c23a → #e6a23c → #f56c6c.

4. Devices follow floors; dragging lands on the floor's height plane

Device components read their own floorId and apply the same explode lift, clip hiding, and layer visibility as floors. When the camera zooms out beyond about 46 meters, interior devices are skipped; the selected or focused floor is always kept.

Dragging disables the orbit controls; a pointer screen-coordinate ray intersects the horizontal plane. The plane's height is the device's current world Y, so dragging stays on the same floor and cannot be pulled to another elevation.

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 writes back to the store; the undo stack snapshots on the first move
updateObject(obj.id, {
  position: [round2(q.x + off.dx), obj.position[1], round2(q.z + off.dz)],
})

Dragging a device in from the left panel also uses a ray to hit the ground and feeds the landing point to the create form. The object is written to objects only after code validation passes. Rotation and scale go through TransformControls, rounding the matrix to two decimals on release.

Focus is also a state:

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 listens for focusReq.ts and interpolates between the current camera position and the target. The search box, floor buttons, and screen clicks all use this single entry point.

5. Editor and screen save the same scene

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)      // always keep a local copy
  return saveScene(objects, fxItems)     // returns true once the backend responds
}

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 })
}

The screen route also calls initFromServer on mount; the central 3D area renders BuildingScene directly. Sky, lights, weather, materials, floor explode, and device positions therefore match the editor, and the charts on the left and right just draw this same state into energy, environment, and alert views.

Summary

The core of this twin is "specification-driven geometry, state-driven views":

The IoT protocol slot is already on every device. The next step is filling MQTT or Modbus measurement points into iot.points and replacing heatValue with live values; heat bands, device cards, and screen charts will switch from demo data to on-site data without touching the 3D interaction layer.

Comments

Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.

吹风er

How long did this take?

前端繁华如梦

It took 5 days.