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.

The two forms a length unit takes Both forms resolve to a single multiplier, which is why an imperial model needs no separate code path. An SI unit names a base unit and an enumerated prefix; a conversion-based unit names a unit and gives its relationship to an SI unit as an explicit factor. One traversal reads whichever the file declares. IfcSIUnit — base unit plus a prefix — prefix is a name, not a number — MILLI + METRE → 0.001 — the metric case IfcConversionBasedUnit — a named unit — explicit factor to an SI unit — FOOT → 0.3048 — the imperial case One code path — read the factor the file declares.

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})")
Who applied the factor decides whether you should A branch that cannot be resolved from the coordinates, because coordinates scaled twice look exactly like coordinates in a different unit. The geometry settings decide whether the kernel already applied the declared unit. Establish which your settings produce once, with a model of known size, and then state it explicitly at every call site. Did the kernel already apply the unit? Do not scale already metres yes Scale once by the declared factor no

Key implementation notes:

  • already_applied is 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.ptp on 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.

What the extent assertion actually rejects The horizontal span of one building under three interpretations. Applied twice, the model is a hundredth of a metre across; not applied at all, it is hundreds of kilometres; applied once, it is a building. A single plausibility range rejects both failure directions without needing to know anything about the design. factor applied twice 0.03 m factor applied once 30 m factor not applied 30,000 m 0 30,000 One range catches both directions — no knowledge of the design required.

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.