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.

One pipeline crossing three sections: parsing, architecture standards, deployment A left-to-right flow of four stages. Stage 1 Parse DWG with ezdxf and stage 2 Detect walls and doors both belong to the floor-plan parsing and vectorization section. Stage 3 Project and build the routing graph draws its coordinate frame from the indoor coordinate reference system in the architecture-standards section. Stage 4 Validate and serialize emits a GeoJSON FeatureCollection matching the schema consumed by the production deployment section. A band beneath the stages marks which section owns each: stages one and two under Floor-Plan Parsing, stage three under Architecture Standards, stage four under Production Deployment. One orchestrated run across three sections 1 2 3 4 Parse DWGezdxf entities Detect walls& doors Project + buildrouting graph Validate +serialize GeoJSON LINE · LWPOLYLINE polygonize openings CRS + networkx topology audit Floor-Plan Parsing & Vectorization Architecture Standards Production Deployment Each stage hands a typed artifact to the next; the run fails loudly at a named stage rather than emitting a broken map.

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.

This reference sits within Async Batch Processing Pipelines, part of the wider Automated Floor Plan Parsing & Vectorization section.