Three.js From Zero · Article s11-09
S11-09 IFC.js BIM Viewer in 200 Lines
IFC.js BIM Viewer in 200 Lines — clickable elements, property panels, layer toggles
AEC firms run multi-million-dollar projects through BIM models. The browser stack — web-ifc + Three.js — turns those models into something a project manager can review on a phone. Here is the smallest working viewer plus the production-scale version above it.
1. Why the browser is suddenly the BIM hot zone
BIM = Building Information Modeling. Architects, engineers, and contractors author geometry alongside metadata — beam steel grade, wall fire rating, supplier SKU, schedule activity. The IFC file format (Industry Foundation Classes, ISO 16739) is the open exchange standard, and most authoring tools (Revit, ArchiCAD, Tekla, Allplan) export it.
For 20 years the only viewer was a desktop app: Solibri, Navisworks, BIMvision, Forge Viewer. AEC firms hand laptops to clients, vendors, and inspectors just to navigate a model. That's about to flip — three things changed:
- web-ifc ships a WASM IFC parser fast enough to load a real building (10-100 MB IFC) in under 30 seconds.
- Three.js renders the resulting geometry at 60 FPS on a mid-tier laptop or recent phone.
- That Open Company (formerly IFC.js) wraps both into
@thatopen/components— selection, clipping, dimensions, fragments, all as Three.js Object3D's.
The market opportunity: most AEC firms are quietly looking for a "share a model link" feature. Forge Viewer charges per API call. xeokit is AGPL-encumbered. A pure Three.js viewer is the unblocked path.
2. IFC vs glTF — pick the right tool
Both are 3D file formats. They overlap on geometry. They diverge on everything else:
| aspect | glTF | IFC |
|---|---|---|
| Primary purpose | render-ready 3D | data-rich 3D |
| File size (10-floor office) | ~5-30 MB | ~80-500 MB |
| Carries materials/PBR | yes | poorly (see below) |
| Carries quantities, schedules, suppliers | no | yes — that's the point |
| Browser-native loader | GLTFLoader | web-ifc WASM |
| Edit round-trip with Revit | broken | supported |
Common production pattern: store IFC as the source of truth, ship glTF for fast public viewing, fall back to IFC when someone clicks "Show properties" on a beam. The That Open FragmentsManager is essentially a glTF-style binary derived from IFC, with the IFC GUIDs preserved so property reads stay live.
3. The minimum viewer (web-ifc, raw)
Below is what the actual web-ifc + Three.js loop looks like. We're not running this in the demo because hosting a real .ifc blob CORS-correctly is finicky for a single-page tutorial — but every line is real and copy-pastable into a project with a bundler.
// npm i web-ifc-three three
import * as THREE from 'three';
import { IFCLoader } from 'web-ifc-three/IFCLoader';
const loader = new IFCLoader();
// WASM lives outside JS — point at the asset
loader.ifcManager.setWasmPath('https://unpkg.com/[email protected]/');
// Load
const model = await loader.loadAsync('/model.ifc');
scene.add(model);
// Pick an element
const ray = new THREE.Raycaster();
canvas.addEventListener('click', async (e) => {
ray.setFromCamera(mouse, camera);
const hit = ray.intersectObject(model)[0];
if (!hit) return;
// The trick: faceIndex → expressID → IFC properties
const expressID = loader.ifcManager.getExpressId(model.geometry, hit.faceIndex);
const props = await loader.ifcManager.getItemProperties(model.modelID, expressID);
const type = await loader.ifcManager.getIfcType(model.modelID, expressID);
console.log(type, props); // e.g. "IFCWALLSTANDARDCASE" + Pset_WallCommon
});
That's the entire core. Everything after is UX: highlighting the selection, building the property panel, filtering by IFC type. The property graph in IFC is genuinely deep — a single wall element resolves into a property set tree with quantities, materials, classification, schedule activity. getPropertySets() walks one level; getMaterialsProperties(), getSpatialStructure() walk others.
4. The "That Open Components" engine — production version
For anything beyond toy models, jump straight to @thatopen/components. It layers an ECS pattern on Three.js:
// npm i @thatopen/components @thatopen/components-front
import * as OBC from '@thatopen/components';
const components = new OBC.Components();
const world = components.get(OBC.Worlds).create();
world.scene = new OBC.SimpleScene(components);
world.camera = new OBC.OrthoPerspectiveCamera(components);
world.renderer = new OBC.SimpleRenderer(components, container);
// Streamed fragments (compressed, instanced)
const fragments = components.get(OBC.FragmentsManager);
const ifcLoader = components.get(OBC.IfcLoader);
await ifcLoader.setup();
const buffer = await fetch('/model.ifc').then(r => r.arrayBuffer());
const model = await ifcLoader.load(new Uint8Array(buffer));
world.scene.three.add(model);
// Highlighter component → instant selection styling
const hl = components.get(OBCF.Highlighter);
hl.setup({ world });
hl.events.select.onHighlight.add((data) => {
// data is { fragmentID: ExpressID[] }; fetch props on demand
});
Why use this layer: fragments. Raw IFC geometry is one mesh per element, hundreds of thousands of meshes on a real building, draw-call apocalypse. FragmentsManager bins by IFC type and instances aggressively — the same beam profile becomes one InstancedMesh with 800 transforms. That's the difference between 4 FPS and 60 FPS.
5. Live demo — a procedural BIM-style scene
Click an element. Read its properties. Toggle layers (walls / windows / doors / floors). The geometry is procedural here so the article runs offline; the interaction model is identical to a real IFC viewer — every Object3D carries an expressID, an IFC type, and a Pset_* property bag.
Properties
6. The selection-to-property pipeline
Three primitives are doing all the work in the demo:
- Per-mesh metadata — every Object3D has
.userData = { expressID, ifcType, psets }. In a real loader, that comes fromgetItemProperties(). Here we synthesize it at construction. - Raycaster on click — same primitive as in any S1-06 tutorial. The hit returns the Mesh;
userDatais one read away. - Selection highlight — clone the material and set emissive, or swap to a "selected" material. We restore the original on deselect.
// userData carries the IFC contract
mesh.userData = {
expressID: 12834,
ifcType: 'IFCWALLSTANDARDCASE',
category: 'wall',
psets: {
Pset_WallCommon: {
FireRating: '90 min', LoadBearing: false, IsExternal: true,
},
Pset_QuantityArea: { NetSideArea: 24.5, GrossSideArea: 25.0 },
},
};
raycaster.setFromCamera(mouse, camera);
const hit = raycaster.intersectObjects(scene.children, true)[0];
if (hit) showProps(hit.object.userData);
7. Layer / category filtering
Every element has an IFC type. Toggling "walls off" is just visiting all meshes whose type starts with IFCWALL and flipping .visible. In production you index those into Three.js Groups at load time:
const groups = {
IFCWALL: new THREE.Group(),
IFCWINDOW: new THREE.Group(),
IFCDOOR: new THREE.Group(),
IFCSLAB: new THREE.Group(),
IFCROOF: new THREE.Group(),
};
for (const k in groups) scene.add(groups[k]);
// On load, route mesh into its group by IFC type prefix
groups[matchIfcRoot(mesh.userData.ifcType)].add(mesh);
// Toggling a category is one boolean
groups.IFCWALL.visible = !groups.IFCWALL.visible;
The same pattern drives property-based filters — "show only fire-rated 90 min walls" is a traversal that flips .visible based on a Pset read. Try it in the demo.
8. Performance — BIM models are a different beast
A medium hospital is 2-5 million distinct elements; an airport master plan can hit 50 million. Three.js rendering caps out long before that without help.
| scale | technique | library |
|---|---|---|
| < 50k elements | raw IFCLoader, one mesh each | web-ifc-three |
| 50k - 500k elements | fragments (instanced by IFC type) | That Open FragmentsManager |
| 500k - 5M elements | XKT streaming + spatial chunks + LOD | xeokit (own engine, not Three.js) |
| 5M+ elements | federated XKT, server-side culling, level streaming | xeokit / Forge / hosted |
Two non-obvious wins on real data:
- BVH everything. Without
three-mesh-bvh, raycasting on a multi-million-element scene is unworkable. Build the BVH once at load time; selection becomes O(log n) instead of O(n). - Don't decode property sets eagerly. First click latency comes from
getItemProperties(). Cache by expressID; lazy-load on selection. The user feels 50ms of "loading…" which is fine; eagerly decoding all properties at load adds 30 seconds of stall.
9. xeokit when Three.js taps out
For projects past 1M elements or with federated IFCs (20 building IFCs combined into one master), xeokit is still ahead of Three.js+web-ifc. Its XKT format is a custom binary that carries pre-computed Octree spatial indexes; it can stream a 50M-element model into a viewport of 20k visible elements at any moment. The trade-off:
- Not Three.js. Adopting xeokit means a parallel canvas (or fully replacing your engine).
- AGPLv3 license — commercial use needs a paid license unless you open-source your front-end.
- The XKT pipeline (
xeokit-convert) is a Node tool you run server-side; it's not a browser-runtime parser like web-ifc.
Practical 2026 guidance: start with That Open Components on Three.js. Move to xeokit only if you measure perf hitting a wall.
10. Takeaways
- BIM = geometry + metadata. The metadata is the entire point — that's why glTF doesn't replace IFC.
web-ifcWASM parses IFC in the browser;@thatopen/componentswraps it in Three.js with selection, fragments, and tools.- Selection pipeline = raycast → faceIndex → expressID → property set.
- Layer toggles = bin meshes into Groups by IFC type prefix.
- For 1M+ elements, switch to xeokit + XKT (federated streaming, AGPL).
- Property-set reads are async and slow — lazy-load, never eager.