Three.js / React-Three-Fiber Integration¶
Use @ifc-lite/geometry with Three.js or react-three-fiber instead of the built-in WebGPU renderer.
Why Three.js?¶
The built-in @ifc-lite/renderer uses WebGPU for maximum performance, but you may want Three.js when:
- WebGPU is unavailable — Three.js works with WebGL (wider browser support)
- Existing Three.js ecosystem — integrate with your existing 3D scene, post-processing, controls
- react-three-fiber — declarative 3D with React
- Custom rendering — shadow maps, custom shaders, physics, XR
Prefer Babylon.js?
The same MeshData typed arrays work with any engine. See the Babylon.js Integration tutorial for the Babylon.js equivalent.
Architecture¶
The key insight: @ifc-lite/geometry outputs plain typed arrays that are engine-agnostic:
interface MeshData {
expressId: number;
ifcType?: string; // "IfcWall", "IfcDoor", etc.
positions: Float32Array; // [x,y,z, x,y,z, ...]
normals: Float32Array; // [nx,ny,nz, ...]
indices: Uint32Array; // Triangle indices
color: [number, number, number, number]; // RGBA (0-1)
origin?: [number, number, number]; // Per-element local-frame origin (see below)
}
Two contracts to respect when consuming MeshData:
origin: when present,positionsare relative to a per-element local frame and the world position of vertexiisorigin + positions[3i..3i+3](this preserves f32 precision on building-scale coordinates). Fold it in by translating the mesh (threeMesh.position.fromArray(mesh.origin)), or by adding it to the vertices when you merge geometry. Absent means positions are already absolute.- Winding order is unreliable: IFC triangle winding is not consistently
outward, so render with
THREE.DoubleSideeven for opaque materials.
You only need @ifc-lite/geometry (and its dependency @ifc-lite/wasm) — skip @ifc-lite/renderer entirely.
Quick Start¶
Scaffold a project¶
Or install manually¶
Convert MeshData to Three.js¶
import * as THREE from 'three';
import type { MeshData } from '@ifc-lite/geometry';
function meshDataToThree(mesh: MeshData): THREE.Mesh {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
'position',
new THREE.BufferAttribute(mesh.positions, 3),
);
geometry.setAttribute(
'normal',
new THREE.BufferAttribute(mesh.normals, 3),
);
geometry.setIndex(new THREE.BufferAttribute(mesh.indices, 1));
const [r, g, b, a] = mesh.color;
const material = new THREE.MeshStandardMaterial({
color: new THREE.Color(r, g, b),
transparent: a < 1,
opacity: a,
// DoubleSide even for opaque: IFC triangle winding is not reliably outward.
side: THREE.DoubleSide,
depthWrite: a >= 1,
});
const threeMesh = new THREE.Mesh(geometry, material);
// Fold the per-element local-frame origin (world = origin + positions).
if (mesh.origin) threeMesh.position.fromArray(mesh.origin);
threeMesh.userData.expressId = mesh.expressId;
return threeMesh;
}
Load and Display¶
import { GeometryProcessor } from '@ifc-lite/geometry';
const processor = new GeometryProcessor();
await processor.init();
const buffer = new Uint8Array(await file.arrayBuffer());
const result = await processor.process(buffer);
for (const mesh of result.meshes) {
scene.add(meshDataToThree(mesh));
}
Streaming (Progressive Display)¶
For large files, use streaming to show geometry as it loads:
for await (const event of processor.processStreaming(new Uint8Array(buffer))) {
switch (event.type) {
case 'batch':
for (const mesh of event.meshes) {
scene.add(meshDataToThree(mesh));
}
// Re-render your Three.js scene here (e.g. webglRenderer.render(scene, camera))
break;
case 'complete':
console.log(`Loaded ${event.totalMeshes} meshes`);
break;
}
}
processAdaptive(buffer) yields the same events and picks the strategy for
you: small files (under 2 MB by default) arrive as a single batch event,
large files go through the streaming or parallel worker path (see the
instancing note under Performance Tips before consuming the parallel path).
Batching for Performance¶
Individual meshes per entity give you picking granularity but many draw calls. For large models, batch by color:
function batchMeshesByColor(
meshes: MeshData[],
): THREE.Mesh[] {
// Group by color
const buckets = new Map<string, MeshData[]>();
for (const m of meshes) {
const key = m.color.join(',');
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key)!.push(m);
}
const result: THREE.Mesh[] = [];
for (const [, group] of buckets) {
// Merge geometry
let totalPos = 0, totalIdx = 0;
for (const m of group) {
totalPos += m.positions.length;
totalIdx += m.indices.length;
}
const positions = new Float32Array(totalPos);
const normals = new Float32Array(totalPos);
const indices = new Uint32Array(totalIdx);
let pOff = 0, iOff = 0, vOff = 0;
for (const m of group) {
// Merging bakes vertices into one buffer, so fold each mesh's
// local-frame origin into the copied positions.
const [ox, oy, oz] = m.origin ?? [0, 0, 0];
for (let i = 0; i < m.positions.length; i += 3) {
positions[pOff + i] = m.positions[i] + ox;
positions[pOff + i + 1] = m.positions[i + 1] + oy;
positions[pOff + i + 2] = m.positions[i + 2] + oz;
}
normals.set(m.normals, pOff);
for (let i = 0; i < m.indices.length; i++) {
indices[iOff + i] = m.indices[i] + vOff;
}
pOff += m.positions.length;
iOff += m.indices.length;
vOff += m.positions.length / 3;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
geo.setIndex(new THREE.BufferAttribute(indices, 1));
const [r, g, b, a] = group[0].color;
const mat = new THREE.MeshStandardMaterial({
color: new THREE.Color(r, g, b),
transparent: a < 1,
opacity: a,
side: THREE.DoubleSide,
depthWrite: a >= 1,
});
result.push(new THREE.Mesh(geo, mat));
}
return result;
}
React-Three-Fiber (R3F)¶
Install¶
IFC Component¶
import { useEffect, useState, useMemo } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import * as THREE from 'three';
import { GeometryProcessor, type MeshData } from '@ifc-lite/geometry';
function IfcMesh({ mesh }: { mesh: MeshData }) {
const geometry = useMemo(() => {
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(mesh.positions, 3));
geo.setAttribute('normal', new THREE.BufferAttribute(mesh.normals, 3));
geo.setIndex(new THREE.BufferAttribute(mesh.indices, 1));
return geo;
}, [mesh]);
const [r, g, b, a] = mesh.color;
return (
<mesh geometry={geometry} position={mesh.origin ?? [0, 0, 0]}>
<meshStandardMaterial
color={new THREE.Color(r, g, b)}
transparent={a < 1}
opacity={a}
side={THREE.DoubleSide}
/>
</mesh>
);
}
function IfcModel({ url }: { url: string }) {
const [meshes, setMeshes] = useState<MeshData[]>([]);
useEffect(() => {
let cancelled = false;
async function load() {
const res = await fetch(url);
const buffer = new Uint8Array(await res.arrayBuffer());
const processor = new GeometryProcessor();
await processor.init();
const result = await processor.process(buffer);
if (!cancelled) setMeshes(result.meshes);
}
load();
return () => { cancelled = true; };
}, [url]);
return (
<group>
{meshes.map((mesh, i) => (
// Index key: one entity can emit several MeshData (one per material/part),
// so expressId alone is not unique.
<IfcMesh key={i} mesh={mesh} />
))}
</group>
);
}
export default function App() {
return (
<Canvas camera={{ position: [20, 15, 20], fov: 50 }}>
<ambientLight intensity={0.6} />
<directionalLight position={[50, 80, 50]} intensity={0.8} />
<IfcModel url="/model.ifc" />
<OrbitControls />
</Canvas>
);
}
Entity Picking¶
Raycasting¶
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
canvas.addEventListener('click', (event) => {
const rect = canvas.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
const hit = intersects[0].object;
const expressId = hit.userData.expressId;
console.log('Selected entity:', expressId);
}
});
Highlight on Hover¶
let hoveredMesh: THREE.Mesh | null = null;
const highlightColor = new THREE.Color(0x4f46e5);
canvas.addEventListener('pointermove', (event) => {
const rect = canvas.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
// Reset previous
if (hoveredMesh) {
const orig = hoveredMesh.userData.originalColor;
(hoveredMesh.material as THREE.MeshStandardMaterial).emissive.copy(orig);
}
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
hoveredMesh = intersects[0].object as THREE.Mesh;
const mat = hoveredMesh.material as THREE.MeshStandardMaterial;
if (!hoveredMesh.userData.originalColor) {
hoveredMesh.userData.originalColor = mat.emissive.clone();
}
mat.emissive.copy(highlightColor);
} else {
hoveredMesh = null;
}
});
Coordinate Handling¶
IFC files may use large georeferenced coordinates. The geometry processor handles this automatically:
const result = await processor.process(new Uint8Array(buffer));
if (result.coordinateInfo.hasLargeCoordinates) {
const shift = result.coordinateInfo.originShift;
console.log(`Coordinates shifted by (${shift.x}, ${shift.y}, ${shift.z})`);
// The shift was SUBTRACTED from the mesh positions, so to recover the
// original file coordinates (e.g. for geolocation) add it back:
function toOriginal(local: THREE.Vector3): THREE.Vector3 {
return new THREE.Vector3(
local.x + shift.x,
local.y + shift.y,
local.z + shift.z,
);
}
}
Performance Tips¶
| Strategy | When to use | Benefit |
|---|---|---|
| Individual meshes | Small models, need picking | Simple, per-entity control |
| Color batching | Medium models (1k-10k entities) | Fewer draw calls |
| LOD | Large models | Reduce triangle count at distance |
| Frustum culling | Large scenes | Only render visible geometry |
Note on instancing: on the single-threaded streaming path, mapped and repeated representations arrive as regular per-element
MeshData. On the parallel worker path (whatprocessAdaptiveuses for large files whenSharedArrayBufferis available), heavily repeated opaque elements are instead packed into per-batchinstancedShardsbuffers on thebatchevent and do NOT appear inevent.meshes. If you consume onlyevent.meshes, construct the processor withnew GeometryProcessor({ enableInstancing: false })so every element stays on the flat path. Alternatively, decode the shards with the exporteddecodeInstancedShard()and buildTHREE.InstancedMeshfrom the templates and per-instance transforms yourself.
Extracting IFC Object Data¶
Geometry alone is just triangles — to build a useful BIM viewer you also need entity attributes, property sets, quantity sets, and the spatial hierarchy. The @ifc-lite/parser package provides all of this from the same IFC buffer, no server required.
Both pipelines run in parallel from the same buffer — geometry streams into the scene while the parser builds its data index in the background:
Building the Data Store¶
The data store scans the raw IFC STEP text once and creates a columnar index for fast lookups. It is pure JavaScript — no WASM required.
import {
IfcParser,
type IfcDataStore,
extractEntityAttributesOnDemand,
extractPropertiesOnDemand,
extractQuantitiesOnDemand,
} from '@ifc-lite/parser';
async function buildDataStore(buffer: ArrayBuffer): Promise<IfcDataStore> {
return new IfcParser().parseColumnar(buffer);
}
Querying Entity Data¶
Once the store is built, extract attributes, property sets, and quantity sets for any entity:
// Attributes (GlobalId, Name, Description, ObjectType, Tag)
const attrs = extractEntityAttributesOnDemand(store, expressId);
console.log(attrs.name); // "Basic Wall:Generic - 200mm"
console.log(attrs.globalId); // "2O2Fr$t4X7Zf8NOew3FLOH"
// Property sets (e.g. Pset_WallCommon → IsExternal, FireRating, …)
const psets = extractPropertiesOnDemand(store, expressId);
for (const pset of psets) {
for (const prop of pset.properties) {
console.log(`${pset.name}: ${prop.name} = ${prop.value}`);
}
}
// Quantity sets (e.g. Qto_WallBaseQuantities → NetSideArea, GrossVolume, …)
const qsets = extractQuantitiesOnDemand(store, expressId);
for (const qset of qsets) {
for (const q of qset.quantities) {
console.log(`${qset.name}: ${q.name} = ${q.value}`);
}
}
Performance
extractEntityAttributesOnDemand re-parses the source buffer on each call. For bulk lookups, use store.entities.getName(id) and store.entities.getTypeName(id) which read from the columnar index in O(1).
Click → Properties Panel¶
Combine picking with data extraction:
canvas.addEventListener('click', (event) => {
const expressId = pickAt(event.clientX, event.clientY);
if (expressId == null || !dataStore) return;
const attrs = extractEntityAttributesOnDemand(dataStore, expressId);
const psets = extractPropertiesOnDemand(dataStore, expressId);
const qsets = extractQuantitiesOnDemand(dataStore, expressId);
// Render attrs, psets, qsets into your UI panel
});
Extracting Spatial Structure¶
The data store includes a pre-parsed spatial hierarchy — the Project → Site → Building → Storey → Elements tree shown in the model tree panel of BIM viewers.
import { type SpatialNode } from '@ifc-lite/data';
const hierarchy = store.spatialHierarchy;
if (hierarchy) {
const project = hierarchy.project; // SpatialNode (root)
// Walk: project.children -> sites -> buildings -> storeys
for (const site of project.children) {
for (const building of site.children) {
for (const storey of building.children) {
console.log(`${storey.name} (${storey.elevation}m) - ${storey.elements.length} elements`);
}
}
}
}
Each SpatialNode has:
expressId,name,type(anIfcTypeEnumvalue: IfcProject / IfcSite / IfcBuilding / IfcBuildingStorey / IfcSpace, ...)elevation— metres above project base (storeys only)children— child spatial containerselements— expressIds of directly contained elements
Storey Lookup and Selection Sync¶
The hierarchy provides a reverse map from any element to its containing storey, enabling two-way sync between the 3D view and a spatial tree UI:
// Find which storey contains an element
const storeyId = hierarchy.elementToStorey.get(expressId);
// 3D click → reveal in tree
function onEntityClicked(expressId: number) {
applyHighlight(meshDataByExpressId.get(expressId));
const storeyId = hierarchy.elementToStorey.get(expressId);
if (storeyId != null) expandTreeNode(storeyId);
highlightTreeRow(expressId);
}
// Tree click → highlight in 3D
function onTreeElementClicked(expressId: number) {
applyHighlight(meshDataByExpressId.get(expressId));
showPropertiesPanel(expressId);
}
For a full implementation of the spatial tree UI (grouping elements by IFC type, search/filter, expand/collapse), see examples/threejs-viewer/src/ifc-data.ts and the renderSpatialPanel() function in src/main.ts.
Complete Data Flow¶
Vite Configuration¶
The COOP/COEP headers enable SharedArrayBuffer, which the geometry worker
pool uses to share the IFC bytes across workers. The workers are ES modules,
so force the ESM worker format for production builds:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
worker: {
format: 'es',
},
optimizeDeps: {
exclude: ['@ifc-lite/wasm'],
},
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
});
server.headers is dev-only
The server.headers block above only applies to Vite's dev server. In
production or preview builds these responses come from your hosting layer or
CDN, so serve the same Cross-Origin-Opener-Policy: same-origin and
Cross-Origin-Embedder-Policy: require-corp headers there too — otherwise
SharedArrayBuffer is unavailable and the worker pool falls back to slower
per-worker copies. (Vite's preview server also honours a matching
preview.headers block.)
Full Example¶
See examples/threejs-viewer/ for a complete, runnable example with geometry streaming, object picking, a properties panel, and a spatial tree — all wired together.
Next Steps¶
- Babylon.js Integration — Same workflow with Babylon.js
- Querying Data — Fluent query API, SQL queries, and direct data access
- Building a Viewer — Full viewer with WebGPU
- Geometry Processing — Geometry API details
- API Reference — Complete API docs