Converting IFC Length Units to Metres in Python
An IFC model declares its length unit on the project, and unlike a DXF header that declaration is binding on every length in the file. Read it, resolve any prefix or conversion factor into a single multiplier, establish once whether your geometry settings already applied it, and verify the result with an extent check. The failure this prevents is the classic thousandfold error, which produces a structurally perfect model at one-thousandth of its size. This page is part of Unit Conversion Pipelines.
How IFC Declares a Length Unit
The project carries a unit assignment listing the units in force for each measure type. The length entry takes one of two forms.
An SI unit names a base unit and optionally a prefix — metre with the prefix MILLI is a millimetre. The prefix is an enumerated name, not a number, so resolving it means a lookup table rather than arithmetic on a string.
A conversion-based unit names a unit and gives its relationship to an SI unit as an explicit factor: a foot as 0.3048 metres. This is how imperial models declare themselves, and it means an imperial model needs no special handling — it declares a factor and the factor is used.
The complication is that the geometry kernel may already have applied the factor by the time you see coordinates, depending on the geometry settings in force. Applying it again is the error this page exists to prevent, and it is not detectable from the numbers alone: coordinates scaled twice look exactly like coordinates in a different unit.
Production-Ready Script
# ifcopenshell>=0.7.0, numpy>=1.24, Python 3.9+
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import ifcopenshell
SI_PREFIX = {
"EXA": 1e18, "PETA": 1e15, "TERA": 1e12, "GIGA": 1e9, "MEGA": 1e6, "KILO": 1e3,
"HECTO": 1e2, "DECA": 1e1, "DECI": 1e-1, "CENTI": 1e-2, "MILLI": 1e-3,
"MICRO": 1e-6, "NANO": 1e-9,
}
class UnitError(ValueError):
pass
@dataclass(frozen=True)
class LengthUnit:
name: str
metres_per_unit: float
source: str # "SI" | "conversion-based"
def read_length_unit(model) -> LengthUnit:
"""The declared length unit, resolved to a single multiplier."""
assignments = model.by_type("IfcUnitAssignment")
if not assignments:
raise UnitError("model declares no unit assignment — geometry is unscaled")
for unit in assignments[0].Units:
if getattr(unit, "UnitType", None) != "LENGTHUNIT":
continue
if unit.is_a("IfcSIUnit"):
factor = SI_PREFIX.get(unit.Prefix, 1.0) if unit.Prefix else 1.0
label = f"{(unit.Prefix or '').lower()}{unit.Name.lower()}"
return LengthUnit(label, factor, "SI")
if unit.is_a("IfcConversionBasedUnit"):
measure = unit.ConversionFactor # IfcMeasureWithUnit
value = float(measure.ValueComponent.wrappedValue)
base = measure.UnitComponent
base_factor = SI_PREFIX.get(getattr(base, "Prefix", None), 1.0) \
if getattr(base, "Prefix", None) else 1.0
return LengthUnit(unit.Name, value * base_factor, "conversion-based")
raise UnitError("unit assignment declares no LENGTHUNIT")
def to_metres(coords: np.ndarray, unit: LengthUnit, *, already_applied: bool) -> np.ndarray:
"""Scale exactly once. already_applied describes what the KERNEL did."""
if already_applied:
return np.asarray(coords, dtype=float)
return np.asarray(coords, dtype=float) * unit.metres_per_unit
def assert_plausible_extents(coords_m: np.ndarray, *, min_span=0.5, max_span=50_000.0):
span = float(np.ptp(np.asarray(coords_m)[:, :2], axis=0).max())
if not (min_span <= span <= max_span):
raise UnitError(
f"model span is {span:.4g} m — the length unit was applied twice or not at all"
)
return span
if __name__ == "__main__":
model = ifcopenshell.open("model.ifc")
unit = read_length_unit(model)
print(f"{unit.name}: {unit.metres_per_unit} m per unit ({unit.source})")
Key implementation notes:
already_appliedis an explicit argument rather than something the function tries to detect. Detection is not possible from the coordinates, so the caller has to state what its geometry settings do — and stating it is what makes the assumption reviewable.- Conversion-based units multiply the declared factor by any prefix on the base unit. A factor given against millimetres rather than metres is unusual and legal.
- The extent assertion is a separate function so it can be called at the boundary regardless of how the coordinates were produced. It catches both directions of the error with one range.
np.ptpon the horizontal ordinates gives the span without materialising a bounding box object.
Compatibility Matrix
| Component | Supported range | Notes |
|---|---|---|
ifcopenshell |
>=0.7.0 |
by_type, is_a |
| IFC schema | IFC2X3, IFC4, IFC4X3 | unit assignment unchanged across these |
| SI prefixes | full enumeration | table above covers the length-relevant range |
| Conversion units | any declared factor | imperial models need no special path |
numpy |
>=1.24 |
scaling and span |
Fallback Strategies
1. No unit assignment. Raises. A model without one has unscaled geometry and no way to interpret it; obtain a corrected export rather than assuming metres.
2. Coordinates a thousand times too small. The factor was applied twice — once by the kernel and once by you. Correct already_applied rather than dividing by a thousand somewhere downstream.
3. Several unit assignments. A federated file can carry more than one. Selecting the first is a guess; fail on ambiguity and resolve which project context applies.
4. Angle and area units differ from the length unit. They are declared separately and a pipeline that reads areas or angles needs to resolve those too. The same traversal serves, filtered on a different unit type.
5. Extents plausible but the model is wrong. The check is a floor, not a proof. It catches thousandfold errors, not a model authored in centimetres and declared in millimetres — that needs a known dimension, which is what the survey verification on the parent section provides.
FAQ
Does ifcopenshell apply the unit for me?
It depends on the geometry settings, which is exactly why this has to be checked rather than assumed. The kernel can return coordinates already in metres or in the model’s authored unit. Establish which your settings produce, once, with a test against a model of known size, and then rely on it — but do not rely on it without having checked.
How do I handle an imperial IFC model?
Through the conversion-based unit. A model authored in feet declares a unit whose conversion factor relates it to the SI metre, and reading that factor gives you the same single multiplier as a metric model. There is no separate imperial code path; there is one code path that reads whatever factor the file declares.
What extent range should the check use?
Whatever is plausible for the content, stated explicitly. A building is metres to hundreds of metres; an infrastructure alignment is kilometres. The check is not trying to validate the design, only to catch the thousandfold errors — so a wide range that rejects 0.01 m and 40 000 m buildings does the job.
Related Pages
- Unit Conversion Pipelines — parent reference on where each format records its unit
- ifcopenshell Workflow — the geometry settings that decide what the kernel returns
- Detecting Drawing Units When $INSUNITS Is Missing — the DXF equivalent, where no declaration exists at all