Python cookbook
Task-sized recipes for openbim-ifc, the Python binding of the IFC core. Each recipe is a region of crates/openbim-ifc-py/tests/python/test_cookbook.py, which runs on every pull request against the built wheel 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 Python API reference.
Set up
pip install openbim-ifcOpen a file
import openbim_ifc
model = openbim_ifc.open(path) # read once, straight from disk
print((model.schema, len(model))) # ('IFC4', 68)
header = model.header # the STEP header, a frozen dataclass
print((header.originating_system, header.time_stamp))
with open(path, "rb") as file: # or from bytes you already hold
same = openbim_ifc.IfcModel.parse(file.read())A file that does not parse raises openbim_ifc.IfcError with codeparse; IfcModel.parse(data, options=ParseOptions(on_malformed="skip")) reads a damaged export and lists what it skipped in model.diagnostics().
Read property sets
One object's property sets, as plain values through the entity view, and as the exact records the binding returns:
wall = next(w for w in model.by_type("IfcWall") if w.Name == "Wall A")
# Plain values: the wall's own, then those inherited from its type.
for set_name, properties in wall.psets.items():
for name, value in properties.items():
print(f"{set_name}.{name} = {value!r}")
# Pset_WallCommon.IsExternal = False
# Pset_WallCommon.FireRating = 'F30' (inherited from the wall type)
width = wall.qtos["Qto_WallBaseQuantities"]["Width"] # 200.0, in its stated unit
# The exact records: declared types, units, and where each set came from.
for pset in model.property_sets(wall.id):
print((pset.name, pset.source, [(p.name, p.value) for p in pset.properties]))Many objects in one call, here every wall:
# One pass for every wall: the property index is built once, so this
# stays linear in the model where a loop of property_sets is quadratic.
rows = {} # id -> {"Set.Property": value}
for answer in model.property_sets_many(model.ids_of_type("IfcWall")):
if answer.refusal is not None:
print(answer.object, answer.refusal.code, answer.refusal.message)
continue
row = rows.setdefault(answer.object, {})
for pset in answer.sets:
for prop in pset.properties:
row.setdefault(f"{pset.name}.{prop.name}", prop.value)
# Or as a pandas frame, one row per wall (pip install 'openbim-ifc[pandas]'):
# frame = model.to_dataframe("IfcWall")property_sets_many() with no ids answers for every object definition in the file.
List storeys and their elements
tree = model.spatial_tree()
for storey in (node for node in tree.nodes if node.kind == "storey"):
print(f"{storey.name} (#{storey.id})")
for element in map(model.by_id, storey.elements):
print(f" {element.type} #{element.id} {element.Name}")
# Level 0 (#25)
# IFCWALL #30 Wall A
# IFCWALL #31 Wall BEach 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
report = model.validate() # against the schema the header declares
if not report.conformant:
print(f"{report.errors} error(s), {report.warnings} warning(s)")
for finding in report.findings:
# severity: "error", "evaluation-error", "warning" or "unsupported"
at = "file" if finding.entity is None else f"#{finding.entity}"
print(f"{finding.severity} {finding.rule} {at} {finding.attribute_name or ''}: {finding.message}")The recipe ran on the fixture with Wall A's PredefinedType set to an item IfcWallTypeEnum does not have. validate(max_findings) caps the report; report.truncated says when the cap was reached.
Convert to and from ifcXML
xml = model.write_ifcxml() # this library's lossless layout, UTF-8 bytes
back = IfcModel.parse_ifcxml(xml)
step = back.write() # the same entities, as STEP again
# The buildingSMART XSD layout of a release, for tools that read it.
xsd = model.write_ifcxml(xsd_profile="IFC4")
from_xsd = IfcModel.parse_ifcxml(xsd, xsd_profile="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
from openbim_ifc import PropertyEdit, Text, Typed
wall = next(w for w in model.by_type("IfcWall") if w.Name == "Wall B")
# Property values are exact IFC values; Pset_/Qto_ sets are checked
# against the release's PSD/QTO catalog, which the wheel embeds.
# Wall B inherits FireRating from its type: this overrides it on the wall.
wall.set_property("Pset_WallCommon", "FireRating", Typed("IFCLABEL", Text("F90")))
# Several edits as one checked transaction: all of them, or none.
model.set_properties([
PropertyEdit(wall.id, "Checks", "Reviewer", Typed("IFCLABEL", Text("QA"))),
PropertyEdit.removal(wall.id, "Pset_Families", "Colour"),
])
# Attributes take plain values, coerced against their declared type.
wall.Name = "Wall B (checked)" # IfcLabel
wall.PredefinedType = "solidwall" # IfcWallTypeEnum: .SOLIDWALL.
with open(out, "wb") as file:
file.write(model.write())Property values are exact values (Typed, Text, Real, ...), typed as property_sets reports them; attributes take plain Python values coerced against the attribute's declared type, or an exact value. A refused edit raises IfcError and changes nothing: invalid-value, template-violation (the PSD/QTO catalog disagrees), missing-property (a removal of a property the object does not state), type-mismatch (a plain value that does not fit).
Read placements, meshes and exact geometry
Placements are in every wheel:
for product in model.product_placements():
if product.refusal is not None: # per product, never raised
print((product.id, product.refusal.code))
continue
# A column-major 4x4 in metres; the translation is its last column.
x, y, z = product.transform[12:15]
body = product.representation # None for an axis-only product
print((product.type_name, (x, y, z), body and body.representation_type))
# ('IFCWALL', (512002.0, 5403001.0, 3.0), 'SweptSolid') ...Meshes link a geometry kernel, which the published wheel leaves out (it raises feature-disabled); build one with maturin build --release --features mesh in crates/openbim-ifc-py:
# A wheel built with `maturin build --release --features mesh`.
world = {}
for mesh in model.product_meshes():
if mesh.refusal is not None or not mesh.indices:
continue # refused, or no Body
m = mesh.transform # column-major, metres; positions are relative to it
p = mesh.positions # array('f'): x y z per vertex
world[mesh.id] = [
tuple(m[a] * p[i] + m[4 + a] * p[i + 1] + m[8 + a] * p[i + 2] + m[12 + a] for a in range(3))
for i in range(0, len(p), 3)
]
# mesh.indices: array('I'), three per trianglePositions are f32 relative to each product's f64 transform, so a site kilometres from the origin keeps its millimetres; numpy.frombuffer(mesh.positions, numpy.float32) reads them without a copy.
Curves have no triangles: an Axis, a FootPrint or an annotation's curves come as polylines from the same wheel (#458), one array('f') per product and a slice per curve with the certified bound on how far the exact curve lies from it:
model = openbim_ifc.open(path)
for product in model.product_curves(representation="Axis"):
for line in product.polylines:
# positions: array('f'), x y z per vertex in metres relative
# to product.transform; this curve is a slice of them.
start = 3 * line.first_vertex
xyz = product.positions[start : start + 3 * line.vertex_count]
print((product.id, len(xyz) // 3, line.closed, line.chord_bound))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. It needs a wheel built with the graph feature:
# A wheel built with `maturin build --release --features graph`.
import json
kinds = {}
for product in model.product_geometry(): # or encoding="cbor": bytes
if product.refusal is not None or product.payload is None:
continue # refused, or no Body
envelope = json.loads(product.payload) # Axiolid's wire format
# Each node is tagged by its kind; a reference is an earlier node's index.
kinds[product.id] = [next(iter(node)) for node in envelope["graph"]["nodes"]]
# {36: ['Profile', 'SolidOperation', 'Instance'], 46: [...]}Each payload is the envelope {"format": "axiolid-geometry-graph", "version": "1.0", "graph": {...}}, exact, in world coordinates, metres; encoding="cbor" gives it as bytes. The binding page describes it.
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:
wall = model.by_guid("0LwOtle8vEaxZPGHkxQdYT") # an Entity, or None
# The first lookup indexes every GlobalId once; the rest reuse it.
wall_type = model.type_object(wall.id) # IfcRelDefinesByType
print((model[wall_type].Name, model.occurrences_of(wall_type))) # ('WT-200', [17, 18])
for opening in model.openings_of(wall.id): # IfcRelVoidsElement
fillings = [model[id].Name for id in model.fillings_of(opening)]
print((model[opening].Name, fillings))
# ('Door opening', ['Door 1'])
# ('Recess', [])
print(model.duplicate_global_ids()) # [] -- a copied GlobalId is listed here
print(openbim_ifc.new_guid()) # random, e.g. '2Vq9nF0yL5yQ$1x_4kZb3T'
print(openbim_ifc.name_based_guid("my-exporter", "wall", "42")) # same every runA GlobalId that several entities carry raises IfcError with codeinvalid-model naming every holder; model.duplicate_global_ids() lists them all. new_guid() draws an RFC 9562 version-4 UUID from the operating system; name_based_guid(*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:
walls = model.select("IfcWall, Pset_WallCommon.FireRating=F90") # Entity views
print([wall.Name for wall in walls]) # ['Wall A', 'Wall B']
print(model.select_ids('IfcDoor + location="Level 1"')) # [19, 28, 38, 40]
print(model.get_value(walls[0], "type.Name")) # 'WT-200'
print(model.get_value(walls[0], "materials.Name")) # ('Concrete',)
print(dict(model.get_value(walls[0], "Pset_WallCommon")))
# {'IsExternal': True, 'FireRating': 'F90', 'AcousticRating': 'R45'}
model.set_value(walls[0], "type.Name", "WT-300") # one checked transaction
try:
model.select("IfcWall, colour=red")
except openbim_ifc.IfcError as error:
print((error.code, error.span)) # ('invalid-selector', (9, 15))select returns Entity views and select_ids the ids, in ascending id order; base= restricts both to given entities. A property set reads as a read-only mapping, several values as a tuple, a reference as an Entity. set_value writes an attribute coerced as wall.Name = ... is, or a property in the type the property already states. A selector that does not parse, or names a class or attribute the release does not declare, raises IfcError with code invalid-selector and span, the byte range it refuses.
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:
columns = ["Name", "type.Name", "Pset_WallCommon.FireRating"]
csv = model.export_table("IfcWall", columns, key="id") # bytes; or format="xlsx"
print(csv.decode().splitlines()[1]) # '17,Wall A,WT-200,F90'
# Edit the table (here, as text), then plan the import: a dry run.
edited = csv.decode().replace("17,Wall A,WT-200,F90", "17,Wall A,WT-200,F120")
plan = model.import_table(edited, dry_run=True)
for change in plan.changes:
print((change.row, change.path, change.before, change.after))
# (2, 'Pset_WallCommon.FireRating', 'F90', 'F120')
result = model.import_table(edited) # one checked transaction
print(result.applied) # True
# A refused cell refuses the whole table, by row and column.
refused = model.import_table("id,Qto_WallBaseQuantities.Length\n17,long\n")
print([(r.row, r.column, r.code) for r in refused.refusals]) # [(2, 2, 'type-mismatch')]export_table returns bytes (format="xlsx" for a workbook); import_table takes bytes or a str and reports every change and refusal without raising. 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:
# Door #100 with what it alone owns: placement, representation, its
# filling and type relationships. A dry run changes nothing.
report = model.remove_product(100, dry_run=True)
print(len(report.removed)) # 11
for kept in report.kept:
if kept.reason == "shared": # still used by something that stays
print((kept.id, kept.type_name, kept.users))
# (82, 'IFCLOCALPLACEMENT', (81,)) the opening's placement
# (112, 'IFCREPRESENTATIONMAP', (111,)) the door type's geometry
model.remove_product(100) # 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.remove_product(20, rebase_placements=True)
# Then purge what nothing uses any more: no classes means every
# resource entity no IfcRoot reaches.
print([entity.type_name for entity in model.purge(dry_run=True).removed][:3])rebase_placements=True re-bases placements relative to a removed one onto its parent, composing the transforms; untype_occurrences=True lets a type object with occurrences go. Without them those removals raise still-referenced. purge(["IfcPropertySet"], include_roots=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:
from openbim_ifc import AuthorOp, Text, handle
# 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([
AuthorOp.assign_type(
111, [100], map_representations=True, predefined_type="sync", replace=True
)
])
# A new door, typed the same way in the batch that creates it.
result = model.author([
AuthorOp.product("IfcDoor", {"Name": Text("Door 2")}, container=14),
AuthorOp.assign_type(111, [handle(0)], map_representations=True),
])
body = model[result.ids[0]].Representation.Representations[0]
print(body.RepresentationType) # 'MappedRepresentation'
# Wall B (#40) moves into the building (#13); without replace the
# second containment is refused with invalid-model.
model.author([AuthorOp.contain(13, [40], replace=True)])The type assignment takes map_representations, mapping_target, material_usage, layer_set_direction, predefined_type 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; AuthorOp.contain and AuthorOp.aggregate take the same flag. material_usage derives an IfcMaterialLayerSetUsage (walls AXIS2, slabs and plates AXIS3, POSITIVE, offset 0) or an IfcMaterialProfileSetUsage from the type's set; predefined_type="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:
from openbim_ifc import AuthorOp, Enum, Real, Ref, handle
from openbim_ifc import List as Aggregate # an IFC aggregate value
h = handle # h(i): the entity operation i of the batch produces
result = model.author(
[
# The items first: a profile swept 1 m up, through the generic create.
AuthorOp.create("IfcCartesianPoint", {"Coordinates": Aggregate((Real(0.6), Real(0.05)))}),
AuthorOp.create("IfcAxis2Placement2D", {"Location": Ref(h(0))}),
AuthorOp.create(
"IfcRectangleProfileDef",
{"ProfileType": Enum("AREA"), "Position": Ref(h(1)), "XDim": Real(1.2), "YDim": Real(0.1)},
),
AuthorOp.create(
"IfcExtrudedAreaSolid",
{"SweptArea": Ref(h(2)), "Position": Ref(9), "ExtrudedDirection": Ref(31), "Depth": Real(1.0)},
),
# Context #11 is the 'Body' sub-context: the identifier is
# 'Body', and the type is inferred from the item.
AuthorOp.shape_representation(11, [h(3)]), # 4
AuthorOp.assign_representation(h(4), 130), # 5: window #130's shape
# Wall #20's body swapped for a copy of wall #40's.
AuthorOp.copy_representation(45), # 6
AuthorOp.unassign_representation(25, 20),
AuthorOp.remove_representation(25),
AuthorOp.assign_representation(h(6), 20),
]
)
print(model.attribute_by_name(result.ids[4], "RepresentationType")) # Text('SweptSolid')
# One alone, with a report of what went and what stayed:
report = model.remove_representation(105, dry_run=True) # the door's body
print([entity.id for entity in report.removed]) # [104, 105, 106, 107, 108]shape_representation checks a given representation_type 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 raises invalid-model; on a type (IfcTypeProduct) 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:
# 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.
report = model.copy_product(20, offset=(0.0, 2.0, 0.0))
print(model.attribute_by_name(report.copy, "Name")) # Text(value='Wall A')
print(len(report.copied)) # 55 originals, each with its copy
print(report.joined) # (19, 62, 67, 79, 80, 120, 136)
# 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.
library = openbim_ifc.open(CASCADE)
appended = model.append(library, [66], global_ids="regenerate")
print([entity.type_name for entity in appended.copied])
# ['IFCPROPERTYSINGLEVALUE', 'IFCPROPERTYSET', 'IFCWALLTYPE']
print(appended.skipped) # (67,): its occurrences stay in the libraryA copy copies what remove_product 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; global_ids="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
from openbim_ifc import AuthorOp, Header, Text, handle
model = IfcModel()
model.set_header(Header(
description=("ViewDefinition [DesignTransferView]",),
implementation_level="2;1",
name="new.ifc",
time_stamp=datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
author=("",),
organization=("",),
preprocessor_version="openbim-ifc",
originating_system="cookbook",
authorization="",
schema=("IFC4",), # the release every operation is checked against
))
result = model.author([ # handle(i): the entity operation i of this batch creates
AuthorOp.project({"Name": Text("Demo")}), # 0
AuthorOp.placement(), # 1
AuthorOp.spatial("IfcSite", handle(0), placement=handle(1)), # 2
AuthorOp.spatial("IfcBuilding", handle(2)), # 3
AuthorOp.spatial("IfcBuildingStorey", handle(3), {"Name": Text("Level 0")}), # 4
AuthorOp.placement(relative_to=handle(1), location=(4.0, 0.0, 0.0)), # 5
AuthorOp.product("IfcWall", {"Name": Text("Wall")}, container=handle(4), placement=handle(5)), # 6
])
wall = result.ids[6] # every IfcRoot got a GlobalId
data = 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:
# Between units named as text: exact factors, checked dimensions.
print(openbim_ifc.convert_unit(1.0, "foot", "MILLIMETRE")) # 304.8
print(openbim_ifc.format_length(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.
report = model.convert_length_unit("METRE", dry_run=True)
print((report.from_unit, report.to, report.length_factor))
# ('MILLI METRE', 'METRE', 0.001)
report = model.convert_length_unit("METRE")
print(report.applied) # True
for kept in report.kept: # values left as they are, and why
print((kept.entity_type, 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.