DWG Floor Plan to Routable GeoJSON in One Python Pipeline
This is the end-to-end orchestration that ties the individual SVG/DWG parsing workflows together: a single Python entry point that takes a DWG floor plan and returns a validated, routable GeoJSON FeatureCollection.
What “One Pipeline” Means Across Section Boundaries
Every prior reference on this site handles one link in the chain — parsing, detection, coordinate systems, schema design. This page is the glue that runs them in sequence for one floor level, and it deliberately crosses section boundaries: it starts in floor-plan parsing, borrows the coordinate frame from architecture standards, and ends by emitting the exact envelope the deployment layer expects. The value of assembling it as one orchestrated function is that the intermediate artifacts never touch disk in an incompatible shape and the whole run either produces a topologically valid graph or fails loudly at a known stage.
Pipeline Configuration Reference
The whole run is parameterized by one config object, so a portfolio of buildings differs only by these values, not by code.
| Parameter | Type | Example | Notes |
|---|---|---|---|
dwg_path |
str |
"BLDG-04/level_02.dxf" |
DXF input; native DWG is converted at ingest before this stage |
layer_map |
dict[str, str] |
{"A-WALL": "wall", "A-DOOR": "door"} |
Maps CAD layer names to semantic classes |
level |
int |
2 |
Floor level index; negative for basements, encodes Z connectivity |
snap_tol |
float |
0.05 |
Metres; endpoints closer than this are welded when closing polygons |
crs |
str |
"local:BLDG-04" |
Target indoor coordinate frame the geometry is projected into |
unit_scale |
float |
0.001 |
Multiplier from drawing units to metres (mm drawings → 0.001) |
Step-by-Step Pipeline
Four ordered steps, each a focused typed function, chain into one run_pipeline call. Steps 1 and 2 lean on references that cover them in depth; this page keeps each block tight and shows the seams between them.
Step 1: Parse the DWG into typed geometry
Read the DXF, explode block references to world coordinates, and split entities into wall and door candidates by layer. The mechanics of ezdxf traversal are covered fully in parsing DWG files with Python and ezdxf; here it is one stage of the larger run.
import logging
import ezdxf
from shapely.affinity import scale as shp_scale
from shapely.geometry import LineString
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
def parse_dwg(dwg_path: str, layer_map: dict[str, str], unit_scale: float) -> dict[str, list[LineString]]:
"""Parse a DXF into class-bucketed, metre-scaled LineStrings, exploding blocks."""
try:
doc = ezdxf.readfile(dwg_path)
except (IOError, ezdxf.DXFStructureError) as exc:
logger.exception("parse failed for %s: %s", dwg_path, exc)
raise
msp = doc.modelspace()
inserts = [e for ins in msp.query("INSERT") for e in ins.virtual_entities()]
buckets: dict[str, list[LineString]] = {cls: [] for cls in set(layer_map.values())}
for e in list(msp) + inserts:
cls = layer_map.get(e.dxf.layer)
if cls is None:
continue
if e.dxftype() == "LINE":
geom = LineString([e.dxf.start[:2], e.dxf.end[:2]])
elif e.dxftype() == "LWPOLYLINE":
geom = LineString([(x, y) for x, y, *_ in e.get_points()])
else:
continue
buckets[cls].append(shp_scale(geom, xfact=unit_scale, yfact=unit_scale, origin=(0, 0)))
logger.info("parsed %s: %s", dwg_path, {k: len(v) for k, v in buckets.items()})
return buckets
Step 2: Detect rooms and doors, projected into the indoor CRS
Polygonize wall segments into room faces and reduce door segments to opening midpoints, all expressed in the building’s shared frame. The classification heuristics belong to the wall and door detection algorithms reference, and the frame is the one defined by the indoor coordinate reference system so this level lines up with its neighbours.
import logging
from shapely.geometry import LineString, Point, Polygon
from shapely.ops import polygonize, unary_union
logger = logging.getLogger(__name__)
def detect_spaces(
walls: list[LineString], doors: list[LineString], snap_tol: float
) -> tuple[list[Polygon], list[Point]]:
"""Polygonize walls into rooms and reduce doors to opening centroids."""
noded = unary_union(walls) # split walls at every intersection
rooms = [p for p in polygonize(noded) if p.area > snap_tol]
if not rooms:
logger.warning("no closed rooms formed; check wall connectivity / snap_tol")
door_points = [d.interpolate(0.5, normalized=True) for d in doors]
logger.info("detected %d rooms, %d doors", len(rooms), len(door_points))
return rooms, door_points
Step 3: Build the routing graph and validate topology
Turn rooms into nodes, connect any two rooms sharing a door into a weighted edge with networkx, and audit the result — invalid polygons are repaired, and a fragmented graph is a hard failure, not a warning.
import logging
import networkx as nx
from shapely.geometry import Point, Polygon
from shapely.validation import make_valid
logger = logging.getLogger(__name__)
def build_and_validate(
rooms: list[Polygon], doors: list[Point], snap_tol: float
) -> nx.Graph:
"""Build a room-adjacency routing graph and fail closed on a fragmented topology."""
graph = nx.Graph()
fixed = [make_valid(r) if not r.is_valid else r for r in rooms]
for i, room in enumerate(fixed):
graph.add_node(i, geometry=room, centroid=(room.centroid.x, room.centroid.y))
for door in doors:
touching = [i for i, r in enumerate(fixed) if r.distance(door) <= snap_tol]
for a, b in zip(touching, touching[1:]):
graph.add_edge(a, b, weight=door.distance(fixed[a].centroid))
components = nx.number_connected_components(graph) if graph.number_of_nodes() else 0
if components > 1:
logger.error("routing graph fragmented into %d components", components)
raise ValueError(f"topology check failed: {components} disconnected components")
logger.info("graph ok: %d nodes, %d edges", graph.number_of_nodes(), graph.number_of_edges())
return graph
Step 4: Serialize the validated GeoJSON FeatureCollection
Emit the site’s canonical envelope — one Feature per room with the indoor properties schema and a content hash over the geometry, ready for the schema contract the deployment layer enforces.
import hashlib
import json
import logging
import networkx as nx
logger = logging.getLogger(__name__)
def serialize(graph: nx.Graph, level: int) -> dict:
"""Serialize graph nodes into the canonical GeoJSON FeatureCollection envelope."""
features = []
for node_id, data in graph.nodes(data=True):
room = data["geometry"]
coords = [list(room.exterior.coords)]
topo = hashlib.sha256(room.wkb).hexdigest()[:8]
features.append({
"type": "Feature",
"geometry": {"type": "Polygon", "coordinates": coords},
"properties": {
"feature_id": f"L{level}-room-{node_id:04d}",
"space_class": "room",
"level": level,
"is_routable": graph.degree(node_id) > 0,
"accessibility_rating": "step_free",
"topology_hash": topo,
},
})
collection = {"type": "FeatureCollection", "features": features}
logger.info("serialized %d features for level %d", len(features), level)
return collection
def run_pipeline(cfg: dict) -> dict:
"""Orchestrate the four stages end to end for one floor level."""
buckets = parse_dwg(cfg["dwg_path"], cfg["layer_map"], cfg["unit_scale"])
rooms, doors = detect_spaces(buckets.get("wall", []), buckets.get("door", []), cfg["snap_tol"])
graph = build_and_validate(rooms, doors, cfg["snap_tol"])
return serialize(graph, cfg["level"])
Common Errors & Fixes
Rooms come back empty because blocks were never exploded. If step 1 iterates only the modelspace, wall geometry nested in INSERT blocks is invisible and polygonize has nothing to close. Always fold in virtual_entities() before bucketing, exactly as parse_dwg does — the symptom is zero rooms from a drawing that obviously has them.
polygonize yields no faces from unclosed walls. Wall runs that stop a few millimetres short of meeting never form a closed ring. Node the union first (unary_union) so segments split at crossings, and weld near-miss endpoints within snap_tol; if rooms are still open, the tolerance is smaller than the real gaps and should be raised deliberately, not silently.
Every coordinate is 1000x too large. The drawing was in millimetres and unit_scale was left at 1.0, so a 6-metre room serializes as 6000 units. Set unit_scale to 0.001 for millimetre drawings (or recover it from a known dimension) so the whole FeatureCollection lands in metres and the routing weights are meaningful.
Integration Point
This pipeline is the seam where three sections meet. Its front half is pure floor-plan work — the ezdxf parse from parsing DWG files with Python and ezdxf and the classification from wall and door detection algorithms. Its middle borrows the frame defined by the indoor coordinate reference system so the level aligns with the rest of its building. Its output is contracted by JSON schema design for indoor maps, and the whole run is exactly what CI gating for map updates executes on every candidate dataset before promotion — the topology_hash and connectivity check here are the gate’s first line of defence.
Frequently Asked Questions
Should this really be one function, or separate services?
Logically it is one pipeline; physically it can be either. Running it as a single orchestrated function per floor level keeps the intermediate artifacts typed and in memory, which is ideal for a batch worker processing one level at a time. At portfolio scale the same four stages are decomposed behind a queue so parsing, detection, and serialization scale independently, but the contract between stages — buckets of geometry, then rooms and doors, then a validated graph, then the envelope — stays identical whether it runs in one process or four.
Why validate topology inside the pipeline if CI also gates it?
Failing fast is cheaper than failing late. The in-pipeline connectivity check catches a fragmented graph at the point of production, with the source geometry still in hand for a precise error, rather than after an artifact has been published and the gate rejects it with less context. The gate is the authoritative guard before promotion; the in-pipeline check is a fast local sanity assertion that keeps obviously broken levels from ever reaching it.
What produces the topology_hash and why only eight characters?
It is a SHA-256 over each room’s binary geometry (WKB), truncated to eight hex characters for a compact, content-addressed change signal. Identical geometry always hashes the same, so a re-export that changed nothing produces the same hash and the delivery layer can skip it; any real geometry change produces a new hash. Eight characters is ample to distinguish revisions of a single feature while keeping the envelope readable — the full digest is available if a stronger collision guarantee is ever needed.
Related
- Parsing DWG Files with Python and ezdxf
- Wall & Door Detection Algorithms
- Indoor Coordinate Reference Systems
- JSON Schema Design for Indoor Maps
- CI Gating for Map Updates
This reference sits within Async Batch Processing Pipelines, part of the wider Automated Floor Plan Parsing & Vectorization section.