Reading IfcMapConversion with IfcOpenShell
This page extracts the georeferencing of an IFC4 or IFC4X3 building model with IfcOpenShell — the IfcMapConversion parameters, the IfcProjectedCRS it points to, the project length unit and the scaled IFC4X3 variant — into a single normalised record that a pipeline can validate and apply.
Why you hit this
Every tiling or twin-ingestion job that accepts BIM needs to know where the building is, and the answer is spread over at least three entities with optional attributes, schema-dependent names and a unit system that is not the map’s. Code that reads model.by_type("IfcMapConversion")[0].Eastings works on the first sample file and fails on the second, because the second has two representation contexts, or a None scale, or a CRS name written as "ETRS89 / UTM zone 32N" rather than an EPSG code. The theory of what each parameter means is in BIM and IFC georeferencing for digital twins; this page is the defensive reader.
Prerequisites
ifcopenshell>=0.8.0,pyproj>=3.6, Python 3.10+.- An IFC4, IFC4X1 or IFC4X3 file. IFC2X3 files have no
IfcMapConversionentity at all — see georeferencing IFC2x3 models without IfcMapConversion. - Knowledge of the CRS the project was delivered in, as an EPSG code, so the reader’s output can be checked against it — for example EPSG:25832 for ETRS89 / UTM 32N or EPSG:2263 for New York State Plane Long Island in US feet.
Step-by-Step
1. Find the model context the conversion belongs to
A model can carry several IfcGeometricRepresentationContext entities — a 3D model context, a 2D plan context, sub-contexts for body and axis. The map conversion hangs off one of them through the inverse attribute HasCoordinateOperation.
import ifcopenshell
model = ifcopenshell.open("clinic_block_c.ifc")
assert model.schema.startswith("IFC4"), f"{model.schema}: no IfcMapConversion in this schema"
contexts = [
c for c in model.by_type("IfcGeometricRepresentationContext")
if not c.is_a("IfcGeometricRepresentationSubContext")
]
for c in contexts:
ops = c.HasCoordinateOperation or ()
print(c.id(), c.ContextType, c.CoordinateSpaceDimension, [o.is_a() for o in ops])
Iterating contexts rather than calling by_type("IfcMapConversion") directly is what makes the reader correct for files with a second, unrelated conversion — the pattern some authoring tools use when a plan context is georeferenced separately from the model context. Prefer the context whose ContextType is "Model" and whose dimension is 3.
2. Read the conversion into a normalised record
import math
from dataclasses import dataclass
@dataclass(frozen=True)
class MapConversion:
crs_name: str
vertical_datum: str | None
eastings: float
northings: float
height: float
rotation_rad: float
scale_xy: float
scale_z: float
def read_map_conversion(model):
ctx = next(c for c in contexts if c.ContextType == "Model" and c.CoordinateSpaceDimension == 3)
op = next((o for o in (ctx.HasCoordinateOperation or ()) if o.is_a("IfcMapConversion")), None)
if op is None:
return None
crs = op.TargetCRS
abscissa = op.XAxisAbscissa if op.XAxisAbscissa is not None else 1.0
ordinate = op.XAxisOrdinate if op.XAxisOrdinate is not None else 0.0
if op.is_a("IfcMapConversionScaled"): # IFC4X3 only
base = op.Scale if op.Scale is not None else 1.0
sx, sy, sz = op.FactorX, op.FactorY, op.FactorZ
assert abs(sx - sy) < 1e-9, "anisotropic horizontal scale is not a conformal map"
scale_xy, scale_z = base * sx, base * sz
else:
scale_xy = scale_z = op.Scale if op.Scale is not None else 1.0
return MapConversion(
crs_name=crs.Name,
vertical_datum=getattr(crs, "VerticalDatum", None),
eastings=float(op.Eastings),
northings=float(op.Northings),
height=float(op.OrthogonalHeight),
rotation_rad=math.atan2(ordinate, abscissa),
scale_xy=scale_xy,
scale_z=scale_z,
)
mc = read_map_conversion(model)
print(mc)
Two defaults matter here. XAxisAbscissa and XAxisOrdinate are optional, and their absence means no rotation, so they default to (1, 0) — not (0, 0), which would make atan2 return zero by accident and hide a genuinely malformed file behind a plausible value. Scale is optional and defaults to 1.0 per the schema, which the reader records honestly; deciding whether 1.0 is right is the validation step’s job, not the reader’s.
3. Resolve the CRS name to an EPSG code
IfcProjectedCRS.Name should be an EPSG identifier, and in practice is whatever the authoring tool wrote.
from pyproj import CRS
from pyproj.database import query_crs_info
from pyproj.exceptions import CRSError
def resolve_crs(name):
try:
crs = CRS.from_user_input(name) # "EPSG:25832", "epsg:25832"
except CRSError:
hits = [i for i in query_crs_info(auth_name="EPSG") if i.name.lower() == name.lower()]
if len(hits) != 1:
raise ValueError(f"cannot resolve CRS name {name!r} to exactly one EPSG code")
crs = CRS.from_epsg(int(hits[0].code))
epsg = crs.to_epsg()
if epsg is None or not crs.is_projected:
raise ValueError(f"{name!r} is not a projected CRS with an EPSG code")
return epsg, crs
epsg, crs = resolve_crs(mc.crs_name)
print(f"EPSG:{epsg}", crs.name, [a.unit_name for a in crs.axis_info])
The name lookup handles the common authoring-tool output — the CRS’s descriptive name, such as "ETRS89 / UTM zone 32N" — and refuses anything ambiguous. Printing the axis units catches the US case: EPSG:2263 is in US survey feet, so the map-unit side of Scale is feet and a metric model needs a scale near 3.2808, not 1.0.
4. Reconcile Scale against the project length unit
import ifcopenshell.util.unit
unit_to_m = ifcopenshell.util.unit.calculate_unit_scale(model)
map_unit_to_m = crs.axis_info[0].unit_conversion_factor # 1.0 for metres, 0.3048006 for US ft
expected = unit_to_m / map_unit_to_m
ratio = mc.scale_xy / expected
print(f"project unit→m {unit_to_m}, map unit→m {map_unit_to_m:.7f}, "
f"Scale {mc.scale_xy}, expected ≈ {expected:.7f}, ratio {ratio:.6f}")
if abs(ratio - 1.0) < 0.001:
verdict = "consistent"
elif abs(ratio * 1000 - 1.0) < 0.001 or abs(ratio / 1000 - 1.0) < 0.001:
verdict = "off by 1000: Scale ignores the millimetre project unit"
else:
verdict = "unexplained: check for a projection scale factor or a wrong unit"
print(verdict)
A ratio within a few parts per ten thousand of 1.0 is consistent — the residual is the projection scale factor, which some authors fold into Scale and some do not, and 0.9996 at a UTM central meridian is legitimate. A ratio of 1000 or 0.001 is the classic millimetre mistake. Anything else needs a human.
5. Emit the record as a sidecar
import json
from dataclasses import asdict
from pathlib import Path
record = asdict(mc) | {
"epsg": epsg,
"project_unit_to_m": unit_to_m,
"scale_verdict": verdict,
"rotation_deg": math.degrees(mc.rotation_rad),
"source_file": "clinic_block_c.ifc",
"schema": model.schema,
}
Path("clinic_block_c.georef.json").write_text(json.dumps(record, indent=2))
Writing the normalised record next to the model makes the georeferencing reviewable in a pull request and diffable between model revisions — a rotation that changes by 0.8° between two deliveries of the same building is visible at a glance in JSON and invisible in a 300 MB STEP file.
The record is also the right place to stop a delivery. A CI job that runs the reader on every incoming revision can compare the new sidecar with the last accepted one and fail when any parameter moves by more than its tolerance — a centimetre on the translation, a hundredth of a degree on the rotation, a part per million on the scale. Genuine changes to georeferencing are rare once a project is set out, so an alert on movement catches the far more common case of an export setting that changed between two authors, or a model re-saved from a template with a different origin. Keeping the tolerances in the same repository as the sidecars means a deliberate re-survey is recorded as a reviewed change rather than an override.
Expected Output & Verification
For a millimetre IFC4 model delivered in ETRS89 / UTM 32N with the projection scale folded in:
EPSG:25832 ETRS89 / UTM zone 32N ['metre', 'metre']
project unit→m 0.001, map unit→m 1.0000000, Scale 0.0009996, expected ≈ 0.0010000, ratio 0.999600
consistent
Verify the record rather than the reader. Load the sidecar in a fresh process, apply it to four surveyed corners in the model’s local coordinates and require every horizontal residual under 5 cm; then check that rotation_deg agrees with the angle of a long, straight facade measured on the cadastral map in the same EPSG code, to within a tenth of a degree.
Common Errors
AttributeError: entity instance of type 'IFC4.IfcGeometricRepresentationSubContext' has no attribute 'HasCoordinateOperation'. Sub-contexts inherit from the context entity but do not carry the inverse in every IfcOpenShell build. Filter them out as step 1 does before reading the inverse.
StopIteration on the model context. The file has only a "Plan" context, typical of exports from 2D-first tools, or a context whose ContextType is None. Fall back to the first context with CoordinateSpaceDimension == 3 and log that the model context was unnamed.
CRSError: Invalid projection: ETRS89 / UTM zone 32N. The authoring tool wrote the descriptive name rather than an identifier. The database lookup in step 3 resolves it; if it returns several candidates, the name is genuinely ambiguous and the project’s delivery specification has to supply the code.
Frequently Asked Questions
Can a model legitimately have more than one IfcMapConversion?
Yes — one per representation context, for instance a georeferenced plan context alongside the model context. They should agree. When they do not, the model context wins for 3D work, and the disagreement belongs in the validation report.
Why not use ifcopenshell.util.geolocation instead of writing a reader?
Use it for the arithmetic if it suits your IfcOpenShell version; its helpers have changed names between releases. The reader above exists for the defensive part — context selection, defaults, CRS resolution and the scale verdict — which a transform helper does not do for you.
Does the reader need the geometry at all?
No, and it should not load it. Opening a 400 MB model and reading a handful of entities takes seconds; triangulating it takes minutes. Keep the reader fast so it can run as a pre-flight check on every delivered revision.
Related Guides
- Transforming IFC Coordinates to ECEF for 3D Tiles — applying the record to geometry
- Georeferencing IFC2x3 Models Without IfcMapConversion — the older schema’s options
- Asserting CRS and Units with pyproj — turning the verdict into a CI gate