Mapping IFC Properties to GeoJSON Attributes with Python
To map IFC properties to GeoJSON attributes, extract IfcPropertySet and IfcElementQuantity data via ifcopenshell, flatten the hierarchical BIM metadata into a deterministic key-value dictionary, reproject geometry from the IFC local coordinate system to WGS84 (EPSG:4326) with pyproj, and serialize each element as a GeoJSON Feature. The complete pipeline is five steps: element filtering, IsDefinedBy traversal, EXPRESS type normalization, coordinate transformation, and FeatureCollection serialization. For the broader context of IFC entity structures and infrastructure-specific property sets, see the IFC4x3 Schema Mapping reference.
How ifcopenshell Handles Property Extraction
IFC stores metadata in a graph of relationship entities rather than as direct element attributes. The key relationships are:
IfcRelDefinesByProperties— connects an element to anIfcPropertySet(free-form name/value pairs) or anIfcElementQuantity(typed physical measurements).IfcPropertySingleValue— the most common property node; holds aNamestring and aNominalValuetyped as an EXPRESS wrapper such asIfcLabel,IfcReal, orIfcBoolean.IfcPropertyEnumeratedValue— a list-valued property whereEnumerationValuesreturns a tuple of EXPRESS-typed entries.IfcQuantityLength/IfcQuantityArea/IfcQuantityVolume— quantity subtypes that expose typed numeric accessors (LengthValue,AreaValue,VolumeValue).
ifcopenshell exposes element.IsDefinedBy as a Python list that you iterate directly. Each entry may be IfcRelDefinesByProperties or an unrelated relationship type, so you must guard with rel.is_a("IfcRelDefinesByProperties") before casting. EXPRESS wrapper values carry a .wrappedValue attribute; calling hasattr(val, "wrappedValue") and unwrapping recursively handles arbitrarily nested type chains without hard-coding every IFC type name.
GeoJSON’s properties object must be a flat JSON object — no nested dicts, no non-JSON types. IFC’s hierarchical model means multiple property sets on a single element can contain properties with identical names. The safe pattern prefixes each value key with its parent property set name (e.g., WallAssembly__FireRating) so downstream ingestion into PostGIS, QGIS, or a web mapping API never silently overwrites data.
The diagram below shows the relationship traversal path from an IfcElement to a serialized GeoJSON Feature:
Production-Ready Script
The script below is a complete, copy-pasteable extraction routine. It handles nested property sets, IfcElementQuantity with four quantity subtypes, collision-safe key prefixing, and WGS84 coordinate output. Replace the geometry stub with your actual pyproj transformation for production use.
# Requires: ifcopenshell>=0.8.0, pyproj>=3.0, geojson>=3.0
import ifcopenshell
import json
from typing import Any, Dict, List
from pathlib import Path
def normalize_ifc_value(val: Any) -> Any:
"""Recursively unwrap IFC EXPRESS typed wrappers to JSON-safe primitives."""
if val is None:
return None
# Unwrap single-level and nested EXPRESS typed values (IfcLabel, IfcReal, etc.)
if hasattr(val, "wrappedValue"):
return normalize_ifc_value(val.wrappedValue)
if isinstance(val, (str, int, float, bool)):
return val
# IfcPropertyEnumeratedValue returns a tuple — flatten to list
if hasattr(val, "__iter__") and not isinstance(val, str):
return [normalize_ifc_value(v) for v in val]
return str(val)
def extract_properties(element) -> Dict[str, Any]:
"""
Flatten all IfcPropertySet and IfcElementQuantity data attached to an element.
Keys are prefixed with their parent set name to prevent cross-set collisions:
e.g., 'Pset_WallCommon__FireRating' rather than a bare 'FireRating'.
"""
props: Dict[str, Any] = {
"ifc_guid": element.GlobalId,
"ifc_type": element.is_a(),
"ifc_name": getattr(element, "Name", None),
}
if not hasattr(element, "IsDefinedBy"):
return props
for rel in element.IsDefinedBy:
if not rel.is_a("IfcRelDefinesByProperties"):
continue
pset_def = rel.RelatingPropertyDefinition
pset_name = getattr(pset_def, "Name", "Unknown")
if pset_def.is_a("IfcPropertySet"):
for prop in pset_def.HasProperties:
if prop.is_a("IfcPropertySingleValue"):
key = f"{pset_name}__{prop.Name}"
props[key] = normalize_ifc_value(prop.NominalValue)
elif prop.is_a("IfcPropertyEnumeratedValue"):
key = f"{pset_name}__{prop.Name}"
props[key] = normalize_ifc_value(prop.EnumerationValues)
elif pset_def.is_a("IfcElementQuantity"):
for qty in pset_def.Quantities:
if qty.is_a("IfcQuantityLength"):
props[f"QTY_{pset_name}__{qty.Name}"] = normalize_ifc_value(
qty.LengthValue
)
elif qty.is_a("IfcQuantityArea"):
props[f"QTY_{pset_name}__{qty.Name}"] = normalize_ifc_value(
qty.AreaValue
)
elif qty.is_a("IfcQuantityVolume"):
props[f"QTY_{pset_name}__{qty.Name}"] = normalize_ifc_value(
qty.VolumeValue
)
elif qty.is_a("IfcQuantityCount"):
props[f"QTY_{pset_name}__{qty.Name}"] = normalize_ifc_value(
qty.CountValue
)
return props
def get_element_centroid_wgs84(element, transformer) -> list | None:
"""
Return [longitude, latitude] for the element's local placement origin,
reprojected to EPSG:4326 via pyproj.Transformer.
In a full pipeline, replace this stub with triangulated geometry extraction
using ifcopenshell.geom.create_shape() or ifcopenshell.util.placement.
"""
try:
import ifcopenshell.util.placement
m = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
# m is a 4x4 numpy array; column 3 holds the translation vector
local_x, local_y, local_z = float(m[0][3]), float(m[1][3]), float(m[2][3])
lon, lat = transformer.transform(local_x, local_y)
return [lon, lat]
except Exception:
return None
def build_feature_collection(
ifc_path: str,
source_epsg: int,
output_path: str,
element_types: list[str] | None = None,
) -> None:
"""
Parse an IFC file, map properties for each spatial element,
reproject geometry to EPSG:4326, and write a GeoJSON FeatureCollection.
Args:
ifc_path: Path to the .ifc file.
source_epsg: EPSG code of the IFC project's coordinate system.
output_path: Destination .geojson file path.
element_types: IFC entity type names to include; defaults to common types.
"""
from pyproj import Transformer # pyproj>=3.0
if element_types is None:
element_types = [
"IfcBuildingElement",
"IfcCivilElement",
"IfcTransportElement",
"IfcFacilitiesPart",
]
ifc_file = ifcopenshell.open(ifc_path)
transformer = Transformer.from_crs(
f"EPSG:{source_epsg}", "EPSG:4326", always_xy=True
)
features: List[Dict] = []
for elem_type in element_types:
for element in ifc_file.by_type(elem_type):
props = extract_properties(element)
coords = None
if hasattr(element, "ObjectPlacement") and element.ObjectPlacement:
coords = get_element_centroid_wgs84(element, transformer)
geometry = (
{"type": "Point", "coordinates": coords} if coords else None
)
features.append(
{
"type": "Feature",
"id": element.GlobalId,
"geometry": geometry,
"properties": props,
}
)
fc = {"type": "FeatureCollection", "features": features}
Path(output_path).write_text(
json.dumps(fc, indent=2, ensure_ascii=False), encoding="utf-8"
)
print(f"Wrote {len(features)} features to {output_path}")
# --- Entry point ---
# build_feature_collection(
# ifc_path="infrastructure_model.ifc",
# source_epsg=27700, # e.g. British National Grid
# output_path="output.geojson"
# )
Key implementation notes:
normalize_ifc_valuerecurses on.wrappedValueto handle multi-level EXPRESS nesting (e.g.,IfcMeasureWithUnitwrappingIfcReal). Never hard-code a list of IFC type names — the recursive check is format-version agnostic.- Key prefixing with
pset_name__prevents data loss when two property sets both define a property calledFireRatingorDescription. Downstream PostGIS columns and QGIS attribute tables receive unique field names automatically. ifcopenshell.util.placement.get_local_placementreturns a 4×4 numpy array; the translation component is in column index 3, rows 0–2. This is the element origin in the IFC project coordinate system — applypyproj.Transformerimmediately before appending toproperties.- Pass
always_xy=TruetoTransformer.from_crsto guarantee longitude-first coordinate order in EPSG:4326 output, matching the GeoJSON RFC 7946 convention. - When only metadata mapping is required (no geometry), omit the
ifcopenshell.geomimport entirely and skipget_element_centroid_wgs84. Geometry parsing is the dominant CPU cost in large IFC files.
Compatibility Matrix
| Component | Supported Range | Notes |
|---|---|---|
ifcopenshell |
>=0.7.0 |
IFC4x3 EXPRESS schema requires >=0.8.0; IsDefinedBy traversal works across IFC2x3, IFC4, IFC4x3 |
pyproj |
>=3.0 |
always_xy parameter introduced in 3.0; earlier versions silently swap axes |
geojson (optional) |
>=3.0 |
Used here via plain dict/json; the geojson package validates Feature structure if installed |
| Python | 3.9+ |
Uses `list[str] |
| IFC Schema Version | IFC2x3, IFC4, IFC4x3 | IsDefinedBy and IfcPropertySet exist across all three; IFC4x3 adds Pset_Alignment*, Pset_Railway* |
| OS | Linux, macOS, Windows | ifcopenshell wheels available for all three on PyPI for Python 3.9–3.12 |
| Known limitations | IfcPropertyTableValue, IfcPropertyListValue |
These subtypes are not handled above; add explicit branches if your models use them |
Fallback Strategies and Troubleshooting
1. IsDefinedBy is empty or returns no IfcRelDefinesByProperties entries
This occurs when the IFC file stores type-level properties on IfcTypeObject rather than on instance elements. Use ifcopenshell.util.element.get_psets(element, psets_only=False) as a drop-in replacement — it merges instance and type-level property sets automatically:
import ifcopenshell.util.element
def extract_properties_merged(element) -> dict:
# Returns {pset_name: {prop_name: value, ...}, ...} merged from instance + type
raw = ifcopenshell.util.element.get_psets(element, psets_only=False)
flat = {"ifc_guid": element.GlobalId, "ifc_type": element.is_a()}
for pset_name, prop_dict in raw.items():
for prop_name, val in prop_dict.items():
flat[f"{pset_name}__{prop_name}"] = val
return flat
2. pyproj.Transformer raises CRSError: Invalid projection
The IFC file’s IfcProjectedCRS may store an authority-specific name (e.g., "OSGB 1936 / British National Grid") rather than an EPSG: string. Resolve it with pyproj.CRS.from_user_input() before building the transformer:
from pyproj import CRS, Transformer
source_crs = CRS.from_user_input("OSGB 1936 / British National Grid")
transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True)
3. Feature geometry is null for every element
Most often the IFC file stores geometry in a project’s local coordinate system with no ObjectPlacement on leaf elements — only on their container (IfcBuildingStorey, IfcSite). Use ifcopenshell.util.placement.get_local_placement on the containing spatial structure, then offset leaf element positions relative to it.
4. Output GeoJSON fails validation in QGIS or Mapbox
RFC 7946 requires all coordinates to be decimal degrees in WGS84, longitude first. If output values exceed ±180 for longitude or ±90 for latitude, pyproj transformed into a projected output CRS. Verify always_xy=True is set and that the destination CRS argument is "EPSG:4326" not "EPSG:3857".
5. Memory exhaustion on large infrastructure models (>500 MB)
Replace ifc_file.by_type(elem_type) list iteration with a generator and stream features directly to a file rather than accumulating a Python list:
import ijson # pip install ijson — for streaming large JSON; here used for output only
def stream_feature_collection(ifc_path: str, elem_types: list, transformer, out_path: str):
ifc_file = ifcopenshell.open(ifc_path)
with open(out_path, "w", encoding="utf-8") as f:
f.write('{"type":"FeatureCollection","features":[\n')
first = True
for etype in elem_types:
for element in ifc_file.by_type(etype):
props = extract_properties(element)
feature = {"type": "Feature", "id": element.GlobalId,
"geometry": None, "properties": props}
if not first:
f.write(",\n")
json.dump(feature, f, ensure_ascii=False)
first = False
f.write("\n]}")
For infrastructure models where geometry is the bottleneck, open the IFC file with geometry disabled — pass settings that skip shape creation and call get_psets directly. Parsing geometry for a 300,000-element rail alignment model can consume 8–12 GB RAM; metadata-only extraction typically stays under 1 GB.
Related Pages
- IFC4x3 Schema Mapping — parent reference covering entity hierarchy traversal, CRS extraction, and the full IFC4x3 property model
- Core Format Fundamentals & Schema Mapping — the broader format interoperability context including DXF, DWG, and IFC schema translation patterns
- Metadata Extraction Strategies — parallel extraction patterns for DXF XDATA and block attributes alongside IFC property sets
- DXF Entity Structure Breakdown — contrast with IFC: how DXF encodes properties as group codes and extended data rather than typed relationship graphs