Converting CAD Elevations to Indoor Z-Levels

This page covers the exact technique for collapsing the noisy Z-coordinates in a CAD deliverable into the discrete, integer-indexed floor levels a wayfinding engine can route over. It is the concrete, ezdxf-to-level-table recipe inside the broader Level Mapping & Z-Axis Logic stage — start there for the conceptual model, then use this page when you have a .dxf in hand and need a deterministic floor table out.

Concept Definition

Converting CAD elevations to indoor Z-levels is a three-operation transform applied to the population of Z-values extracted from a drawing: normalize (shift every elevation so the primary ground floor sits at Z=0), cluster (group elevations that belong to the same physical slab despite millimetre-to-decimetre survey noise), and label (assign every resulting group a stable canonical floor key such as B1, G, L2).

The conversion is necessary because CAD elevations are not floor levels. Surveyors and BIM modelers anchor Z to absolute geodetic datums (NAVD88, EGM96) or to arbitrary site benchmarks (Z=100.00 ft at slab top), and a single floor never arrives as one clean number — slab warping, stair-stringer offsets, MEP penetrations, and rounding spread it across a band of ±0.05 m to ±0.30 m. A naive equality test (z == 3.00) therefore shatters one floor into dozens of phantom levels. The transform’s job is to be deterministic where the source data is fuzzy: the same drawing must always yield the same level table, because that table becomes the floor key every downstream stage trusts.

How six real elevations in one drawing map onto level indices A table of six surfaces from one building's drawings and the level index each maps to. A basement slab at minus 3.20 metres maps to level minus one. The ground finished floor level at 0.00 is the datum and maps to level zero. A mezzanine at 2.10 metres maps to level 0.5, because it is a half level rather than a storey. Level one's finished floor at 4.20 maps to level one. The level one ceiling at 7.10 maps to nothing at all — it is a surface, not a floor. A plant deck at 29.60 metres maps to level eight, which is a storey index rather than a height. Elevation is continuous; a level is an ordinal label Surface in the drawing Elevation (m) level Why Basement slab -3.20 -1 below the ground FFL datum Ground FFL ● 0.00 ● 0 the datum itself Mezzanine ● 2.10 △ 0.5 half level — not a storey Level 1 FFL ● 4.20 ● 1 first full storey above ground Level 1 ceiling ● 7.10 △ — △ not a level: a surface, not a floor Plant deck ● 29.60 8 storey index, not building height Levels are ordinal. Nothing downstream should ever compute a level by dividing height.

Two rows are the whole problem. Ceilings look exactly like floors to a clustering algorithm, and a mezzanine's spacing is half a storey — so any rule that divides elevation by a nominal floor-to-floor height gets both wrong.

From noisy CAD elevations to a discrete, labelled floor table Top: a five-stage left-to-right pipeline. DXF entities (3DFACE, POLYLINE, INSERT, SOLID) pass an entity stream into Z-extraction, which emits a raw float list of elevations; datum normalization subtracts the 10th-percentile baseline and harmonizes feet to metres, emitting a metre-shifted array; a one-dimensional DBSCAN with eps set to the vertical tolerance groups same-slab points into cluster labels; level labelling orders those centroids and assigns canonical floor keys. Bottom: a Z-axis chart. On the left, the normalized Z population is shown as scattered, noisy dots within four faint tolerance bands. A central DBSCAN-and-median arrow collapses each band, dropping the minus-one noise class, into the clean horizontal floor lines on the right, labelled L2 at +7.80 metres, L1 at +3.90 metres, G at 0.00 metres and B1 at minus 3.20 metres. CAD elevations → discrete floor table: the conversion pipeline entity stream raw floats metre array labels DXF entities 3DFACE · POLYLINE INSERT · SOLID Z-bearing primitives Z extraction read elevation attr raw Z population drop 2D / TEXT datum normalize − 10th-pct baseline feet → metres ground → Z = 0 DBSCAN cluster eps = tolerance_m 1-D Z axis noise → class −1 level labelling order centroids B-n · G · L-n round to mm canonical level IDs Normalized Z population → discrete floor levels raw normalized Z (noisy) clustered floor levels +7.8 +3.9 0.0 −3.2 Z (m) −1 dropped DBSCAN + median L2 · +7.80 m L1 · +3.90 m G · 0.00 m B1 · −3.20 m

Minimal Working Example

The snippet below is the whole technique in under 40 lines: read a DXF with ezdxf, pull Z-values from 3D-bearing entities, normalize against the densest low cluster, run a one-dimensional DBSCAN, and emit ordered level IDs. The vertical tolerance (eps) is the only parameter you usually need to tune.

import logging
import numpy as np
import ezdxf
from sklearn.cluster import DBSCAN

logger = logging.getLogger("z_levels")

def cad_to_z_levels(dxf_path: str, tolerance_m: float = 0.15) -> dict[str, float]:
    """Map a DXF's elevations to canonical floor IDs (e.g. {'G': 0.0, 'L1': 3.9})."""
    try:
        doc = ezdxf.readfile(dxf_path)
    except (IOError, ezdxf.DXFStructureError) as exc:      # missing or corrupt file
        logger.error("Cannot read DXF %s: %s", dxf_path, exc)
        raise

    z_vals = [float(e.dxf.elevation) for e in doc.modelspace()
              if e.dxftype() in {"3DFACE", "POLYLINE", "INSERT", "SOLID"}
              and e.dxf.hasattr("elevation")]
    if not z_vals:
        raise ValueError("No Z-bearing entities found; check entity-type filter.")

    z = np.asarray(z_vals) - np.percentile(z_vals, 10)     # anchor ground floor to 0
    labels = DBSCAN(eps=tolerance_m, min_samples=5).fit(z.reshape(-1, 1)).labels_

    centroids = {c: float(np.median(z[labels == c])) for c in set(labels) if c != -1}
    ordered = sorted(centroids, key=centroids.get)
    ground = min(ordered, key=lambda c: abs(centroids[c]))
    g = ordered.index(ground)
    keys = {c: "G" if i == g else f"B{g - i}" if i < g else f"L{i - g}"
            for i, c in enumerate(ordered)}
    logger.info("Resolved %d floor levels from %d samples", len(keys), len(z_vals))
    return {keys[c]: round(centroids[c], 3) for c in ordered}

The output is a small, stable dictionary that downstream stages join against, and it serializes cleanly into the GeoJSON envelope the rest of the site uses — each level becomes a Feature whose z_index and level_id travel with the floor geometry:

{
  "type": "FeatureCollection",
  "features": [
    { "type": "Feature", "geometry": null,
      "properties": { "level_id": "G",  "z_index": 0, "elevation_m": 0.0 } },
    { "type": "Feature", "geometry": null,
      "properties": { "level_id": "L1", "z_index": 1, "elevation_m": 3.9 } },
    { "type": "Feature", "geometry": null,
      "properties": { "level_id": "B1", "z_index": -1, "elevation_m": -3.2 } }
  ]
}

Parameter & Spec Reference

Parameter Type Default Notes
tolerance_m (DBSCAN eps) float 0.15 Vertical merge band in metres. 0.15 suits commercial slabs; tighten to 0.08 for precise as-builts, widen to 0.25 for warped legacy scans.
min_samples int 5 Minimum Z-points to form a floor cluster. Raise on dense drawings (50+) to suppress fixtures; lower for sparse single-storey plans.
baseline percentile int 10 Percentile used as the ground datum. The 10th percentile resists basement-sump outliers better than min().
entity-type filter set[str] {3DFACE, POLYLINE, INSERT, SOLID} Z-bearing primitives. Exclude TEXT/MTEXT annotations and 2D LINE layers (implicitly Z=0) to keep noise out of the distribution.
input units str m DWG/DXF may be imperial. Harmonize feet/inches to metres before clustering so eps stays meaningful.
floor-to-floor gap float ≥ 2.0 m Validation threshold: consecutive level centroids closer than ~1.5 m signal an unresolved split-level or over-tight eps.
Entity counts at each distinct elevation in one building's drawing set A histogram of how many entities sit at each distinct Z value across one building's drawings. Tall spikes appear at minus 3.20, 0.00, 4.20, 8.40 and 12.60 metres, which are the finished floor levels. A medium spike at 2.10 metres is the mezzanine. Smaller spikes at 7.10 and 11.30 metres are ceilings, and tiny counts at minus 0.05 and 0.02 are float noise around the ground datum that must be snapped rather than treated as separate levels. Floors, ceilings, a mezzanine and float noise, all in one axis 0 100 200 300 0 4 8 12 elevation (m, relative to ground FFL) entities at this Z

Cluster on the spikes, not on a spacing rule. The floor spikes are 4.2 m apart, the mezzanine is not, and the ceiling spikes are large enough to be mistaken for storeys — which is why the clustering is followed by a semantic check on what kind of entity sits at each Z.

Cluster label -1 is DBSCAN’s noise class — rooftop plant, suspended fixtures, and stray survey points land there and are intentionally dropped before labelling. Before extraction, confirm the drawing’s vertical reference frame: if it carries a project benchmark (BM-1 = 100.00 ft), every Z must be shifted relative to that baseline, the same datum discipline enforced across a campus by indoor coordinate reference systems.

Common Errors & Fixes

ValueError: No Z-bearing entities found — the drawing stores elevations on entity types outside the filter, or all geometry is genuinely 2D with elevation held on the layer or block insert rather than the vertex. Inspect the DXF with ezdxf first ({e.dxftype() for e in msp}) and widen the filter, or read INSERT.dxf.insert.z for block-referenced floors. The mechanics of walking each entity type are covered in parsing DWG files with Python ezdxf.

Every floor collapses into one cluster (or one floor explodes into many). A single returned level on a multi-storey building means eps swallowed the floor-to-floor gap — your units are almost certainly feet read as metres (a 3.9 m storey becomes 12.8, and eps=0.15 now spans nothing). Convert units first. The inverse — dozens of levels — means eps is below the slab’s own noise band; raise tolerance_m toward 0.20 and re-check that consecutive centroids differ by at least 2.0 m.

A POI lands on the wrong floor (L1 kiosk resolves to L2). The Z-sample sat in the overlap of two clusters, typically at a mezzanine edge or double-height void. Snap ambiguous POIs to the nearest vertical-circulation node rather than the nearest centroid, and flag any Z-cluster that spans non-contiguous footprints for a sub-level designation (L1-A, L1-B) instead of silently merging it.

Integration Point

This conversion is the first vertical-topology step in the Level Mapping & Z-Axis Logic stage of the Indoor Mapping Architecture & Standards reference. Its DXF input is produced by the parsing stage, so pair it with parsing DWG files with Python ezdxf upstream. Its output — the level_id/z_index table — becomes the floor key that POI Taxonomy & Classification joins assets against, so a misassigned level here silently corrupts every downstream point. When clustering yields an ambiguous floor that cannot be resolved, degrade to planar navigation through fallback routing architectures rather than emitting a broken vertical edge.

Frequently Asked Questions

How should mezzanines be indexed?

As fractional levels — 0.5, 1.5 — rather than as integers, and the choice is about ordering rather than aesthetics. A mezzanine sits physically between two storeys, and every consumer of the level index sorts by it: the floor picker in the client, the vertical-transition logic in the router, the level filter on the tiles. Giving a mezzanine the next integer pushes every floor above it up by one and silently renumbers the whole building, so the fourth floor in the data is the third floor on the lift buttons. A fractional index keeps the ordering correct and keeps integers meaning what the building’s own signage means. The one thing to check is that the field is typed as a float end to end; a schema that declares level an integer will truncate 0.5 to 0 and merge the mezzanine into the ground floor.

Why not just divide elevation by the floor-to-floor height?

Because floor-to-floor spacing is not constant, and the failure is silent. Ground floors are routinely taller than the storeys above them, plant levels and double-height atria break the rhythm entirely, and a mezzanine sits at half spacing by definition. A division rule gets each of those wrong and produces a plausible integer for every one, so nothing downstream reports an error — the building simply has a floor that does not exist and a stairwell that connects the wrong pair of levels. Clustering the actual Z values found in the drawings and then checking what kind of entity sits at each cluster is more work and is the only approach that degrades honestly, because a cluster it cannot classify becomes a question rather than a wrong answer.

How do I stop ceilings being detected as floors?

By classifying clusters on entity semantics rather than on elevation alone. A ceiling plane and a floor slab are indistinguishable as Z values — both are large horizontal surfaces with many entities at one elevation — so the discriminator has to be which drawing layer and entity type they came from. In practice that means keeping the layer name alongside each entity through the parsing stage rather than discarding it once geometry is extracted, and treating any cluster dominated by ceiling-layer entities as not-a-level. Where the layer naming is too inconsistent to rely on, the fallback heuristic is that ceilings sit 2.4 to 3.2 metres above the nearest floor cluster and carry no door or stair entities — but layer naming, negotiated once with the draughting team, is far more reliable than any geometric rule.

This page is part of the Level Mapping & Z-Axis Logic section of the Indoor Mapping Architecture & Standards reference.