Reading IFC Georeferencing with ifcopenshell
The direct answer: an IFC4 file records its georeferencing in an IfcMapConversion object, which carries the Eastings, Northings, OrthogonalHeight, XAxisAbscissa, XAxisOrdinate, and Scale needed to map local model coordinates onto a projected grid, and links to an IfcProjectedCRS naming the grid (for example EPSG:25832). Read those attributes with ifcopenshell, derive the rotation as atan2(XAxisOrdinate, XAxisAbscissa), and apply rotation, scale, and offset to move local (x, y, z) to projected (E, N, H). IFC2x3 has no map conversion, so fall back to the IfcSite latitude/longitude anchor. This closes the coordinate gap left open by the geometry routines in the ifcopenshell Workflow guide.
How ifcopenshell Exposes Georeferencing
IFC4 introduced a formal coordinate-operation model. The model’s IfcGeometricRepresentationContext (the same context that defines precision and dimensionality) can reference an IfcMapConversion through its inverse HasCoordinateOperation attribute. That map conversion is the bridge between the file’s local engineering coordinates and a real-world projected system. Its TargetCRS is an IfcProjectedCRS whose Name is conventionally an EPSG string such as EPSG:25832, alongside optional GeodeticDatum, MapProjection, MapZone, and MapUnit fields.
The map conversion stores a rigid 2D transform plus a height offset:
Eastings,Northings,OrthogonalHeight— the projected coordinates of the model’s local origin.XAxisAbscissa,XAxisOrdinate— the components of a unit vector giving the direction of the local X axis in the grid. They encode rotation as(cos θ, sin θ), not as an angle.Scale— a combined map scale factor relating model distances to grid distances. It defaults to1.0.
The forward transform for a local point (x, y) follows the buildingSMART definition:
E = Eastings + Scale * (x * XAxisAbscissa - y * XAxisOrdinate)
N = Northings + Scale * (x * XAxisOrdinate + y * XAxisAbscissa)
H = OrthogonalHeight + z * Scale
The rotation angle, when you need it explicitly (for aligning a north arrow or reprojecting downstream), is θ = atan2(XAxisOrdinate, XAxisAbscissa).
ifcopenshell ships a helper module, ifcopenshell.util.geolocation, whose functions xyz2enh and enh2xyz apply exactly this forward and inverse transform, while local2global composes it into a placement matrix. Those helpers are convenient, but reading the attributes yourself keeps the transform explicit and portable — the approach the script below takes.
What IFC georeferencing does not guarantee:
- It is not always present. IFC2x3 predates
IfcMapConversionentirely, and many IFC4 exports still omit it. - The CRS
Nameis not validated. It is a free-text label;EPSG:25832,25832, andETRS89 / UTM zone 32Nall appear in the wild. - The
IfcSitefallback is coarse.RefLatitude/RefLongitudeare degrees-minutes-seconds tuples meant as an approximate anchor, not a survey-grade grid transform.
Production-Ready Script
The script reads the IfcProjectedCRS name and IfcMapConversion parameters, builds a forward transform, and maps a batch of local coordinates to projected eastings and northings. When no map conversion exists it falls back to the IfcSite degrees-minutes-seconds anchor. The math is written out explicitly so the transform is auditable and does not depend on a specific util.geolocation signature.
# ifcopenshell>=0.8.0, numpy>=1.24.0, Python 3.9+
import ifcopenshell
import numpy as np
import math
from dataclasses import dataclass
from typing import Optional
@dataclass
class MapTransform:
eastings: float
northings: float
orthogonal_height: float
x_abscissa: float # cos(rotation)
x_ordinate: float # sin(rotation)
scale: float
crs_name: Optional[str]
@property
def rotation_rad(self) -> float:
return math.atan2(self.x_ordinate, self.x_abscissa)
def apply(self, xyz: np.ndarray) -> np.ndarray:
"""Map local (N,3) coordinates to projected (E, N, H)."""
x, y, z = xyz[:, 0], xyz[:, 1], xyz[:, 2]
e = self.eastings + self.scale * (x * self.x_abscissa - y * self.x_ordinate)
n = self.northings + self.scale * (x * self.x_ordinate + y * self.x_abscissa)
h = self.orthogonal_height + z * self.scale
return np.column_stack([e, n, h])
def read_map_transform(model) -> Optional[MapTransform]:
"""Read IfcMapConversion + IfcProjectedCRS. Returns None if absent (IFC2x3)."""
conversions = model.by_type("IfcMapConversion")
if not conversions:
return None
mc = conversions[0]
# TargetCRS is the IfcProjectedCRS; its Name is usually an EPSG string.
crs_name = None
target = getattr(mc, "TargetCRS", None)
if target is not None:
crs_name = getattr(target, "Name", None)
# XAxisAbscissa/XAxisOrdinate are optional; default to no rotation.
abscissa = mc.XAxisAbscissa if mc.XAxisAbscissa is not None else 1.0
ordinate = mc.XAxisOrdinate if mc.XAxisOrdinate is not None else 0.0
# Normalize the direction vector so it is a true unit rotation.
mag = math.hypot(abscissa, ordinate) or 1.0
return MapTransform(
eastings=mc.Eastings or 0.0,
northings=mc.Northings or 0.0,
orthogonal_height=mc.OrthogonalHeight or 0.0,
x_abscissa=abscissa / mag,
x_ordinate=ordinate / mag,
scale=mc.Scale if mc.Scale is not None else 1.0,
crs_name=crs_name,
)
def _dms_to_dd(dms) -> float:
"""Convert an IFC compound-plane-angle [deg, min, sec, millionths] to decimal."""
d = dms[0]
m = dms[1] if len(dms) > 1 else 0
s = dms[2] if len(dms) > 2 else 0
us = dms[3] if len(dms) > 3 else 0 # millionths of a second
sign = -1.0 if min(d, m, s, us) < 0 else 1.0
return sign * (abs(d) + abs(m) / 60.0 + abs(s) / 3600.0 + abs(us) / (3600.0 * 1e6))
def read_site_anchor(model) -> Optional[dict]:
"""IFC2x3 fallback: coarse geographic anchor from IfcSite."""
sites = model.by_type("IfcSite")
if not sites:
return None
site = sites[0]
if not site.RefLatitude or not site.RefLongitude:
return None
return {
"latitude": _dms_to_dd(site.RefLatitude),
"longitude": _dms_to_dd(site.RefLongitude),
"elevation": site.RefElevation or 0.0,
}
if __name__ == "__main__":
model = ifcopenshell.open("model.ifc")
transform = read_map_transform(model)
if transform is not None:
print(f"Projected CRS: {transform.crs_name}")
print(f"Grid rotation: {math.degrees(transform.rotation_rad):.4f} deg")
# Example: move three local points onto the projected grid.
local = np.array([[0.0, 0.0, 0.0],
[10.0, 0.0, 3.0],
[10.0, 5.0, 3.0]])
print(transform.apply(local))
else:
anchor = read_site_anchor(model)
if anchor:
print(f"No IfcMapConversion; IfcSite anchor: "
f"{anchor['latitude']:.6f}, {anchor['longitude']:.6f}")
else:
print("No georeferencing found in this file.")
Key implementation notes:
- The direction vector is normalized (
abscissa / mag,ordinate / mag) before use. Some exporters store a slightly non-unit vector; normalizing prevents a stray scale creeping into the rotation. - Every attribute read is null-guarded.
XAxisAbscissa,XAxisOrdinate, andScaleare optional in the schema, and a missing rotation must default to the identity, not to zero. _dms_to_ddcarries a single sign across all components. IFC stores negative latitudes and longitudes with every non-zero element negated, so taking the sign from the minimum component is robust when the degrees field itself is zero.- The transform is a rigid 2D map conversion plus a height offset. It does not reproject between two EPSG grids — for that, feed the projected
crs_nameand the eastings/northings into apyprojtransformer, as shown in Reprojecting CAD Coordinates with pyproj Transformer.
The projected coordinates from transform.apply are exactly what the footprints in Batch Converting IFC to GeoJSON with ifcopenshell need before serialization.
Compatibility Matrix
| Component | Supported Range | Notes |
|---|---|---|
| Python | 3.9 – 3.12 | dataclasses and math.atan2 only; no external transform dependency |
| ifcopenshell | ≥ 0.8.0 | by_type attribute access is schema-stable; util.geolocation helpers (xyz2enh, enh2xyz, local2global) available since 0.7.0 |
| NumPy | ≥ 1.24.0 | Vectorizes the transform over coordinate batches |
| IFC4 / IFC4x3 | Full | IfcMapConversion + IfcProjectedCRS; IFC4x3 adds optional ScaleY/ScaleZ for anisotropic scaling |
| IFC2x3 | Fallback only | No map conversion; use IfcSite RefLatitude/RefLongitude/RefElevation |
| CRS resolution | pyproj ≥ 3.5 | Needed only to interpret the IfcProjectedCRS name or reproject onward |
Fallback Strategies
1. No IfcMapConversion (IFC2x3 or an unreferenced IFC4 export)
read_map_transform returns None. Drop to read_site_anchor, but treat the result as an approximate origin only. Combine it with a known project rotation from survey documentation rather than trusting the file to supply the grid alignment.
2. Keep working in local coordinates when georeferencing is missing
For internal geometry checks you often do not need real-world coordinates at all. Expose a local_fallback flag so the pipeline continues with an identity transform and flags the output as ungeoreferenced:
# ifcopenshell>=0.8.0
def resolve_transform(model, local_fallback=True):
t = read_map_transform(model)
if t is None and local_fallback:
return MapTransform(0, 0, 0, 1.0, 0.0, 1.0, crs_name=None) # identity
return t
3. Scale factor confusion
Scale is a combined map scale factor, not a unit conversion. A millimeter model still needs its length-unit scaling applied separately via ifcopenshell.util.unit.calculate_unit_scale; do not fold the two together. Apply the unit scale to local coordinates first, then the map conversion.
4. Rotation appears mirrored or 90 degrees off
A mirrored result usually means the abscissa/ordinate pair was read as (sin, cos) instead of (cos, sin). Confirm with atan2(XAxisOrdinate, XAxisAbscissa) — abscissa is the cosine term. A 90-degree error typically means a rotation was assumed when the vector was actually absent and should have defaulted to identity.
5. IfcProjectedCRS.Name is not a clean EPSG code
Normalize before handing it to pyproj. Strip whitespace, accept a bare integer as an EPSG code, and keep a small lookup for named grids your projects use:
def normalize_crs(name: str) -> str:
name = (name or "").strip()
if name.isdigit():
return f"EPSG:{name}"
return name # e.g. 'EPSG:25832' or a WKT string pyproj can parse
FAQ
Why does my IFC2x3 file have no IfcMapConversion?
IfcMapConversion and IfcProjectedCRS were introduced in IFC4. IFC2x3 files cannot carry them. Georeferencing in IFC2x3 relies on IfcSite RefLatitude, RefLongitude, and RefElevation, which are coarse degrees-minutes-seconds tuples intended as an approximate anchor, not a precise grid transform.
How is the grid rotation encoded in IfcMapConversion?
The rotation is stored as a unit direction vector in XAxisAbscissa (its cosine) and XAxisOrdinate (its sine), not as an angle. Recover the counter-clockwise angle from grid east with atan2(XAxisOrdinate, XAxisAbscissa). If both are absent, assume no rotation.
What does the Scale attribute on IfcMapConversion do?
Scale is a combined map scale factor that relates model distances to grid distances, absorbing the point scale factor of the projection. It defaults to 1.0 when omitted. Apply it to the rotated local coordinates before adding the eastings and northings offset.
How do I convert IfcSite RefLatitude to decimal degrees?
RefLatitude and RefLongitude are lists of integers: degrees, minutes, seconds, and optionally millionths of a second. Convert with degrees + minutes/60 + seconds/3600 + millionths/(3600 x 1e6), carrying the sign of the first non-zero component across all terms.
Related Pages
- ifcopenshell Workflow — parent guide covering geometry compilation and the coordinate stages this georeferencing feeds
- Batch Converting IFC to GeoJSON with ifcopenshell — apply this map transform to footprints before writing GeoJSON
- Extracting IFC Wall Geometries to Shapely — sibling routine that produces the local geometry this transform georeferences
- Reprojecting CAD Coordinates with pyproj Transformer — reproject the projected eastings and northings between EPSG grids
- Converting CAD Local Coordinates to EPSG:4326 — the wider local-to-global coordinate normalization workflow