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 to 1.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 IfcMapConversion entirely, and many IFC4 exports still omit it.
  • The CRS Name is not validated. It is a free-text label; EPSG:25832, 25832, and ETRS89 / UTM zone 32N all appear in the wild.
  • The IfcSite fallback is coarse. RefLatitude/RefLongitude are degrees-minutes-seconds tuples meant as an approximate anchor, not a survey-grade grid transform.
IFC georeferencing resolution and fallback The representation context links to IfcMapConversion and IfcProjectedCRS for a precise grid transform; when that is absent, as in IFC2x3, the path falls back to IfcSite RefLatitude, RefLongitude, and RefElevation converted from degrees-minutes-seconds. RepresentationContext HasCoordinateOperation? present absent (IFC2x3) IfcMapConversion E, N, H, rotation, scale IfcSite lat / long DMS tuples, coarse Projected E / N IfcProjectedCRS grid Decimal degrees EPSG:4326 anchor precise grid transform on the left; approximate site anchor fallback on the right

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, and Scale are optional in the schema, and a missing rotation must default to the identity, not to zero.
  • _dms_to_dd carries 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_name and the eastings/northings into a pyproj transformer, 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.