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.
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.
Related
- How to Define Indoor CRS for Multi-Building Campuses
- Parsing DWG Files with Python ezdxf
- Best Practices for Indoor POI Taxonomy
This page is part of the Level Mapping & Z-Axis Logic section of the Indoor Mapping Architecture & Standards reference.