Skip to content

JavaScript and TypeScript cookbook ​

Task-sized recipes for @openbim/ifc, the WebAssembly build of the IFC core. Each recipe is a region of crates/openbim-ifc-wasm/tests/js/cookbook.mjs, which runs on every pull request against the built package and checks what the recipe prints, so the code below is the code that ran. The comments show its output for the fixtures it reads: test/fixtures/synthetic-properties/synthetic_properties.ifc (two walls on one storey, with their property sets) and test/fixtures/synthetic-bindings/binding_geometry.ifc (placed, extruded products).

For the full surface, see the binding page and the TypeScript API reference; to try the library without installing anything, open the playground.

Set up ​

bash
npm install @openbim/ifc

In Node and in a bundler, import the package by name:

js
import { IfcModel } from "@openbim/ifc"; // the bundler loads the wasm module

In a browser without a bundler, import the web build and initialise it once:

js
import init, { IfcModel } from "@openbim/ifc/web";

await init(); // fetches openbim_ifc_wasm_bg.wasm from next to the module

Values cross in a tagged encoding that keeps every distinction STEP makes ({ kind: "typed", type: "IFCLABEL", value: { kind: "text", value: "F30" } }). The recipes print them through this helper, which drops what a report does not need:

js
// A tagged IfcValue as a plain JavaScript value: typed wrappers unwrapped,
// a list as an array, a reference as its id, `$`, `*` and `.U.` as null.
function plain(value) {
  switch (value.kind) {
    case "typed":
      return plain(value.value);
    case "list":
      return value.items.map(plain);
    case "ref":
      return value.id;
    case "null":
    case "derived":
    case "unknown":
      return null;
    default:
      return value.value;
  }
}

Open a file ​

js
// Node: a Buffer is a Uint8Array.
const model = IfcModel.parse(readFileSync(path));
console.log(model.schema, model.size); // IFC4 68
console.log(model.header().name, model.header().originatingSystem);

In a browser the bytes come from a File; the model is parsed in the page and nothing leaves it:

js
// A browser: a File from <input type="file"> or a drop; nothing is uploaded.
const fromFile = IfcModel.parse(new Uint8Array(await file.arrayBuffer()));

A file that does not parse throws an IfcError with code parse; IfcModel.parseWithOptions(bytes, { onMalformed: "skip" }) reads a damaged export and lists what it skipped in model.diagnostics().

Read property sets ​

One object's property sets, its own first, then those it inherits from its type:

js
const wall = model.idsOfType("IfcWall").find((id) => plain(model.attributeByName(id, "Name")) === "Wall A");
for (const set of model.propertySets(wall)) {
  // source: "occurrence" (the wall's own) or "type" (inherited from its type)
  for (const property of set.properties) {
    console.log(`${set.name}.${property.name}`, plain(property.value), set.source);
  }
}
// Pset_WallCommon.IsExternal false occurrence
// Qto_WallBaseQuantities.Width 200 occurrence ...
// Pset_WallCommon.FireRating F30 type

Many objects in one call, here every wall as a table row:

js
// One call for every wall: the property index is built once, so this stays
// linear in the model where a loop of propertySets is quadratic.
const rows = [];
for (const { object, sets, refusal } of model.propertySetsMany(model.idsOfType("IfcWall"))) {
  if (refusal) {
    console.warn(object, refusal.code, refusal.message);
    continue;
  }
  const row = { id: object, Name: plain(model.attributeByName(object, "Name")) };
  for (const set of sets) {
    for (const property of set.properties) row[`${set.name}.${property.name}`] ??= plain(property.value);
  }
  rows.push(row);
}
console.table(rows); // one row per wall, one column per "Set.Property"

propertySetsMany() with no ids answers for every object definition in the file.

List storeys and their elements ​

js
const tree = model.spatialTree();
for (const storey of tree.nodes.filter((node) => node.kind === "storey")) {
  console.log(`${storey.name} (#${storey.id})`);
  for (const id of storey.elements) {
    console.log(`  ${model.typeOf(id)} #${id} ${plain(model.attributeByName(id, "Name"))}`);
  }
}
// Level 0 (#25)
//   IFCWALL #30 Wall A
//   IFCWALL #31 Wall B

Each node also has its parent and children, so the same tree gives the project, site and building above the storeys and the spaces below them; tree.orphans lists elements no container holds.

Validate ​

js
const report = model.validate(); // against the schema the header declares
console.log(report.conformant ? "conformant" : `${report.errors} error(s), ${report.warnings} warning(s)`);
for (const finding of report.findings) {
  // severity: "error", "evaluation-error", "warning" or "unsupported"
  const at = finding.entity === undefined ? "file" : `#${finding.entity}`;
  console.log(`${finding.severity} ${finding.rule} ${at} ${finding.attributeName ?? ""}: ${finding.message}`);
}

The recipe ran on the fixture with Wall A's PredefinedType set to an item IfcWallTypeEnum does not have. validate(maxFindings) caps the report; report.truncated says when the cap was reached.

Convert to and from ifcXML ​

js
// STEP to ifcXML, in this library's lossless layout, and back.
const xml = model.writeIfcXml(); // a Uint8Array of UTF-8 XML
const back = IfcModel.parseIfcXml(xml);
const step = back.write(); // the same entities, as STEP again

// The buildingSMART XSD layout of a release, for tools that read it.
const xsd = model.writeIfcXml("IFC4");
const fromXsd = IfcModel.parseIfcXml(xsd, "IFC4");

The lossless layout carries everything the STEP file does. An XSD layout refuses, with code write, what it cannot carry exactly.

Edit a property set and attributes ​

js
// Pset_ and Qto_ writes are checked against the release's PSD/QTO
// catalog, which the module loads once (from catalog/ beside it).
await IfcModel.loadCatalog(model.schema);

const label = (value) => ({ kind: "typed", type: "IFCLABEL", value: { kind: "text", value } });
// One checked transaction: every edit, or none and the model unchanged.
model.setProperties([
  // Wall B inherits FireRating from its type: this overrides it on the wall.
  { object: wallB, set: "Pset_WallCommon", name: "FireRating", value: label("F90") },
  // A set the wall lacks is created with its relationship.
  { object: wallB, set: "Checks", name: "Reviewer", value: label("QA") },
]);

// Attributes take plain values, coerced against their declared type.
model.setAttributeByName(wallB, "Name", "Wall B (checked)"); // IfcLabel
model.setAttributeByName(wallB, "PredefinedType", "solidwall"); // IfcWallTypeEnum: .SOLIDWALL.

const bytes = model.write(); // save with fs.writeFileSync or a download link

Property values are exact IfcValues, typed as propertySets reports them; attributes take plain JavaScript values (strings, numbers, booleans, arrays, null) coerced against the attribute's declared type, or an exact IfcValue. A refused edit throws and changes nothing: invalid-value, template-violation (the PSD/QTO catalog disagrees), type-mismatch (a plain value that does not fit).

Read placements, meshes and exact geometry ​

Placements are in every build:

js
for (const product of model.productPlacements()) {
  if (product.refusal) {
    console.log(product.id, product.refusal.code); // per product, never thrown
    continue;
  }
  // A column-major 4x4 in metres; the translation is its last column.
  const [x, y, z] = product.transform.slice(12, 15);
  console.log(product.typeName, x, y, z, product.representation?.representationType);
}
// IFCWALL 512002 5403001 3 SweptSolid ...

Meshes need the mesh entry, which carries the geometry kernel:

js
import { IfcModel } from "@openbim/ifc/mesh"; // productMeshes included
js
// From `@openbim/ifc/mesh`: triangles relative to each product's transform.
const triangles = new Map();
for (const mesh of model.productMeshes()) {
  if (mesh.refusal || mesh.indices.length === 0) continue; // refused, or no Body
  const world = new Float64Array(mesh.positions.length);
  const m = mesh.transform; // column-major, metres, f64
  for (let i = 0; i < mesh.positions.length; i += 3) {
    const [x, y, z] = mesh.positions.subarray(i, i + 3);
    for (let axis = 0; axis < 3; axis++) {
      world[i + axis] = m[axis] * x + m[4 + axis] * y + m[8 + axis] * z + m[12 + axis];
    }
  }
  triangles.set(mesh.id, { type: mesh.typeName, world, indices: mesh.indices });
}

Positions are f32 relative to each product's f64 transform, so a site kilometres from the origin keeps its millimetres. The WebGL viewer in crates/openbim-ifc-wasm/examples/viewer/, which the playground uses, shows the same data on screen.

Curves have no triangles: an Axis, a FootPrint or an annotation's curves come as polylines from the mesh entry (#458), one Float32Array per product and a slice per curve with the certified bound on how far the exact curve lies from it:

js
const model = IfcModel.parse(bytes);
for (const product of model.productCurves(undefined, "Axis")) {
  for (const line of product.polylines) {
    // positions: Float32Array, x y z per vertex in metres relative to
    // product.transform; this curve is a slice of them.
    const xyz = product.positions.subarray(
      3 * line.firstVertex,
      3 * (line.firstVertex + line.vertexCount),
    );
    console.log(product.id, xyz.length / 3, line.closed, line.chordBound);
  }
}

Trimmed, composite, offset and intrinsic curves compile (#506). A curve the reference compiler has no certified polyline for (an implicit curve, an offset of an offset) is refused with code unsupported, its message the compiler's reason.

The exact representation instead of triangles (#367): each Body as Axiolid's neutral geometry graph in its wire format, for your own kernel. The mesh entry carries it:

js
// From `@openbim/ifc/mesh`: each Body as Axiolid's neutral graph, exact, in
// world metres. "object" parses it; "json" keeps the text, "cbor" the bytes.
const kinds = new Map();
for (const product of model.productGeometry(undefined, "object")) {
  if (product.refusal || !product.payload) continue; // refused, or no Body
  const { graph } = product.payload; // with format "axiolid-geometry-graph", version "1.0" to "1.4"
  // Each node is tagged by its kind; a reference is an earlier node's index.
  kinds.set(product.id, graph.nodes.map((node) => Object.keys(node)[0]));
}
// 36n => ["Profile", "SolidOperation", "Instance"], 46n => [...]

Each payload is the envelope { format, version, graph }, typed as GeometryGraphEnvelope, exact, in world coordinates, metres; the binding page describes the encodings.

Find entities by GlobalId, types and openings ​

An entity by its GlobalId, an occurrence's type object and the type's other occurrences, and a wall's openings with what fills them, read from test/fixtures/synthetic-relations/relations_ifc4.ifc:

js
const name = (id) => model.attributeByName(id, "Name").value;
const wall = model.byGuid("0LwOtle8vEaxZPGHkxQdYT"); // 17n, or undefined
// The first lookup indexes every GlobalId once; the rest reuse it.

const wallType = model.typeObject(wall); // IfcRelDefinesByType
log(name(wallType), model.occurrencesOf(wallType)); // "WT-200" [17n, 18n]
for (const opening of model.openingsOf(wall)) { // IfcRelVoidsElement
  log(name(opening), model.fillingsOf(opening).map(name));
}
// "Door opening" ["Door 1"]
// "Recess" []

log(model.duplicateGlobalIds()); // [] -- a copied GlobalId is listed here
log(IfcModel.newGuid()); // random, from crypto.getRandomValues
log(IfcModel.nameBasedGuid(["my-exporter", "wall", "42"])); // same every run

A GlobalId that several entities carry throws IfcError with codeinvalid-model naming every holder; duplicateGlobalIds() lists them all. IfcModel.newGuid() draws an RFC 9562 version-4 UUID from the host's crypto.getRandomValues (browsers, Node 19 and later; unsupported on Node 18); IfcModel.nameBasedGuid(parts) is version 8, the same for the same parts in every binding. Both are IFC's 22-character form.

Select entities and read values by path ​

A selector picks entities by class, attribute, property, type, material, classification, location and group; a value path reads (and writes) one value of an entity. Read from test/fixtures/synthetic-relations/selector_ifc4.ifc:

js
const walls = model.select("IfcWall, Pset_WallCommon.FireRating=F90"); // [17n, 18n]
log(model.select('IfcDoor + location="Level 1"')); // [19n, 28n, 38n, 40n]

log(model.getValue(walls[0], "type.Name")); // { kind: "text", value: "WT-200" }
const pset = model.getValue(walls[0], "Pset_WallCommon");
log(Object.fromEntries(pset.entries.map(([name, value]) => [name, value.value])));
// { IsExternal: true, FireRating: "F90", AcousticRating: "R45" }

model.setValue(walls[0], "type.Name", "WT-300"); // one checked transaction
try {
  model.select("IfcWall, colour=red");
} catch (error) {
  log(error.code, error.span); // "invalid-selector" [9, 15]
}

select returns ids as bigints in ascending order; a second argument restricts it to given ids. getValue returns the tagged encoding, with a list as { kind: "list", items } and a property set as { kind: "properties", entries } of [name, value] pairs. setValue takes a plain value or an IfcValue. A selector that does not parse throws IfcError with code invalid-selector and span, the byte range it refuses in the UTF-8 query.

Export a table and import it back ​

The walls of test/fixtures/synthetic-relations/selector_ifc4.ifc as a table, one column per value path, keyed by id; one cell edited, the import planned as a dry run, then applied as one checked transaction:

js
const csv = model.exportTable("IfcWall", ["Name", "type.Name"], { key: "id" }); // a Uint8Array
const text = new TextDecoder().decode(csv);
log(text.split("\r\n")[1]); // "17,Wall A,WT-200"

// Edit the table, then plan the import: a dry run lists every change.
const edited = new TextEncoder().encode(text.replace("17,Wall A,", "17,Wall A1,"));
for (const change of model.importTable(edited, { dryRun: true }).changes) {
  log(change.row, change.path, change.before, change.after); // 2 "Name" "Wall A" "Wall A1"
}
log(model.importTable(edited).applied); // true: one checked transaction

// A refused cell refuses the whole table, by row and column.
const bad = new TextEncoder().encode("id,Name\n99,Ghost\n");
log(model.importTable(bad).refusals.map((r) => [r.row, r.column, r.code])); // [[2, 1, "unknown-key"]]

exportTable returns a Uint8Array; importTable reports every change and refusal without throwing. The npm package writes CSV and TSV; XLSX needs a build with sheet-xlsx. A cell whose text is what its path reads changes nothing; any refused cell refuses the whole table.

Remove a product and purge ​

A door with what it alone owns, a wall whose openings place their fillings, then what nothing uses any more, from test/fixtures/synthetic-relations/cascade_ifc4.ifc:

js
// Door #100 with what it alone owns: placement, representation, its
// filling and type relationships. A dry run changes nothing.
const report = model.removeProduct(100n, { dryRun: true });
log(report.removed.length); // 11
for (const kept of report.kept) {
  if (kept.reason === "shared") log(kept.id, kept.typeName, kept.users);
}
// 82n "IFCLOCALPLACEMENT" [81n]       the opening's placement
// 112n "IFCREPRESENTATIONMAP" [111n]  the door type's geometry

model.removeProduct(100n); // applied: one checked transaction
// A wall whose openings place their fillings: re-base those placements
// onto the storey so nothing moves, or it is refused.
model.removeProduct(20n, { rebasePlacements: true });

// Then purge what nothing uses any more: no classes means every resource
// entity no IfcRoot reaches.
log(model.purge({ dryRun: true }).removed.length > 0);

{ rebasePlacements: true } re-bases placements relative to a removed one onto its parent, composing the transforms; { untypeOccurrences: true } lets a type object with occurrences go. Without them those removals throw still-referenced. purge({ classes: ["IfcPropertySet"], includeRoots: true }) purges unreferenced entities of the named classes only. The binding page lists what the cascade removes and keeps.

Assign a type and propagate it ​

Door 1 typed again by its door type, with the type's representation map mapped onto it, and Wall B moved into the building, from test/fixtures/synthetic-relations/cascade_ifc4.ifc:

js
// Door 1 (#100) typed again by D-90 (#111) with replace: what the old
// assignment mapped is taken back, the type's representation map is
// mapped afresh, and PredefinedType is left to the type.
model.author([
  { op: "assignType", typeObject: 111n, objects: [100n], mapRepresentations: true, predefinedType: "sync", replace: true },
]);
// A new door, typed the same way in the batch that creates it.
const { ids } = model.author([
  { op: "product", type: "IfcDoor", container: 14n, attributes: { Name: { kind: "text", value: "Door 2" } } },
  { op: "assignType", typeObject: 111n, objects: [IfcModel.handle(0)], mapRepresentations: true },
]);
const shape = model.attributeByName(ids[0], "Representation").id;
const body = model.attributeByName(shape, "Representations").items[0].id;
log(model.attributeByName(body, "RepresentationType").value); // "MappedRepresentation"
// Wall B (#40) moves into the building (#13); without replace the second
// containment is refused with invalid-model.
model.author([{ op: "contain", structure: 13n, elements: [40n], replace: true }]);

The type assignment takes mapRepresentations, mappingTarget, materialUsage, layerSetDirection, predefinedType and replace. Every option is off by default: the operation then writes only the IfcRelDefinesByType and refuses an object that already has a type (invalid-model). With replace: true the previous type is unassigned and the representations and material usage it propagated are removed with what only they use; contain and aggregate take the same flag. materialUsage derives an IfcMaterialLayerSetUsage (walls AXIS2, slabs and plates AXIS3, POSITIVE, offset 0) or an IfcMaterialProfileSetUsage from the type's set; predefinedType: "validate" refuses an object whose own PredefinedType contradicts the type's. The binding page lists the rules.

Assemble, assign, copy and remove representations ​

A body for a window from new items, a wall's body swapped for a copy of another's, and the dry run of removing a door's body, on the same fixture:

js
const h = IfcModel.handle; // h(i): the entity operation i of the batch produces
const ref = (id) => ({ kind: "ref", id });
const real = (value) => ({ kind: "real", value });
const result = model.author([
  // The items first: a profile swept 1 m up, through the generic create.
  { op: "create", type: "IfcCartesianPoint", attributes: { Coordinates: { kind: "list", items: [real(0.6), real(0.05)] } } },
  { op: "create", type: "IfcAxis2Placement2D", attributes: { Location: ref(h(0)) } },
  {
    op: "create",
    type: "IfcRectangleProfileDef",
    attributes: { ProfileType: { kind: "enum", value: "AREA" }, Position: ref(h(1)), XDim: real(1.2), YDim: real(0.1) },
  },
  {
    op: "create",
    type: "IfcExtrudedAreaSolid",
    attributes: { SweptArea: ref(h(2)), Position: ref(9n), ExtrudedDirection: ref(31n), Depth: real(1) },
  },
  // Context #11 is the 'Body' sub-context: the identifier is 'Body', and
  // the type is inferred from the item.
  { op: "shapeRepresentation", context: 11n, items: [h(3)] }, // 4
  { op: "assignRepresentation", representation: h(4), to: 130n }, // 5: window #130's shape
  // Wall #20's body swapped for a copy of wall #40's.
  { op: "copyRepresentation", representation: 45n }, // 6
  { op: "unassignRepresentation", representation: 25n, from: 20n },
  { op: "removeRepresentation", representation: 25n },
  { op: "assignRepresentation", representation: h(6), to: 20n },
]);
log(model.attributeByName(result.ids[4], "RepresentationType").value); // "SweptSolid"
// One alone, with a report of what went and what stayed:
const report = model.removeRepresentation(105n, { dryRun: true }); // the door's body
log(report.removed.map((entity) => entity.id)); // [104n, 105n, 106n, 107n, 108n]

shapeRepresentation checks a given representationType against the items by the release's CorrectItemsForType rule, and refuses a mismatch with invalid-value. A representation belongs to one product shape, map or shape aspect, so assigning one already used throws invalid-model; on a type it becomes a new IfcRepresentationMap. A copy shares its context, styles and the type geometry a mapped item maps, and copies everything else. The binding page has the rules.

Copy a product and append from another model ​

Wall A copied with its openings, door and window, then a wall type taken from another file, from test/fixtures/synthetic-relations/cascade_ifc4.ifc:

js
// Wall A (#20) with what it alone owns -- placement, body, its own
// property set, material usage, openings with their door and window --
// two metres along. Its type, shared property set, classification,
// storey and layer are shared: the copy joins them.
const report = model.copyProduct(20n, { offset: [0, 2, 0] });
log(model.attributeByName(report.copy, "Name")); // { kind: "text", value: "Wall A" }
log(report.copied.length); // 55 originals, each with its copy
log(report.joined); // [19n, 62n, 67n, 79n, 80n, 120n, 136n]

// From another model: the wall type with its property set. Equal units,
// contexts, owner history, materials and styles are reused; this model
// holds the type's GlobalIds already, so new ones.
const library = IfcModel.parse(readFileSync(CASCADE));
const appended = model.append(library, [66n], { globalIds: "regenerate" });
log(appended.copied.map((entity) => entity.typeName));
// ["IFCPROPERTYSINGLEVALUE", "IFCPROPERTYSET", "IFCWALLTYPE"]
log(appended.skipped); // [67n]: its occurrences stay in the library

A copy copies what removeProduct would remove with the product and shares what it would keep. openings: false, fillings: false, container: false and aggregate: false leave those out; placement puts the copy on a placement of your own. append keeps GlobalIds by default and refuses one the model holds; globalIds: "merge" treats a GlobalId this model holds (on the same type) as the same object. It refuses models whose length or angle units differ, and entities the target release cannot hold. The binding page lists what is copied, shared and skipped.

Create a model ​

js
const model = new IfcModel();
model.setHeader({
  description: ["ViewDefinition [DesignTransferView]"],
  implementationLevel: "2;1",
  name: "new.ifc",
  timeStamp: new Date().toISOString().slice(0, 19),
  author: [""],
  organization: [""],
  preprocessorVersion: "@openbim/ifc",
  originatingSystem: "cookbook",
  authorization: "",
  schema: ["IFC4"], // the release every operation is checked against
});
const text = (value) => ({ kind: "text", value });
const h = IfcModel.handle; // h(i): the entity operation i of this batch creates
const { ids } = model.author([
  { op: "project", attributes: { Name: text("Demo") } }, // 0
  { op: "placement" }, // 1
  { op: "spatial", type: "IfcSite", parent: h(0), placement: h(1) }, // 2
  { op: "spatial", type: "IfcBuilding", parent: h(2) }, // 3
  { op: "spatial", type: "IfcBuildingStorey", parent: h(3), attributes: { Name: text("Level 0") } }, // 4
  { op: "placement", relativeTo: h(1), location: [4, 0, 0] }, // 5
  { op: "product", type: "IfcWall", container: h(4), placement: h(5), attributes: { Name: text("Wall") } }, // 6
]);
const wall = ids[6]; // a bigint; every IfcRoot got a GlobalId
const bytes = model.write();

Every operation is checked against the release the header declares, and a batch is one transaction. The binding page lists every operation.

Convert units and a model's length unit ​

Between units named as text, as feet and inches, then the whole of test/fixtures/synthetic-bindings/binding_geometry.ifc from millimetres to metres:

js
// Between units named as text: exact factors, checked dimensions.
log(IfcModel.convertUnit(1, "foot", "MILLIMETRE")); // 304.8
log(IfcModel.formatLength(444.5, "MILLIMETRE", { style: "feet-inches" }));
// 1' - 5 1/2"

// The whole model to metres: geometry, quantities and properties, as one
// checked transaction. A dry run only reports.
const dry = model.convertLengthUnit("METRE", { dryRun: true });
log(dry.from, dry.to, dry.lengthFactor); // "MILLI METRE" "METRE" 0.001
const report = model.convertLengthUnit("METRE");
log(report.applied); // true
for (const kept of report.kept) log(kept.entityType, kept.attribute, kept.reason);

Every length, area and volume value is rescaled: geometry, placements, quantities and property values. Values with a unit of their own keep it and appear in kept with the reason. A model holding a value the conversion cannot carry is refused whole, with unsupported, and left unchanged.

Released under the AGPL-3.0-or-later licence. ISO and CEN standards material is not redistributed.