0021 — Bindings carry placements by default and meshes on request
- Status: Accepted
- Date: 2026-10-08
- Deciders: openbimrs contributors
- Supersedes: —
- Amends: ADR 0013 — the bindings gain geometry; ADR 0004 stands unchanged
Context
The language bindings (ADR 0013) carried no geometry: no placements, no representations, no meshes. For a browser viewer, the most common reason to load IFC in JavaScript, that was the main gap (#328).
Three levels were possible, each with a different cost:
- Placements and representation selection. Per product, its world placement and which representation is the Body. Kernel-free: the facade's
geometry-select, which readsifc-modelslots (ADR 0018). - The neutral representation as a value. The
GeometryGrapheach Body lowers into, serialised for a host to evaluate with its own kernel. - Meshes. Triangles from
ifc-geometry'scompile-reference-backend, the one execution provider ADR 0004 admits, and only behind an opt-in feature.
The IFC crates never tessellate (ADR 0004): a mesh exists only where the reference backend (ADR 0012) computes it, and nothing links that backend by default.
Decision
We will bind Level 1 in every default build and Level 3 behind an opt-in feature in every host, and defer Level 2.
- The facade grows both first.
ifc::product_placements(model, ids)joinsproducts_world_transformswithselect_shape_representationand the representation's context, one answer per product with each half's ownGeometryResult.ifc::product_meshes(model, ids)(facade featuremesh:geometryplusifc-geometry/compile-reference-backend) callscompile_product_mesh_withonce per product with one backend instance. Both take the placement from the public placement API, so a change to placement resolution reaches the bindings unchanged.ifc-geometryre-exportsToleranceandTriMeshfrom itscompilemodule, so the facade names noaxiolid-*crate. - Matrices are 4x4 column-major, in metres. The layout WebGL, three.js (
Matrix4.fromArray) and most graphics APIs read: three basis columns, then the origin. - Mesh positions are
f32relative to the product's placement; the placement isf64. The backend returns world coordinates. A georeferenced site sits kilometres from the origin, where anf32keeps centimetres at best, so each vertex is mapped back through the inverse placement inf64before it is narrowed. A host drawstransform * positionand keeps the large offset in the matrix. Indices areu32. The tolerance is one millimetre: lowering converts every length to metres, so the bridge, not the host, knows the file's scale (ADR 0004). - Refusals are records, typed per product. A product whose placement, selection, lowering or compilation is refused carries a
GeometryRefusal(code, theentityat fault,message) and every other product is still answered. The codes are the shared binding codes:unsupported(valid IFC not interpreted, or a mesh the backend refused),invalid-model,missing-referenceandbudget-exceeded(a cycle, or a chain or aggregate over its limit). A call fails as a whole only withunsupported-schema(the release is not bundled, as for the domain views) orfeature-disabled. - One feature name per level, in every host.
placementsis a default feature ofopenbim-ifc-binding-coreand the WASM crate, and so of the npm package, the wheel and the C library.meshis opt-in in every host crate (openbim-ifc-wasm,openbim-ifc-py,openbim-ifc-capi); without it the mesh call exists and refuses withfeature-disabled, so every build has one surface (ADR 0013, #244). No published artifact enables it: the npm package stays one module per target, as #317 and #318 left it, and publishing a mesh entry is #369. - Each host carries arrays in its own idiom. JavaScript:
Float32ArrayandUint32Arraycopies on the record. Python:array('f')andarray('I'). C: compiling is the expensive step and C sizes every buffer with a first call, somodel_product_meshescompiles once into an opaque mesh-set handle, read record by record (meshes_records, a tape) and array by array (meshes_positions,meshes_indices, caller buffers), then destroyed; it owns its data and outlives the model. .NET wraps those calls (ProductMeshes, returningMeshedProductwithfloat[]anduint[]). ABI 0.1.7.
Level 2 is deferred. The neutral graph is Axiolid's model, and Axiolid has not promised it as a stable serialisation format: axiolid-model has no documented encoding, and nothing says which changes to its node kinds break a serialised graph. Binding it now would freeze Rust internals into four host APIs. The follow-up is #367, blocked by axiolid/kernel#267.
Alternatives considered
| Option | Why not |
|---|---|
| Meshes in the default build | Links an execution provider into every consumer, which ADR 0004 forbids, and roughly doubles the browser module (see Consequences). |
| Mesh positions in world coordinates | Exact for a model at the origin, wrong by centimetres for a surveyed site once a GPU reads f32. |
Positions as f64 | Twice the memory and upload; GPUs read f32. The f64 part is the per-product matrix, sixteen numbers. |
| Abort the whole call on the first refused product | One malformed wall would hide the building; the facade's batch placement already answers per product. |
| A second npm build now | #317/#318 kept one module per target; a second module doubles the package. A separate entry is #369. |
| Level 2 now, with Axiolid's Rust types as the format | Couples four host APIs to an unpromised internal model. |
Consequences
Positive
- A viewer gets placements and the chosen Body from every published binding, and meshes from a build that asks for them, with the same codes in every host.
- The bridge still computes nothing: Level 1 reads slots, and Level 3 calls the reference backend ADR 0004 and ADR 0012 already admit.
Negative / costs
- Measured on the browser module, after
wasm-bindgen, withoutwasm-opt: Level 1 adds 42 KB to the default npm module (2,867,002 to 2,909,468 bytes; +15 KB undergzip -9, +11 KB under brotli) and 60 KB to an IFC4-only one (815,769 to 875,610). Level 3 adds 2.1 MB to the default (to 5,052,660 bytes; 978,432 to 1,735,307 undergzip -9, 640,878 to 1,150,357 under brotli) and 2.75 MB to an IFC4-only build (to 3,626,301), the reference compiler and its boolean engine. The table is in Module size. - From npm, meshes need the crate built from source until #369.
Follow-ups / risks to watch
- Level 2 waits on axiolid/kernel#267 (#367).
- A host that wants a different backend or tolerance has only the Rust API for now; the bindings fix the reference backend and one millimetre.
Amendments
Amended 2026-10-08 (#369): the npm package carries a mesh entry. The package
@openbim/ifcnow ships a second module, the default features plusmesh, as the subpath entry@openbim/ifc/mesh(mesh/,mesh/bundler/,mesh/web/, with the default entry's conditional exports). The default entry is unchanged, still without meshes, so an application that does not import the mesh entry loads none of it; the package grows from 3.9 MB to 9.2 MB packed.meshstays opt-in in every crate, and the wheel and the C library still leave it out. A second package was the alternative: it would duplicate the catalog files and their loader, and need its own trusted-publishing configuration and version lockstep.scripts/check-mesh.shis gone; the mesh entry's suites run inscripts/build-npm-pkg.sh. Sizes are in Package size.Amended 2026-10-10 (#367): Level 2 is bound, on Axiolid's wire format 1.0. What Level 2 waited for exists: axiolid/kernel#267 released a versioned encoding of
GeometryGraph(Axiolid ADR 0085;axiolid-model0.3.8, featureserde), the envelope{"format": "axiolid-geometry-graph", "version": "1.0", "graph": {"nodes", "roots"}}in JSON or CBOR, nodes in insertion order, a reference the index of an earlier node, and a reader that refuses an unknown kind, variant or field, a newer version and a non-finite number by name, and revalidates every graph. The bindings carry Axiolid's payload as it is and define no format of their own.- Every layer.
ifc-geometryre-exportsaxiolid_model::wirebehind its featurewire, so neither the facade nor the bindings name anaxiolid-*crate; the facade'sgeometry-wireaddsifc::product_graphs(model, ids), each product's Body lowered as the mesh level lowers it (the same graph the reference backend compiles), with its world placement and a per-productGeometryResult.openbim-ifc-binding-coreaddsproduct_geometry(ids, encoding), records with the payload or a typed refusal with the four codes above (a graph the wire writer refuses, which only a non-finite number can be, isinvalid-model). The hosts: WASMproductGeometry(ids?, encoding?)with"json"(a string),"object"(parsed) or"cbor"(aUint8Array) and TypeScript types for the envelope; Pythonproduct_geometry(ids=None, encoding="json"), astrorbytes; Copenbim_ifc_v0_1_model_product_geometryinto anOpenbimIfcGraphsset read bygraphs_records(a tape) andgraphs_payload(a byte buffer), as the mesh set is (ABI 0.1.9); .NETProductGeometry(ids, encoding)returningProductGraphwithbyte[] Payload. - World coordinates. The graph carries the product's placement in its
Instancetransforms, as lowering composes it; the record's placement rides along for a host that wants the product's frame and is never applied again. JSON and CBOR carryf64bit-exactly, so thef32concern of Level 3 does not arise. - Opt-in, by size. The feature is
graphin every crate, off by default. Measured on the browser module (afterwasm-bindgen, nowasm-opt): it adds 1,059,241 bytes to the default (2,962,323 to 4,021,564; 995,024 to 1,336,245 undergzip -9, 655,098 to 868,377 under brotli 11), and 1.64 MB to an IFC4-only build, whose lowering reachesifc-alignmentand every release's schema table, against Level 1's 15 KB undergzip -9. Besidemesh, which links the lowering already, it adds 285,839 bytes (88 KB undergzip -9, 46 KB under brotli), so the npm package's mesh entry (@openbim/ifc/mesh) carries both; the default entry, the wheel and the C release archives leave it out, and without it the call refuses withfeature-disabled. The table is in Module size. - The stability rule we inherit. Axiolid's versioning is ours: a minor version of the wire format (a new node kind, variant or optional field) reaches every binding as a minor release, and a host reader must accept it; a major version of the wire format is a breaking change of every binding (a major release of the npm package, the wheel and the .NET package, and a new
v0_2_C export rather than a changedv0_1_one).GEOMETRY_FORMAT_VERSIONin the binding core names the version a build writes, and its tests hold it toaxiolid_model::wire::FORMAT_VERSION. - The deferral above, and its alternative "Level 2 now, with Axiolid's Rust types as the format", are superseded by this amendment: the format is now Axiolid's published one, not its Rust types.
- Every layer.
Amended 2026-10-10 (#423): wire format 1.1 reaches the bindings as a minor release.
axiolid-model0.3.9 (axiolid/kernel#294) adds format 1.1: anOrientedCurveStationmay carry a seam-snapping window, whichifc-geometrysets on a station along an offset whose length is a quadrature (a kerb beside an alignment). Under the stability rule above that is a minor version:GEOMETRY_FORMAT_VERSION(and Python's constant, .NET'sProductGraph.FormatVersion) is now"1.1", the newest version a build writes, and the npm package, the wheel, the .NET package and the C ABI take a minor release. An envelope'sversionis the lowest its content needs:1.0, or1.1when a station in the graph carries a window (Axiolid ADR 0085).axiolid-model0.3.9 itself labels every payload1.1, a defect Axiolid fixes in a patch (axiolid/kernel#297), so hosts and the binding tests read the version and accept either. A reader onaxiolid-model0.3.8 or older refuses a1.1payload, as the format's versioning promises.- Note 2026-10-12 (axiolid/kernel#297):
axiolid-model0.3.10 fixes the labelling, and the workspace requires it. An envelope carries the lowest version its content needs:1.0unless a station carries a seam-snapping window, then1.1. A window-free payload is again byte-identical to what 0.3.8 writes and readable by it, and the binding tests hold a window-free product's payload to exactly1.0and a windowed one's to1.1.
- Note 2026-10-12 (axiolid/kernel#297):
Amended 2026-10-12 (#429): wire format 1.2 reaches the bindings as a minor release.
axiolid-model0.3.11 (axiolid/kernel#301) adds format 1.2: the sections of a run (SectionAtStation) may carry a seam side and a seam-snapping window, andCurveRelation::OffsetsAtStationscarries them on an offset run.ifc-geometrysets them on the sections of anIfcSectionedSolidHorizontalorIfcSectionedSurfacealong an offset whose length is a quadrature. Under the stability rule above that is a minor version:GEOMETRY_FORMAT_VERSION(and Python's constant, .NET'sProductGraph.FormatVersion) is now"1.2", and the npm package, the wheel and the .NET package take a minor release; the C ABI keeps itsv0_1symbols, so the C package stays on 0.1.x (CMake'sfind_package(0.1)refuses 0.2; precedent 0.1.7). The lowest-version rule is unchanged: a window-free payload is1.0, one whose stations alone carry windows1.1, and only a payload with a windowed run1.2. A reader onaxiolid-model0.3.10 or older refuses a1.2payload.Amended 2026-10-13 (#433): wire format 1.3 reaches the bindings as a minor release.
axiolid-model0.3.12 (axiolid/kernel#303) adds format 1.3: a station ofCurveRelation::OffsetsAtStationsmay stand at its basis's start or end (RunStation::basis_end), its distance measured by the kernel.ifc-geometrysets them on anIfcOffsetCurveByDistanceswhose offsets stop short of the ends of a basis whose length is a quadrature. Under the stability rule above that is a minor version:GEOMETRY_FORMAT_VERSION(and Python's constant, .NET'sProductGraph.FormatVersion) is now"1.3", and the npm package, the wheel and the .NET package take a minor release; the C ABI keeps itsv0_1symbols, so the C package stays on 0.1.x. The lowest-version rule is unchanged: only a payload holding a station at its basis's end is1.3, every other keeps1.0,1.1or1.2, and a reader onaxiolid-model0.3.11 or older refuses a1.3payload.Amended 2026-10-16 (#515): wire format 1.4 reaches the bindings as a minor release.
axiolid-model0.3.14 (axiolid/kernel#321) adds format 1.4: the nodeSolidOperation::BoundedHalfSpaceAlongZ, a half-space bounded by its boundary's prism along its placement's Z, the set IFC'sIfcPolygonalBoundedHalfSpacestates.ifc-geometrylowers everyIfcPolygonalBoundedHalfSpaceto it, the aligned clips that wereBoundedHalfSpacebefore included, so one path holds every frame. Under the stability rule above that is a minor version:GEOMETRY_FORMAT_VERSION(and Python's constant, .NET'sProductGraph.FormatVersion) is now"1.4", and the npm package, the wheel and the .NET package take a minor release; the C ABI keeps itsv0_1symbols, so the C package stays on 0.1.x. The lowest-version rule is unchanged: a payload is1.4only when it holds a polygonal bounded clip, every other keeps1.0to1.3. A payload with such a clip was1.0before and is1.4now, so a reader onaxiolid-model0.3.13 or older, which refuses it, must be upgraded to read those products at all.
Relation to existing code
crates/openbim-ifc/src/product_geometry.rsand itsmeshsubmodule; the facade featuremesh.crates/ifc-geometry/src/compile.rs: theToleranceandTriMeshre-exports.crates/openbim-ifc-binding-core/src/geometry.rsandtests/geometry.rs; featuresplacementsandmesh.crates/openbim-ifc-wasm/src/geometry.rs,examples/viewer/,tests/js/geometry.mjs,scripts/build-npm-pkg.sh(the mesh entry, #369).crates/openbim-ifc-py/python/openbim_ifc/geometry.py,tests/python/test_geometry.py.crates/openbim-ifc-capi/src/geometry.rs, theMESHESregistry,tests/c/smoke.c(geometry()).crates/openbim-ifc-dotnet/dotnet/OpenBim.Ifc/Domains/Geometry.cs,MeshedProduct.cs.test/fixtures/synthetic-bindings/binding_geometry.ifc.- Level 2 (#367):
crates/openbim-ifc/src/product_geometry/graph.rs, thewirere-export incrates/ifc-geometry/src/lib.rs,crates/openbim-ifc-binding-core/src/geometry/graph.rsandtests/geometry_graph.rs,crates/openbim-ifc-capi/src/graph.rsandgraph_tests.rs,crates/openbim-ifc-dotnet/dotnet/OpenBim.Ifc/ProductGraph.cs.