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
LINEorLWPOLYLINEruns 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
INSERTreferences toBLOCKdefinitions, 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.
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_union → linemerge 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.
Related
- Parsing DWG Files with Python ezdxf
- Extracting Room Boundaries from SVG Floor Plans
- Building Async Pipelines for Batch Floor Plan Processing
This page is part of the Wall & Door Detection Algorithms collection, within the Automated Floor Plan Parsing & Vectorization section.