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":
- Floor specifications determine elevations and volume; column grid, slabs, and facades are procedurally generated.
- Devices are data carrying a floor ID and an IoT slot; the mesh is just its view.
- Explode, clip, solo, layers, and heat are all store fields; floors and devices subscribe to the same rules.
- Editor and screen share the scene component and the same save; changes take effect on both after saving.
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.
Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.
How long did this take?
It took 5 days.