Geometry Processing¶
Guide to geometry extraction and processing in IFClite.
Overview¶
All geometry is produced by a single Rust pipeline (produce_element_meshes
in the processing crate), whether it runs through the browser WASM build, the
worker pool, or a native host. Every consumer therefore gets identical
per-element meshes, and fixes to the mesher land once for all paths.
IFClite processes IFC geometry through a streaming pipeline:
Tessellation Quality¶
Curved geometry (swept pipes, cylinders, fillets, NURBS patches) is
approximated with straight segments. The detail level is selectable per
GeometryProcessor — no WASM rebuild needed:
| Level | Curved-surface segment density | Profile circles (opening cutters / caps) | Use case |
|---|---|---|---|
'lowest' |
×0.25 | max 8 segments | Maximum throughput, previews |
'low' |
×0.5 | max 16 segments | Mobile, large federated models |
'medium' (default) |
×1 — historical densities | 36 segments | General use |
'high' |
×2 | 36 (never finer) | Smooth pipes / cylinders |
'highest' |
×4 | 36 (never finer) | Close-up curved detail |
import { GeometryProcessor } from '@ifc-lite/geometry';
// At construction…
const geometry = new GeometryProcessor({ tessellationQuality: 'high' });
await geometry.init();
// …or at runtime, BEFORE processing (already-emitted meshes are not
// regenerated — reload the model to apply a new level):
geometry.setTessellationQuality('low');
const result = await geometry.process(new Uint8Array(buffer));
The same knob exists on the raw WASM API for consumers driving
processGeometryBatch directly:
import { IfcAPI } from '@ifc-lite/wasm';
const api = new IfcAPI();
api.setTessellationQuality('highest'); // applies to subsequent batches
Performance trade-off. Triangle count and processing time on
curved-heavy models scale roughly with the density multiplier: 'highest'
can quadruple the triangles of a pipe-rack model, 'lowest' quarters them.
Boxy architectural models (extrusions, breps) are barely affected — only
curved tessellation scales.
Guarantees:
- Leaving the level unset (or passing
'medium'/null) produces output byte-for-byte identical to previous releases — upgrading is safe. - Segment counts rise monotonically with the level (never fewer triangles at a higher level).
- Profile-plane outlines (extruded caps and opening cutters) never get
finer than
'medium'— denser opening circles only multiply earcut cap-bridge slivers on plates with bolt holes. They do coarsen below'medium'for preview levels. - WASM paths only (main-thread, streaming and worker pool); the native Tauri pipeline does not consume the level yet.
Mesh Data Structure¶
Winding order is not outward-guaranteed
Triangle winding in IFC-derived meshes is unreliable by design: source
breps and CSG results do not guarantee outward-facing triangles. Renderers
must draw double-sided (cullMode: 'none' / DoubleSide) and must not use
winding for front/back-face decisions; shade with
abs(dot(normal, viewDir)) or depth testing instead. The bundled
@ifc-lite/renderer already does this.
Accessing Mesh Data¶
import { GeometryProcessor } from '@ifc-lite/geometry';
const geometry = new GeometryProcessor();
await geometry.init();
const result = await geometry.process(new Uint8Array(buffer));
// Get all meshes
for (const mesh of result.meshes) {
console.log(`Entity #${mesh.expressId}:`);
console.log(` Vertices: ${mesh.positions.length / 3}`);
console.log(` Triangles: ${mesh.indices.length / 3}`);
console.log(` Color: rgba(${mesh.color.join(', ')})`);
}
// Find mesh by entity ID
const wallMesh = result.meshes.find(m => m.expressId === wallId);
// Precomputed model bounds (from coordinate info on the result)
const bounds = result.coordinateInfo.shiftedBounds;
console.log(`Model bounds:`, bounds);
Drilling from a Mesh Back to its Source Item¶
An element's expressId names the wall; it does not name the piece of the wall
you just clicked. MeshData.geometryItemId does. It is the STEP express id of
the IfcRepresentationItem the mesh was tessellated from, so on a wall built
from several solids each mesh names its own IfcExtrudedAreaSolid,
IfcFacetedBrep or IfcBooleanResult. Because it is a STEP instance name, it resolves
against the same entity index as any other id, so you can read the source line
back out of the file:
import type { MeshData } from '@ifc-lite/geometry';
/** The raw STEP text of the representation item a mesh was tessellated from. */
function sourceItemText(mesh: MeshData): string | undefined {
// Absent is a real state; see "When it is absent" below. It is never `0`.
if (mesh.geometryItemId === undefined) return undefined;
// Representation items are indexed in `entityIndex.byId` like every other
// entity. They are NOT in the entity TABLE (the columnar parser classifies
// them as skip-category and never materialises them), so
// `store.entities.getTypeName(...)` has nothing to return for one. Take the
// type off the ref instead.
const ref = store.entityIndex.byId.get(mesh.geometryItemId);
if (!ref) return undefined;
console.log(`#${mesh.expressId} came from #${ref.expressId} (${ref.type})`);
return store.source.decodeUtf8(ref.byteOffset, ref.byteOffset + ref.byteLength);
}
for (const mesh of result.meshes) {
const text = sourceItemText(mesh);
if (text) console.log(text);
}
geometryItemId is disjoint from materialId, the other source id on
MeshData. materialId is an IfcMaterial express id, set on the meshes that
slice a wall or slab by material layer. The two are never both set, and they
name different classes of entity, so neither is a fallback for the other:
following materialId as though it were a representation item lands on the
wrong entity, which is exactly the confusion the split fixed (#3199).
When it is absent¶
geometryItemId is undefined when there is no id to carry, never 0. STEP
instance names start at #1, so a zero can only be a producer's own "no
reference" sentinel, and the pipeline filters it to absent at the single place
both source ids are set.
Absence means "no item identity is available here", not "this geometry has no source item". The item existed; the mesh you are holding was merged from more than one of them, so no single id describes it. The cases:
- The single merged mesh fallback. The element was emitted as one mesh for its whole body rather than one mesh per representation item.
- The cached
IfcMappedItempath. A representation map's items were merged into one template, so the template names none of them individually. - A colour-merged batch. Many entities' vertices share one
MeshData, identified per-vertex byentityIds; one item id cannot name many entities' geometry.
A material-layer slice is a fourth kind of absence with a different reading: it
carries materialId instead, so the identity is present but is a material's,
not an item's.
Streaming Geometry¶
Process geometry incrementally for large files:
Streaming Example¶
import { GeometryProcessor } from '@ifc-lite/geometry';
import { Renderer } from '@ifc-lite/renderer';
const geometry = new GeometryProcessor();
await geometry.init();
const renderer = new Renderer(canvas);
await renderer.init();
// Stream geometry progressively
for await (const event of geometry.processStreaming(new Uint8Array(buffer))) {
switch (event.type) {
case 'start':
console.log('Starting geometry extraction');
break;
case 'batch':
// Upload meshes to GPU as they arrive
renderer.addMeshes(event.meshes, true); // isStreaming = true
// Render current state
renderer.render();
console.log(`Meshes so far: ${event.totalSoFar}`);
break;
case 'complete':
// Finalize rendering
renderer.fitToView();
console.log(`Complete: ${event.totalMeshes} meshes`);
break;
}
}
Parallel and Adaptive Processing¶
On multi-core machines with SharedArrayBuffer available (a
cross-origin-isolated page), the processor can fan geometry out to a pool of
Web Workers, each with its own WASM instance processing a disjoint slice of
the element list. Batches are yielded as they arrive from any worker:
// Explicit worker-pool streaming
for await (const event of geometry.processParallel(new Uint8Array(buffer))) {
if (event.type === 'batch') renderer.addMeshes(event.meshes, true);
}
processAdaptive() is the recommended entry point: it picks the best path
automatically. Small files (below a 2 MB threshold by default) are processed
in one shot for instant display; larger files use the parallel worker pool
when available, falling back to single-worker streaming otherwise:
for await (const event of geometry.processAdaptive(new Uint8Array(buffer))) {
switch (event.type) {
case 'batch':
// Note: multiple meshes may share an expressId (one per material/part);
// group by expressId for per-element rendering or picking.
renderer.addMeshes(event.meshes, true);
break;
case 'complete':
renderer.fitToView();
break;
}
}
The worker count is chosen by a cores/memory heuristic; geometry output is identical regardless of the count (workers process deterministic, disjoint slices).
Coordinate Handling¶
IFC files often use large georeferenced coordinates that cause precision issues:
Auto Origin Shift¶
The geometry processor automatically handles large coordinates:
import { GeometryProcessor } from '@ifc-lite/geometry';
const geometry = new GeometryProcessor();
await geometry.init();
const result = await geometry.process(new Uint8Array(buffer));
// Access the computed shift from coordinate info (returned on the result)
const coordInfo = result.coordinateInfo;
if (coordInfo?.originShift) {
console.log(`Origin shifted by:`, coordInfo.originShift);
// { x: 487234.5, y: 5234891.2, z: 0 }
}
// Convert local coordinates back to world
function toWorldCoords(localPos: Vector3, shift: Vector3): Vector3 {
return {
x: localPos.x + shift.x,
y: localPos.y + shift.y,
z: localPos.z + shift.z
};
}
Geometry Processors¶
Extrusion Processor¶
Handles IfcExtrudedAreaSolid entities:
Brep Processor¶
Handles IfcFacetedBrep (and tessellated face-set) entities in Rust. Each
face is projected to its plane and triangulated: simple quads take a fast fan
path, faces with holes go through polygon triangulation with hole support
(falling back to a fan if that fails).
Boolean Operations¶
Handles IfcBooleanClippingResult and opening voids (IfcRelVoidsElement).
Void cutting is a single exact-CSG path in the Rust kernel: boolean
differences are evaluated with exact arithmetic predicates, so opening cuts
are watertight rather than approximated.
Batching¶
The renderer automatically groups geometry by colour into a small number of
batched draw calls (one BatchedMesh per colour group), so a model with many
repeated elements still renders in a handful of draws — no manual step:
import { GeometryProcessor } from '@ifc-lite/geometry';
const geometry = new GeometryProcessor();
await geometry.init();
const result = await geometry.process(new Uint8Array(buffer));
// The renderer batches by colour when you load the meshes.
renderer.loadGeometry(result);
In addition, repeated opaque geometry (e.g. Tekla-style bolt/part repetition)
can be routed to a GPU-instancing path: the streaming batch events carry
packed instanced shards when the processor's enableInstancing option is on
(the default). Federated multi-model loads should pass
enableInstancing: false, since the renderer's instanced path is
primary-model only. See the Rendering Guide for how shards are
uploaded.
Item Ids on Instanced Occurrences¶
Instanced occurrences carry geometryItemId too, so instancing is no longer a
reason to lose the drill-to-source link. Do not turn instancing off to get
item ids: that trade no longer exists, and it costs you the draw-call win for
nothing.
On the wire the id rides the shard: decodeInstancedShard returns each
occurrence with an optional itemId, and the renderer's prepareInstancedRender
turns that into a per-template itemIds column parallel to entityIds. An
occurrence that renders flat because it fell below the instancing threshold
keeps its id as an ordinary MeshData.geometryItemId, so both halves of a model
answer the same question.
DecodedInstancedShard.carriesItemIds is how you tell "this shard declares no
ids at all" from "this one piece has no item":
import { decodeInstancedShard } from '@ifc-lite/geometry';
const shard = decodeInstancedShard(bytes);
if (!shard.carriesItemIds) {
// Nothing in this shard names an item. Every occurrence's `itemId` is
// undefined for the SAME reason, so don't report per-piece "unknown source".
} else {
for (const occurrence of shard.instances) {
// Here an undefined `itemId` is about this occurrence, not the shard.
if (occurrence.itemId !== undefined) {
console.log(`#${occurrence.entityId} <- item #${occurrence.itemId}`);
}
}
}
The flag is derived from the shard's declared instance-record stride, and the
encoder derives that stride from the data (it only widens the record when some
occurrence actually names an item), so false genuinely means "no occurrence
here has one", not "this build cannot see them".
A warm cache can show absence that is about the cache
Instanced shards are persisted into the binary cache as raw wire bytes and
replayed verbatim, without a re-encode. A shard written by a build from
before item ids shipped is a v1 shard: it declares the narrow stride, so
carriesItemIds is false and every occurrence in it reports no item,
even though the current pipeline would produce ids for that same model. The
decoders read such a shard rather than refusing it, so a host with a warm
cache can see absence caused by the cache rather than by the geometry.
In this release those entries are invalidated anyway: the cache
FORMAT_VERSION moves 15 → 16, so every entry misses once and re-meshes.
That bump is not about reading old shards — it is about not handing NEW ones
to an OLD bundle. The cache key carries FORMAT_VERSION, and a build from
before this change refuses any shard version but 1 while the streaming
loader swallows the error, which would silently drop every instanced
occurrence under deploy skew or a rollback. After the one re-mesh, a warm
cache holds v2 shards wherever an occurrence actually names an item. A
batch where none does is still written at the base stride as version 1,
so re-meshing does not turn every shard into a v2 one.
Performance Optimization¶
Memory-Efficient Processing¶
Use streaming for large files:
import { GeometryProcessor } from '@ifc-lite/geometry';
const geometry = new GeometryProcessor();
await geometry.init();
// Stream geometry in batches
for await (const event of geometry.processStreaming(new Uint8Array(buffer), undefined, 50)) {
if (event.type === 'batch') {
renderer.addMeshes(event.meshes, true);
console.log(`Meshes so far: ${event.totalSoFar}`);
}
}
Filtering Geometry¶
To only render specific entity types, filter the meshes after processing:
import { IfcParser } from '@ifc-lite/parser';
import { GeometryProcessor } from '@ifc-lite/geometry';
const parser = new IfcParser();
const store = await parser.parseColumnar(buffer);
// Get expressIds for types you want
const wantedIds = new Set([
...(store.entityIndex.byType.get('IFCWALL') ?? []),
...(store.entityIndex.byType.get('IFCDOOR') ?? []),
...(store.entityIndex.byType.get('IFCWINDOW') ?? [])
]);
// Process all geometry
const geometry = new GeometryProcessor();
await geometry.init();
const result = await geometry.process(new Uint8Array(buffer));
// Filter meshes
const filteredMeshes = result.meshes.filter(m => wantedIds.has(m.expressId));
renderer.loadGeometry(filteredMeshes);
Geometry Statistics¶
import { GeometryProcessor } from '@ifc-lite/geometry';
const geometry = new GeometryProcessor();
await geometry.init();
const result = await geometry.process(new Uint8Array(buffer));
// Totals are precomputed on the result
console.log('Geometry Statistics:');
console.log(` Total meshes: ${result.meshes.length}`);
console.log(` Total triangles: ${result.totalTriangles}`);
console.log(` Total vertices: ${result.totalVertices}`);
Next Steps¶
- Rendering Guide - Display geometry with WebGPU
- Parsing Guide - Parse options and streaming
- API Reference - Complete API docs