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.

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.

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.

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