Three.js From Zero · Article s11-09

S11-09 IFC.js BIM Viewer in 200 Lines

Season 11 · Article 09

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:

aspectglTFIFC
Primary purposerender-ready 3Ddata-rich 3D
File size (10-floor office)~5-30 MB~80-500 MB
Carries materials/PBRyespoorly (see below)
Carries quantities, schedules, suppliersnoyes — that's the point
Browser-native loaderGLTFLoaderweb-ifc WASM
Edit round-trip with Revitbrokensupported

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.

IFC carries materials only as text (e.g. "C30 concrete"), not PBR. Visual quality demands separate art-direction passes — paint your scene in glTF if you need photoreal, then layer IFC selectability on top.

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

Click an element to inspect.

6. The selection-to-property pipeline

Three primitives are doing all the work in the demo:

  1. Per-mesh metadata — every Object3D has .userData = { expressID, ifcType, psets }. In a real loader, that comes from getItemProperties(). Here we synthesize it at construction.
  2. Raycaster on click — same primitive as in any S1-06 tutorial. The hit returns the Mesh; userData is one read away.
  3. 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.

scaletechniquelibrary
< 50k elementsraw IFCLoader, one mesh eachweb-ifc-three
50k - 500k elementsfragments (instanced by IFC type)That Open FragmentsManager
500k - 5M elementsXKT streaming + spatial chunks + LODxeokit (own engine, not Three.js)
5M+ elementsfederated XKT, server-side culling, level streamingxeokit / 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-ifc WASM parses IFC in the browser; @thatopen/components wraps 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.