A browser IFC viewer on the xeoIFC WebAssembly library
xeoIFC Web is an IFC viewer that runs in the browser. It loads large IFC files and many files at once, shows them in 3D with the model tree and the properties, finds, measures and cuts, checks BCF, IDS and clashes, and writes a selection or several models to a new IFC file.
The IFC work is done by the xeoIFC WebAssembly library, the browser form of xeoifc.dll. Its engine runs in a
Web Worker: it reads the files, tessellates them, answers queries and writes IFC. Its renderer draws with WebGPU into a
canvas on the page. Nothing is uploaded: the files stay on your computer. This page shows the calls and what the library
can do.
1. The application
This is the viewer itself, not a screenshot: xeoIFC Web has loaded the Viadotto Acerno bridge (IFC 4.3). Rotate, zoom and pan with the mouse, click elements to select them.
In the full viewer, Drag&Drop .ifc or .ifczip files from your machine to
render them locally (no upload of any IFC data).
xeoIFC Web supports
- Loading of any number of files into one scene.
- Selecting elements in the tree view or 3D view, show element properties and property sets/quantities.
- Search for GUIDs, names, types etc.
- Load terrain from public GIS databases around georeferenced IFC models.
- Export of selected elements to a new IFC file (split).
- Export of several loaded files to a new IFC file (merge).
- BCF, Minimap, storey shift, clip planes, measuring tool, compare revisions tool.
- File types: .ifc, .ifczip, .jt, .stp, .step, .glb, .bcf, .bcfzip, .las, .laz
2. Download the WebAssembly library
Download xeoifc-wasm.zip from the releases page
for integration into your own application. It contains the prebuilt WebAssembly modules, generated JavaScript bindings,
TypeScript declarations, package metadata, licence, third-party notices and a short integration README.
The xeoIFC Web viewer application is not included. To view models directly, use the hosted viewer.
3. Using the library
The library is one module with two faces: engine functions that a Web Worker calls, and the Viewer class on
the page. Control is JSON; the model crosses from the worker to the page as one transferable buffer. The excerpts are
from src/worker.ts, the whole bridge to the engine, and src/main.ts, shortened.
Load a file in the worker
The worker is a second instance of the same module. It gets the file in chunks and gives each chunk to the engine at
once, so the text is never whole in memory. worker_load_finish tessellates the model and returns it as one
packed buffer.
// src/worker.ts
import init, { worker_load_begin, worker_load_chunk, worker_load_finish } from './pkg/viewer_wasm';
const post = self.postMessage.bind(self) as (msg: unknown, transfer?: Transferable[]) => void;
const ready = init();
const onProgress = (phase: string, done: number, total: number) => {
post({ type: 'progress', phase, done, total }); // phases: "parse", "geom", "pack"
};
const onSnapshot = (packed: Uint8Array) => {
post({ type: 'snapshot', packed }, [packed.buffer]); // the scene so far, while the file loads
};
self.onmessage = async (e: MessageEvent<WorkerRequest>) => {
await ready;
const msg = e.data;
switch (msg.type) {
case 'load-begin':
worker_load_begin(msg.circleSegments, msg.renderSharpEdges, msg.clearExisting, msg.orphanedElementsMode);
break;
case 'load-chunk':
worker_load_chunk(new Uint8Array(msg.buffer));
post({ type: 'chunk-ok' }); // the page can send the next chunk
break;
case 'load-finish': {
const out = worker_load_finish(onProgress, onSnapshot); // {packed, tree, sourceId, lineLayers, ..}
post({ type: 'loaded', packed: out.packed, tree: out.tree, sourceId: out.sourceId }, [out.packed.buffer]);
break;
}
}
};
The page reads the file as a stream and sends it in chunks of 8 MB. Each chunk is transferred, not copied.
// src/main.ts (ParserWorker.loadStream)
worker.postMessage({
type: 'load-begin',
circleSegments: settings.circleSegments, // 18
clearExisting, // true: replace the loaded models
orphanedElementsMode: settings.orphanedElementsMode, // 'ignore', 'ifc-project' or 'ifc-site'
renderSharpEdges: settings.renderSharpEdges,
});
const CHUNK = 8 * 1024 * 1024;
const reader = source.stream().getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
queued.push(value);
queuedBytes += value.length;
if (queuedBytes >= CHUNK) await flush(); // one ArrayBuffer: postMessage({ type: 'load-chunk', buffer }, [buffer]),
} // then wait for 'chunk-ok'
await flush();
worker.postMessage({ type: 'load-finish' });
Draw into a canvas
The renderer uses wgpu on WebGPU and draws into a canvas of the page. It draws only when something changed: when the view
does not move, frame() returns false at once.
// src/main.ts
import init, { Viewer } from './pkg/viewer_wasm';
await init();
viewer = await Viewer.create(canvas); // canvas.width and height in device pixels
// While the file loads: the snapshots from the worker
const onSnapshot = (packed: Uint8Array): void => {
if (clearExisting) viewer.set_packed_scene(packed);
else viewer.preview_append_scene(packed); // over the models that are already loaded
};
// The loaded model. The result pairs each entity id with a viewer handle for picking, selection and visibility.
const elementHandles = viewer.set_packed_scene(result.packed); // [entity id, handle, entity id, handle, ..]
// Mouse: viewer.orbit, pan_start / pan_to / pan_end, zoom_pick. Resize: viewer.resize(width, height, devicePixelRatio).
const loop = (): void => {
viewer.frame();
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
Pick an element and show its properties
// src/main.ts: a click at (x, y) in device pixels, like the canvas
const rect = canvas.getBoundingClientRect();
const x = Math.round((e.clientX - rect.left) * devicePixelRatio);
const y = Math.round((e.clientY - rect.top) * devicePixelRatio);
const id = await viewer.pick(x, y); // the viewer handle, or undefined for the background
if (id !== undefined) {
viewer.select(id, e.ctrlKey || e.metaKey); // ctrl adds to the selection
const source = elementSources.get(id); // { sourceId, entityId }, from the handle pairs
// {"type":"IfcWall","globalId":"2C1kfNlluuvc_DT_K$K6Bj","name":"Front wall between the rooms",..}
const info = await parser.query('element_info', source.sourceId, source.entityId);
// [{"pset":"Pset_WallCommon","props":[{"name":"IsExternal","value":"T","unit":null}]}]
const properties = await parser.query('properties', source.sourceId, source.entityId);
}
// src/worker.ts: the 'query' message
value = msg.method === 'element_info'
? worker_element_info(msg.sourceId, msg.id)
: worker_properties(msg.sourceId, msg.id);
post({ type: 'result', reqId: msg.reqId, value });
Query and export
The document API is one function, worker_call: a JSON request in, a JSON response out, with the same
operations and error codes as xeoifc_call of the native library and the command-line tool. The loaded
models are the documents s1, s2, ... These calls find all external walls and write three of
them to a new IFC file.
// src/worker.ts: the 'call' message
post({ type: 'result', reqId: msg.reqId, value: worker_call(msg.request) });
// src/main.ts
const found = await parser.call({ op: 'query.select', doc: 's1', args: {
text: 'is IfcWall and Pset_WallCommon.IsExternal = true',
select: ['name', 'storey.Name'], includeRefs: true, limit: 1000 } });
// {"ok":true,"result":{"count":14,"items":[{"name":"Front wall between the rooms","storey.Name":"Ground floor"},..],
// "refs":["#1022","#1113","#1218",..],..},..}
The viewer keeps only metadata after tessellation. So the export reads the file again, in a worker of its own that ends
afterwards. More files before export-run merge the models.
// src/main.ts (runIfcExport). selection = entity ids ("#1022" is 1022); empty = the whole file.
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
worker.postMessage({ type: 'export-reset' });
worker.postMessage({ type: 'export-chunk', buffer }, [buffer]); // the source file again, in chunks of 8 MB
worker.postMessage({ type: 'export-file-done', selection: Uint32Array.of(1022, 1113, 1218) });
worker.postMessage({ type: 'export-run', licenceKey, bounds });
// 'export-done' brings the IFC file, then worker.terminate()
// src/worker.ts
case 'export-reset': worker_export_reset(); break;
case 'export-chunk': worker_export_chunk(new Uint8Array(msg.buffer)); post({ type: 'chunk-ok' }); break;
case 'export-file-done': worker_export_file_done(msg.selection); post({ type: 'export-file-ok' }); break;
case 'export-run': {
const out = worker_export_run(msg.licenceKey, Date.now() / 1000, msg.bounds ? Float64Array.from(msg.bounds) : undefined);
post({ type: 'export-done', data: out.data, warnings: out.warnings, entities: out.entities }, [out.data.buffer]);
break;
}
The same in a script
The Script panel (in the Lab menu) runs JavaScript with the xeo API of xeoifc run, the API that
made the villa, in a sandboxed worker. Document calls go to worker_call, and their
changes reach the model only on Apply; xeo.viewer calls go to the Viewer. These two lines colour
the external walls red:
const walls = await xeo.query.select({ text: 'is IfcWall and Pset_WallCommon.IsExternal = true',
select: ['name', 'storey.Name'], includeRefs: true, limit: 1000 });
await xeo.viewer.color({ refs: walls.refs, color: 'red' });
4. What the library can do
| Document API | 111 operations in 27 groups through worker_call: queries, entities, property and
quantity sets, materials, classifications, types, spatial structure, placements, new elements with openings,
transactions with undo and redo, validation, diff and merge. api.describe returns the whole API as JSON or
as TypeScript declarations. A wrong call gets an answer with a hint, for example the allowed values of an
enumeration. |
|---|---|
| Queries | is IfcWall and Pset_WallCommon.IsExternal = true and storey.Name = "Level 1", with
columns such as name, globalId, Pset_WallCommon.FireRating or
storey.Name |
| Files | .ifc and .ifczip, IFC 2x3, IFC 4 and IFC 4.3, streamed in chunks. Separate
modules, fetched on first use: JT, STEP CAD, point clouds (LAS, LAZ, COPC, E57, PLY, XYZ, PTS) and meshes (OBJ, STL,
PLY, RVM, LandXML) |
| Split and merge | worker_export_run writes a selection, a whole model or several models to one
IFC file, with the same writer as the command-line tool and the native library |
| Loading | In a Web Worker, with progress, a preview while the file loads, and cancel. Any number of files into one scene. After tessellation the model is pruned to its metadata, so large models fit into the 4 GiB of a wasm instance. |
| Renderer | wgpu on WebGPU, into a canvas. Many models in one scene, each with its own placement. Pick, rectangle pick and hover; selection, visibility, transparency, colours and clip planes. Camera: orbit about the point under the cursor, anchored pan, zoom to the cursor, fit, view directions, orthographic, save and restore. Sharp edges, 5 drafting line layers, point clouds and terrain. |
| Interface | One ES module with TypeScript declarations: 22 engine functions and the Viewer
class with 76 methods. No framework. |
| Application | xeoIFC Web, TypeScript: model tree, properties, search, measure, section planes, 2D section drawings, storey shift, minimap, walk and saved views, BCF, IDS, clash check, compare revisions, properties as a table, GIS terrain, GLB and XKT readers. Lab: heat flow, script editor, AI assistant with your own API key. |
| Browsers | Chrome and Edge 113 or newer. On Linux, WebGPU can need
chrome://flags/#enable-unsafe-webgpu. |
5. Licence
The xeoIFC WebAssembly library is licensed under the
xeoIFC Testing License: you can use and redistribute it unmodified,
in binary form and free of charge, solely for testing and
evaluation. For commercial or production use, contact xeoFoundry. All functions
work without a licence key. Without a key, each IFC file that the library writes gets one more element: an
IfcBuildingElementProxy with the text "xeoIFC evaluation version" next to the model. A licence key from
xeoFoundry removes it. In xeoIFC Web, enter the key in the Settings panel. In your
own worker, set the key for the saves of worker_call and give it to each export:
// saves of worker_call; returns {"state":"licensed"|"evaluation"|"invalid"|"expired","expires":..}
worker_set_licence_key('YOUR-KEY', Date.now() / 1000);
// exports: the key is the first argument
worker_export_run('YOUR-KEY', Date.now() / 1000, bounds);