Automating Wall and Door Detection in CAD

This page covers the specific technique of recovering navigable walls and doors directly from DXF/DWG geometry, and belongs to the Wall & Door Detection Algorithms collection — it focuses on the vector-native CAD path, where geometry already exists as entities rather than pixels.

What “wall and door detection in CAD” means

In a CAD drawing, walls and doors are not objects a routing graph can consume — they are drafting primitives optimized for human readability. Wall and door detection in CAD is the deterministic process of reconstructing two things from those primitives: a wall centerline network (the barriers and the corridors between them) and a set of door openings (the passable edges that cut through those barriers).

Three source representations dominate, and each fights you differently:

  • Double-line walls. A wall is drafted as two parallel LINE or LWPOLYLINE runs bounding a hollow or poché core. The router wants a single centerline, not two parallel edges.
  • Doors as intentional gaps. A doorway is frequently nothing but an empty span in the wall run — a topology break of 0.7–1.2 m — sometimes annotated with a swing-arc INSERT, sometimes bare.
  • Exploded and nested blocks. Door assemblies, wall styles, and furniture arrive as INSERT references to BLOCK definitions, or as flattened fragments scattered across non-obvious floor levels (A-WALL, WALLS, 0, DEFPOINTS).

Detection therefore has three deterministic stages: normalize entities into clean geometry, infer centerlines from parallel pairs, then classify the gaps. The DXF read step itself — proxy objects, binary-vs-ASCII detection, floor-level filtering — is covered in depth in Parsing DWG Files with Python ezdxf; this page assumes you already hold a loaded ezdxf document and concentrates on the geometry that follows.

Double-line wall and door gap reduced to a routable centerline Top: a wall is drafted as two parallel horizontal LINE runs about 0.30 metres apart, broken by a 0.9 metre opening; a quarter-circle swing-arc INSERT block marks the door at the left jamb. An averaging arrow points down. Bottom: the parallel pair collapses to one dashed centerline with the same opening, its two endpoints are routing-graph nodes, and the gap is classified as a passable door feature DOOR_001 with a 0.9 metre dimension. Double-line wall + door block → routable centerline CAD source: two parallel LINE runs with a swing-arc INSERT 0.30 m thickness swing-arc INSERT (door block) parallel-pair averaging Detected: one centerline, gap classified as a door opening centerline (passable edge) node node DOOR_001 gap 0.9 m · within door band

Minimal working example

The core technique is parallel-line pairing: flatten the wall floor level into Shapely lines, then collapse each parallel pair into a centerline by averaging endpoints. The snippet below is self-contained and runnable against any DXF whose walls live on a known floor level.

import logging
from typing import List
import ezdxf
from shapely.geometry import LineString, MultiLineString
from shapely.ops import unary_union

logger = logging.getLogger(__name__)

def walls_to_centerlines(dxf_path: str, layer: str, max_thickness: float = 0.5) -> MultiLineString:
    """Flatten a wall floor level and collapse parallel double-lines to centerlines."""
    try:
        msp = ezdxf.readfile(dxf_path).modelspace()
    except (IOError, ezdxf.DXFStructureError) as exc:        # missing file / corrupt entity table
        logger.error("Cannot read %s: %s", dxf_path, exc)
        return MultiLineString()

    lines: List[LineString] = [
        LineString([(e.dxf.start.x, e.dxf.start.y), (e.dxf.end.x, e.dxf.end.y)])
        for e in msp.query(f'LINE[layer=="{layer}"]')
    ]
    centers: List[LineString] = []
    used: set = set()
    for i, a in enumerate(lines):
        for j, b in enumerate(lines):
            if j <= i or i in used or j in used:
                continue
            if 0.0 < a.distance(b) < max_thickness:          # within one wall thickness == a pair
                centers.append(LineString([a.interpolate(0.5, normalized=True),
                                           b.interpolate(0.5, normalized=True)]))
                used.update((i, j))
    logger.info("Paired %d/%d wall lines into %d centerlines", len(used), len(lines), len(centers))
    return unary_union(centers) if centers else MultiLineString()

This is the readable, O(n²) form. The production normalizer below adds recursive block flattening, LWPOLYLINE/POLYLINE support, and KD-tree spatial indexing so the pairing scales past a few hundred segments.

import logging
import ezdxf
from shapely.geometry import LineString, MultiLineString
from shapely.ops import linemerge, unary_union
from shapely.validation import make_valid
from typing import List, Optional

logger = logging.getLogger(__name__)

def flatten_cad_entities(
    dxf_path: str,
    target_layers: Optional[List[str]] = None,
    tolerance: float = 1e-3,
) -> MultiLineString:
    """Recursively flatten DXF entities into a deduplicated MultiLineString.

    Handles nested INSERT blocks, LWPOLYLINEs, and standard LINEs.
    """
    try:
        doc = ezdxf.readfile(dxf_path)
    except (IOError, ezdxf.DXFStructureError) as exc:
        logger.error("Failed to load DXF %s: %s", dxf_path, exc)
        return MultiLineString()

    msp = doc.modelspace()
    geometries: List[LineString] = []
    target_set = set(target_layers) if target_layers else None

    def extract_lines(entity) -> None:
        dxftype = entity.dxftype()
        if target_set and entity.dxf.layer not in target_set:
            return
        if dxftype in ("LINE", "LWPOLYLINE", "POLYLINE"):
            try:
                if dxftype == "LINE":
                    p1, p2 = entity.dxf.start, entity.dxf.end
                    points = [(p1.x, p1.y), (p2.x, p2.y)]
                elif dxftype == "LWPOLYLINE":
                    # get_points() yields (x, y, start_width, end_width, bulge); keep xy.
                    points = [(p[0], p[1]) for p in entity.get_points()]
                else:  # legacy POLYLINE — iterate the .vertices sequence
                    points = [(v.dxf.location.x, v.dxf.location.y) for v in entity.vertices]
                if len(points) >= 2:
                    line = LineString(points)
                    if not line.is_empty and line.length > tolerance:
                        geometries.append(line)
            except (AttributeError, ValueError) as exc:
                logger.warning("Skipping malformed entity %s: %s", dxftype, exc)
        elif dxftype == "INSERT" and entity.dxf.name in doc.blocks:
            for sub_ent in doc.blocks[entity.dxf.name]:
                extract_lines(sub_ent)

    for entity in msp:
        extract_lines(entity)

    if not geometries:
        return MultiLineString()
    # Merge collinear runs and dissolve duplicate drafting artifacts.
    merged = linemerge(unary_union(geometries))
    return make_valid(merged) if isinstance(merged, (LineString, MultiLineString)) else MultiLineString()

CAD files routinely carry overlapping duplicate lines from copy-paste drafting history; the unary_unionlinemerge pass plus a snap at 1e-3 units prevents the pairing step from reading those duplicates as phantom walls.

Parameter / spec reference

These thresholds only make sense in real-world units, so calibrate them against the floor level’s scale before running detection — measuring gaps in pixels reclassifies the same doorway differently on every export.

Parameter Type Default Notes
target_layers Optional[List[str]] None Floor levels to keep (A-WALL, WALLS, S-WALL). None accepts every level — almost always wrong for production.
tolerance float 1e-3 Minimum segment length (metres) and snap tolerance; filters degenerate micro-segments.
max_thickness float 0.5 Upper bound on wall thickness (m). Two lines closer than this and parallel are paired into one centerline.
min_gap float 0.05 Lower bound that rejects coincident duplicate lines from being mistaken for a thin wall.
gap_threshold float 0.8 Maximum centerline break (m) still treated as a door rather than a corridor mouth. Typical door range: 0.7–1.2 m.
cos_tol float 0.05 Parallelism tolerance on the direction-vector dot product (~8°). Tighten for orthogonal plans, loosen for diagonal wings.

Door classification and GeoJSON output

A door is a break in the centerline routing graph that coincides with a door block insert. Build a graph from the centerline endpoints, find unconnected endpoint pairs within the door range, then match each candidate gap to the nearest INSERT to recover swing and clearance metadata.

import logging
from typing import List, Tuple
import networkx as nx
from shapely.geometry import Point, MultiLineString

logger = logging.getLogger(__name__)

def detect_and_classify_doors(
    centerlines: MultiLineString,
    door_blocks: List[Tuple[Point, float, str]],   # (insert_point, rotation_deg, floor_level)
    gap_threshold: float = 0.8,
) -> dict:
    """Return detected doors as a GeoJSON FeatureCollection of point openings."""
    graph = nx.Graph()
    geoms = centerlines.geoms if hasattr(centerlines, "geoms") else [centerlines]
    for line in geoms:
        start, end = Point(line.coords[0]), Point(line.coords[-1])
        graph.add_edge(start, end, length=line.length)

    features: List[dict] = []
    nodes = list(graph.nodes())
    for i in range(len(nodes)):
        for j in range(i + 1, len(nodes)):
            if graph.has_edge(nodes[i], nodes[j]):
                continue
            dist = nodes[i].distance(nodes[j])
            if not (0.5 < dist < gap_threshold):           # outside the door-width band
                continue
            mid = Point((nodes[i].x + nodes[j].x) / 2, (nodes[i].y + nodes[j].y) / 2)
            match = min(
                (b for b in door_blocks if mid.distance(b[0]) < 0.3),
                key=lambda b: mid.distance(b[0]),
                default=None,
            )
            if match is None:
                continue
            features.append({
                "type": "Feature",
                "geometry": {"type": "Point", "coordinates": [round(mid.x, 3), round(mid.y, 3)]},
                "properties": {
                    "id": f"DOOR_{len(features) + 1:03d}",
                    "gap_width": round(dist, 3),
                    "swing_angle": match[1],
                    "floor_level": match[2],
                    "is_passable": True,
                },
            })
    logger.info("Classified %d doors from %d candidate gaps", len(features), graph.number_of_nodes())
    return {"type": "FeatureCollection", "features": features}

The output stays inside the GeoJSON FeatureCollection envelope the rest of the pipeline already speaks, so a detected opening drops straight into the routing graph as a passable edge:

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": { "type": "Point", "coordinates": [12.84, 7.31] },
      "properties": {
        "id": "DOOR_001",
        "gap_width": 0.91,
        "swing_angle": 90.0,
        "floor_level": "L02",
        "is_passable": true
      }
    }
  ]
}

Common errors & fixes

UnicodeDecodeError on ezdxf.readfile(). The input is a binary DWG, not an ASCII DXF — ezdxf reads DXF text only. Convert first (ODA File Converter or libredwg), or route binary CAD through the SVG/DWG Parsing Workflows where server-side vectorization is handled before re-ingestion.

Phantom walls / doubled edges in the routing graph. Two near-coincident duplicate lines (distance < min_gap) get paired as a thin wall, or duplicate drafting strokes survive into the centerline set. The fix is to reject sub-min_gap pairs and dissolve duplicates before pairing:

from shapely.ops import unary_union, snap

cleaned = snap(unary_union(wall_lines), unary_union(wall_lines), tolerance=1e-3)

Furniture gaps promoted to doorways. A 0.8 m break between a desk and a wall reads as a door because it falls in the gap band. Constrain candidates to the real door range and require a matching block insert — a gap with no nearby INSERT is rejected. If your drawings lack door blocks entirely, fall back to swing-arc detection or annotate openings during attribute mapping instead of guessing from geometry alone.

Integration point

This page’s output is the passable-edge layer the rest of the system depends on. The centerlines and door FeatureCollection feed directly into the routing graph assembled across the Wall & Door Detection Algorithms collection, then get projected into a campus-wide indoor coordinate reference system so adjacent buildings share one frame. Because facility CAD exports run to tens of megabytes, wrap flatten_cad_entities in a process pool rather than calling it inline — the concurrency pattern is detailed in Building Async Pipelines for Batch Floor Plan Processing. Before any of this geometry reaches a live map, the connectivity audit (no orphaned nodes, every wing reachable) becomes a release gate so a mis-detected door never strands a corridor in production.

This page is part of the Wall & Door Detection Algorithms collection, within the Automated Floor Plan Parsing & Vectorization section.