Parsing DWG Files with Python ezdxf

This page covers the specific technique of reading CAD floor plans with the ezdxf library and turning their raw entities into clean, unit-aligned geometry, and it sits within the SVG/DWG Parsing Workflows collection of the floor-plan parsing reference. It is the native-CAD branch that has to converge on the same polygon contract as the SVG room-boundary extractor before any routing work begins.

What “parsing with ezdxf” actually means

ezdxf is a pure-Python reader/writer for the DXF interchange format — the ASCII (or binary) text dialect that CAD tools emit alongside their proprietary .dwg. Parsing here means loading a drawing into an in-memory document tree, querying its modelspace for the entities that carry navigable meaning (walls, doors, room labels), and decomposing each entity into plain coordinate primitives. It is not the same as opening the file in AutoCAD: there is no rendering engine, no COM automation, and no implicit unit handling. You get the exact geometry the drafter stored, quirks included.

That distinction matters because ezdxf.readfile() accepts DXF, not the binary .dwg container. A true .dwg must first pass through the ODA File Converter (or libredwg) to become DXF. Everything below assumes that conversion has already happened and you hold a .dxf on disk. The output you are steering toward is the GeoJSON FeatureCollection envelope the rest of the pipeline consumes, so absolute, metric, right-side-up coordinates are the non-negotiable contract — the same one earned for floor-level (Z-axis) logic and for projection into an indoor coordinate reference system downstream.

Minimal working example

The snippet below is the whole core technique in one self-contained block: open a DXF defensively, pull every line-like entity off the wall floor-levels, and emit a flat list of (start, end) segment tuples ready for Shapely. It handles the two failures you will actually hit first — a structurally broken file and a binary .dwg mistakenly passed to a text reader.

import logging
from typing import List, Tuple
import ezdxf
from ezdxf import DXFStructureError

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("dwg_parse")

Segment = Tuple[Tuple[float, float], Tuple[float, float]]

def extract_wall_segments(path: str) -> List[Segment]:
    """Load a DXF and return wall LINE/LWPOLYLINE segments as coordinate pairs."""
    try:
        doc = ezdxf.readfile(path)
    except DXFStructureError as exc:
        logger.error("Corrupt DXF structure in %s: %s", path, exc)
        raise
    except UnicodeDecodeError as exc:
        raise RuntimeError("Binary DWG detected — convert to DXF first") from exc

    msp = doc.modelspace()
    segments: List[Segment] = []
    for e in msp.query('LINE LWPOLYLINE[layer=="A-WALL"]'):
        if e.dxftype() == "LINE":
            segments.append(((e.dxf.start.x, e.dxf.start.y), (e.dxf.end.x, e.dxf.end.y)))
        else:  # LWPOLYLINE: get_points() yields (x, y, start_w, end_w, bulge)
            pts = [(p[0], p[1]) for p in e.get_points()]
            segments += list(zip(pts, pts[1:]))
    logger.info("Extracted %d wall segments from %s", len(segments), path)
    return segments

The msp.query() string is ezdxf’s entity DSL: a space-separated list of DXF types, each optionally followed by an attribute predicate in brackets. From here the segments flow into Shapely for unioning and into the wall-and-door logic that classifies passable edges.

Parameter and entity reference

The fields you touch most when extracting navigable geometry, with the types ezdxf exposes them as:

Field / argument Type Default Notes
ezdxf.readfile(filename) str / path Reads DXF text only. Binary .dwg raises UnicodeDecodeError; convert via ODA/libredwg first.
doc.modelspace() Modelspace The drawing’s primary entity space. Paperspace layouts are separate and usually annotation-only.
entity.dxf.layer str "0" The floor-level/category name. Naming is unstandardised (A-WALL, WALLS, S-WALL); match case-insensitively.
entity.dxftype() str LINE, LWPOLYLINE, POLYLINE, INSERT, TEXT, MTEXT, ARC, SPLINE.
LWPOLYLINE.get_points() list[tuple] Yields (x, y, start_width, end_width, bulge); a non-zero bulge means the segment is an arc.
INSERT.attribs iterable Block attribute tags (door IDs, room numbers); read attr.dxf.tag / attr.dxf.text.
doc.header["$INSUNITS"] int 0 Unit code — 4=mm, 6=m, 1=inch. 0 means unitless; you must assume a scale.
query('TYPE[attr=="x"]') str Entity DSL. Combine types with spaces; predicates support ==, !=, <, >, and ? regex.

A bulge value on an LWPOLYLINE vertex is the single most overlooked field: ignoring it silently turns curved wall runs and door swings into straight chords. Use ezdxf.math.bulge_to_arc() to expand each bulged segment into sampled points before building geometry.

Common errors and fixes

1. UnicodeDecodeError on readfile(). Symptom: the loader throws before any entity is read. Root cause is a real binary .dwg (or a binary-DXF) handed to the text reader. Detect the format up front and route binaries to a converter rather than crashing the batch:

from ezdxf.recover import readfile as recover_readfile

def load_resilient(path: str):
    """Fall back to ezdxf.recover for files with minor structural damage."""
    try:
        return ezdxf.readfile(path)
    except (UnicodeDecodeError, DXFStructureError) as exc:
        logger.warning("Standard read failed (%s); attempting recover mode", exc)
        doc, auditor = recover_readfile(path)   # repairs many malformed exports
        if auditor.has_errors:
            logger.error("Unrecoverable: %d errors", len(auditor.errors))
        return doc

2. Proxy objects yield no geometry. Symptom: AutoCAD Architecture/MEP exports show ACAD_PROXY_ENTITY types whose walls and ducts never appear in your segment list. ezdxf cannot decompose proxies into primitives. Detect them, log a topology-affecting gap, and degrade to a bounding reference so the floor level is not silently incomplete:

def flag_proxies(entities) -> list:
    """Log proxy entities ezdxf cannot decompose so they aren't lost silently."""
    proxies = [e for e in entities if e.dxftype() in ("ACAD_PROXY_ENTITY", "PROXY_ENTITY")]
    for p in proxies:
        logger.warning("Proxy %s on %s — route to ODA/libredwg for full geometry",
                       p.dxf.handle, p.dxf.layer)
    return proxies

3. Geometry is mirrored or 1000x off scale. Symptom: rooms render flipped, or door widths read as “900 metres”. Root cause is an unhandled $INSUNITS mismatch — millimetre drawings treated as metres — or a Y-axis assumption that does not hold. Read the unit code explicitly and scale to metres before emitting anything:

_TO_METRES = {1: 0.0254, 4: 0.001, 5: 0.01, 6: 1.0}  # in, mm, cm, m

def metre_scale(doc) -> float:
    code = doc.header.get("$INSUNITS", 0)
    if code == 0:
        logger.warning("Unitless drawing; assuming millimetres")
        return 0.001
    return _TO_METRES.get(code, 1.0)

How this feeds the pipeline

The segments and attributes produced here are the CAD-sourced substrate the rest of the floor-plan pipeline binds to. Once scaled to metres they are unioned and snapped into closed rings, classified into passable edges by the wall and door detection algorithms, enriched with the labels and block attributes covered in attribute mapping from blueprints, and then emitted inside the same GeoJSON FeatureCollection envelope defined by the JSON schema for indoor map APIs. When a portfolio of buildings has to be processed at once, this loader is the unit of work driven by the async batch processing pipelines, and the SVG branch converges on the identical polygon contract so CAD-sourced and web-sourced floors are indistinguishable to the routing graph.

From binary .dwg to a metric GeoJSON FeatureCollection with ezdxf A snake-shaped pipeline. Top row, left to right: a binary .dwg input chip flows into an ODA / libredwg convert step that emits text DXF; that flows into ezdxf.readfile with a recover() fallback; that flows into a modelspace query that selects LINE, LWPOLYLINE and INSERT entities on wall, door and label floor levels. A connector turns down the right edge into the bottom row, which flows right to left: a proxy-flag and unit-scale step that detects ACAD_PROXY_ENTITY objects and scales by the $INSUNITS code to metres; then a metric segment list of (start, end) coordinate tuples; then the output GeoJSON FeatureCollection in absolute, Y-up metres. Binary .dwg to metric GeoJSON: the ezdxf parsing path text DXF entity DSL binary .dwg proprietary container not readable directly convert ODA / libredwg .dwg → .dxf ezdxf.readfile recover() fallback doc.modelspace() modelspace query LINE · LWPOLYLINE walls · doors · labels flag + scale proxy → log gap $INSUNITS → metres metric segments (start, end) tuples bulge → sampled arc GeoJSON FeatureCollection absolute · Y-up · metric

This page belongs to the SVG/DWG Parsing Workflows collection, part of the Automated Floor Plan Parsing & Vectorization reference.