SVG/DWG Parsing Workflows for Indoor Mapping Pipelines
Vector-native floor plans arrive in two incompatible dialects — native CAD .dwg/.dxf and web-exported .svg — and an indoor pipeline has to fold both into one coordinate-aligned, semantically tagged geometry schema before any topology or routing work begins. This collection is the vector-ingestion entry point of the wider Automated Floor Plan Parsing & Vectorization reference: it takes raw drawing primitives and emits the same GeoJSON FeatureCollection envelope the rest of the pipeline consumes.
The Problem: Two Formats, One Frame
A facilities team will hand you a DWG exported straight from AutoCAD for one building and an SVG dumped from a BIM viewer for the next, and the two describe the same kind of space in completely different ways. DWG retains drawing units, floor-level hierarchies, block references, and extended entity data, but carries no web coordinate convention. SVG is render-ready but routinely ships with flattened transforms, an arbitrary viewBox scale, a Y-axis that points down, and semantic structure collapsed into anonymous <g> groups during export.
The symptom of getting this wrong is concrete and ugly: rooms render mirrored, walls drift by hundreds of pixels, door widths read as “1200 metres”, and the downstream routing graph fragments because polygons that look closed never actually closed. A parser that survives a real portfolio has to run format-specific extraction, resolve coordinate space deterministically, and converge both branches on a single intermediate representation — typically a GeoJSON FeatureCollection whose properties already carry indoor semantics. Everything after this stage assumes absolute, unit-aligned, metric coordinates; this is where that guarantee is earned.
Prerequisites & Dependencies
Before implementing the workflow, pin the libraries and agree the coordinate assumptions the rest of the pipeline depends on:
ezdxf— reads DWG/DXF, exposes the entity tree, block table, and header variables ($INSUNITS,$INSBASE). Native DWG is read via its DWG add-on or by converting to DXF first.lxml— streaming, namespace-aware XML/SVG DOM traversal. Parse withresolve_entities=Falseto neutralise XXE in untrusted SVG.shapely— geometric validation, repair (make_valid,buffer(0)), and snapping.numpy— affine matrix math for SVG transform flattening.- Coordinate contract: output is a local Cartesian frame, origin bottom-left, Y-up, units in metres. DWG drawing units are read from
$INSUNITS; SVG pixels are scaled by a calibrated pixels-per-metre factor and Y-inverted against theviewBoxheight. Geometry that survives this stage is later projected into a campus-wide indoor coordinate reference system so adjacent buildings share one frame.
The two format branches diverge in mechanics but converge on one output type. DWG entity traversal and block resolution are covered in depth in parsing DWG files with Python ezdxf; SVG path reconstruction is covered in extracting room boundaries from SVG floor plans.
Architecture: Two Branches, One Schema
Conceptually the workflow is a fork-join. A format dispatcher routes each file to a dedicated decoder; each decoder normalises units and coordinate space in its own terms; both emit a common UnifiedGeometry record; a single converter validates and serialises that into the shared GeoJSON envelope. Keeping the join point narrow — one dataclass — means the topology validator, the routing-graph builder, and the client SDK never need to know whether a feature originated in CAD or in markup.
The unified record is deliberately tiny — geometry type, coordinates, and a properties dict aligned to the site-wide indoor schema (space_class, level, is_routable, source_format, topology_hash). The DWG branch fills level and space_class from floor-level layer names; the SVG branch fills them from class attributes. From that point on, both feed the same wall and door detection algorithms and the same attribute mapping from blueprints stage without a format branch in sight.
Step-by-Step Implementation
1. Normalise DWG drawing units to metres
DWG stores coordinates in drawing units whose real-world meaning lives in the $INSUNITS header variable. Resolve it once per file and carry a single scale factor through every entity, rather than guessing per-geometry.
import logging
from typing import Dict
logger = logging.getLogger("parse.dwg.units")
# DXF $INSUNITS codes -> metres-per-unit (AutoCAD standard subset).
_UNIT_SCALE: Dict[int, float] = {
0: 1.0, # Unitless — assume 1:1
1: 0.0254, # Inches
2: 0.3048, # Feet
4: 0.001, # Millimetres
5: 0.01, # Centimetres
6: 1.0, # Metres
7: 1000.0, # Kilometres
}
def dwg_unit_to_metres(dxf_units: int) -> float:
"""Map a DXF $INSUNITS code to a metres-per-unit scale factor."""
scale = _UNIT_SCALE.get(dxf_units)
if scale is None:
logger.warning("Unknown $INSUNITS code %s; defaulting to 1:1", dxf_units)
return 1.0
return scale
2. Extract DWG entities into unified geometry
Walk model space, scale every coordinate, and convert closed polylines into polygons and lines into edges. Resolve the obvious read failures explicitly — a missing file and a corrupt drawing are the two errors you will actually hit in production.
import logging
from dataclasses import dataclass
from typing import Any, Dict, List
import ezdxf
from ezdxf.lldxf.const import DXFStructureError
from shapely.geometry import Polygon
logger = logging.getLogger("parse.dwg")
@dataclass
class UnifiedGeometry:
geom_type: str # "Polygon" | "LineString"
coordinates: List[Any]
properties: Dict[str, Any]
source_file: str
def parse_dwg_entities(filepath: str) -> List[UnifiedGeometry]:
"""Read a DWG/DXF file and return metric, Y-up unified geometry records."""
try:
doc = ezdxf.readfile(filepath)
except IOError as exc: # file missing / unreadable
logger.exception("Cannot open DWG %s: %s", filepath, exc)
raise
except DXFStructureError as exc: # corrupt or invalid DXF/DWG
logger.exception("Malformed DWG structure in %s: %s", filepath, exc)
raise
scale = dwg_unit_to_metres(doc.header.get("$INSUNITS", 0))
geometries: List[UnifiedGeometry] = []
for entity in doc.modelspace():
etype = entity.dxftype()
if etype in ("LWPOLYLINE", "POLYLINE"):
if etype == "LWPOLYLINE":
raw = [(p[0], p[1]) for p in entity.get_points()]
else:
raw = [(v.dxf.location.x, v.dxf.location.y) for v in entity.vertices]
pts = [(x * scale, y * scale) for x, y in raw]
if entity.is_closed and len(pts) >= 3:
poly = Polygon(pts)
if poly.is_valid and poly.area > 0.1: # drop sub-0.1 m² slivers
geometries.append(UnifiedGeometry(
"Polygon", list(poly.exterior.coords),
{"floor_layer": entity.dxf.layer, "space_class": "room",
"source_format": "dwg"}, filepath))
elif etype == "LINE":
p1 = (entity.dxf.start.x * scale, entity.dxf.start.y * scale)
p2 = (entity.dxf.end.x * scale, entity.dxf.end.y * scale)
geometries.append(UnifiedGeometry(
"LineString", [p1, p2],
{"floor_layer": entity.dxf.layer, "space_class": "wall",
"source_format": "dwg"}, filepath))
logger.info("Extracted %d entities from %s (scale=%.4f m/unit)",
len(geometries), filepath, scale)
return geometries
3. Flatten SVG transforms and invert the Y-axis
SVG geometry inherits cumulative transform="matrix(...)" operations from every ancestor <g>, and its origin sits top-left with Y pointing down. Resolve the matrix chain (parent-then-child), then flip Y against the viewBox height and scale pixels to metres in one pass.
import logging
from typing import List, Tuple
import numpy as np
from lxml import etree
logger = logging.getLogger("parse.svg")
def apply_affine(points: List[Tuple[float, float]], matrix: np.ndarray) -> np.ndarray:
"""Apply a 3x3 homogeneous affine matrix to a list of (x, y) points."""
homo = np.array([(x, y, 1.0) for x, y in points])
return (matrix @ homo.T).T[:, :2]
def parse_svg_paths(filepath: str, px_per_metre: float = 1000.0) -> List["UnifiedGeometry"]:
"""Parse SVG <path> M/L/Z geometry into metric, Y-up unified records."""
try:
# resolve_entities=False neutralises XXE in untrusted SVG exports.
tree = etree.parse(filepath, etree.XMLParser(resolve_entities=False))
except (OSError, etree.XMLSyntaxError) as exc: # missing file / malformed XML
logger.exception("Cannot parse SVG %s: %s", filepath, exc)
raise
root = tree.getroot()
_, _, _, vb_h = map(float, root.get("viewBox", "0 0 1000 1000").split())
ns = "{http://www.w3.org/2000/svg}"
geometries: List[UnifiedGeometry] = []
for path in root.iter(f"{ns}path"):
d = path.get("d", "")
nums = [float(t) for t in d.replace(",", " ").split()
if _is_float(t)]
if len(nums) < 6:
continue
raw = [(nums[i], nums[i + 1]) for i in range(0, len(nums) - 1, 2)]
# Flip Y against the viewBox, then scale pixels -> metres.
metric = [((x) / px_per_metre, (vb_h - y) / px_per_metre) for x, y in raw]
if len(metric) >= 3:
geometries.append(UnifiedGeometry(
"Polygon", metric,
{"floor_layer": path.get("class", "default"),
"space_class": "room", "source_format": "svg"}, filepath))
logger.info("Extracted %d paths from %s (px/m=%.1f)",
len(geometries), filepath, px_per_metre)
return geometries
def _is_float(token: str) -> bool:
try:
float(token)
return True
except ValueError:
return False
The simplified M/L/Z reader above is enough for orthogonal room outlines; Bézier and arc linearisation against the full spec is handled in extracting room boundaries from SVG floor plans.
4. Converge both branches on one validated FeatureCollection
Both decoders now emit UnifiedGeometry. The converter repairs invalid rings with make_valid, attaches a content hash so the delivery layer can tell a real change from a no-op re-export, and serialises the shared envelope.
import hashlib
import json
import logging
from pathlib import Path
from typing import Any, Dict, List
from shapely.errors import GEOSException
from shapely.geometry import mapping, shape
from shapely.validation import make_valid
logger = logging.getLogger("parse.unify")
def build_feature_collection(geoms: List["UnifiedGeometry"]) -> Dict[str, Any]:
"""Validate, hash, and serialise unified geometry into a GeoJSON FeatureCollection."""
features: List[Dict[str, Any]] = []
for g in geoms:
raw = ({"type": "Polygon", "coordinates": [g.coordinates]}
if g.geom_type == "Polygon"
else {"type": "LineString", "coordinates": g.coordinates})
try:
repaired = make_valid(shape(raw))
except GEOSException as exc: # degenerate / non-noded geometry
logger.warning("Dropping unrepairable geometry from %s: %s",
g.source_file, exc)
continue
geom_json = mapping(repaired)
props = dict(g.properties)
props["topology_hash"] = hashlib.sha1(
json.dumps(geom_json["coordinates"]).encode()).hexdigest()[:8]
props.setdefault("is_routable", True)
features.append({"type": "Feature", "geometry": geom_json, "properties": props})
logger.info("Built FeatureCollection with %d validated features", len(features))
return {"type": "FeatureCollection", "features": features}
def parse_floor_plan(filepath: str) -> Dict[str, Any]:
"""Dispatch a single file to the correct decoder and return GeoJSON."""
suffix = Path(filepath).suffix.lower()
if suffix in (".dwg", ".dxf"):
geoms = parse_dwg_entities(filepath)
elif suffix == ".svg":
geoms = parse_svg_paths(filepath)
else:
raise ValueError(f"Unsupported floor-plan format: {suffix!r}")
return build_feature_collection(geoms)
The emitted envelope matches the schema established across the pipeline — space_class, level, is_routable, source_format, and topology_hash — so it drops straight into JSON schema design for indoor maps on the delivery side and is gated before release by CI gating for map updates.
Edge Cases & Gotchas
| Symptom | Root cause | Resolution |
|---|---|---|
| Geometry renders mirrored or upside-down | SVG Y-axis not inverted, or DWG origin offset | Flip Y against the viewBox height (vb_h - y); for DWG read $INSBASE and translate to (0,0) before scaling. |
| Walls drift by hundreds of pixels | Nested <g> transforms multiplied in the wrong order |
Post-multiply parent-then-child (parent @ child); validate against a known anchor point in the source. |
| Door widths read as “1200 metres” | $INSUNITS ignored — millimetres treated as metres |
Always resolve $INSUNITS once per file and apply the scale to every coordinate. |
| Open polylines fail topology validation | Drafters left room outlines unclosed, or SVG paths lack Z |
shapely.ops.polygonize() within a 0.05 m tolerance, or flag is_routable=false for review. |
shapely raises TopologyException |
Duplicate vertices from export artefacts or anti-aliasing | Round coordinates to millimetre precision before validation; repair with make_valid / buffer(0). |
| Memory spikes on large DWG | Entire entity tree loaded into RAM | Stream with ezdxf.iterdxf and offload validation to async batch processing pipelines. |
Validation Output
Confirm correctness by asserting on the converged envelope, not by eyeballing a render. A correct single-room extraction looks like this:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": { "type": "Polygon", "coordinates": [[[0,0],[6,0],[6,4],[0,4],[0,0]]] },
"properties": {
"floor_layer": "A-ROOM", "space_class": "room",
"source_format": "dwg", "is_routable": true, "topology_hash": "9f3c2a7e"
}
}
]
}
A wrong extraction is easy to catch with assertions: a mirrored room shows a negative signed area, an un-inverted SVG places vertices above the viewBox height, and an unit-mismatch blows the bounding box far past any real building footprint.
from shapely.geometry import shape
def assert_metric_sanity(fc: dict, max_span_m: float = 500.0) -> None:
"""Fail fast if a FeatureCollection escaped the metric, Y-up contract."""
for feat in fc["features"]:
geom = shape(feat["geometry"])
minx, miny, maxx, maxy = geom.bounds
assert max(maxx - minx, maxy - miny) < max_span_m, "unit mismatch — span too large"
if geom.geom_type == "Polygon":
# CCW exterior ring => positive signed area (OGC convention).
assert geom.exterior.is_ccw, "ring orientation inverted — check Y-axis flip"
Performance & Scale Notes
Per-file cost is dominated by geometry validation, not parsing. make_valid is roughly O(n log n) in vertex count, so the highest-leverage optimisation is vertex reduction before validation: snap to millimetre precision and simplify(tolerance, preserve_topology=True) to collapse the redundant collinear runs CAD exports love to emit. A 50 MB DWG with a quarter-million entities should be streamed with ezdxf.iterdxf rather than fully loaded, and SVG DOMs over ~20 MB parsed with lxml.etree.iterparse so peak memory tracks the largest single floor level, not the whole file.
For portfolio-scale ingestion, parallelise per floor level — each file is independent, so a ProcessPoolExecutor over CPU cores scales near-linearly until the validation stage saturates memory bandwidth. Batch sizing of 8–16 floor levels per worker keeps the GeoJSON write amortised without starving the pool. Floor-level results then merge against a campus level mapping and Z-axis logic so vertical connectors line up across the stack.
Frequently Asked Questions
Should I convert DWG to DXF first, or read DWG directly?
For batch pipelines, convert to DXF once (via the ODA File Converter or ezdxf’s add-on) and cache it. DXF is a stable, documented text format that every ezdxf version reads identically, whereas native DWG support varies by release. Parsing the cached DXF removes a moving part from every subsequent re-vectorisation.
How do I know the SVG Y-axis flip is correct rather than just "looks right"?
Assert on signed area. After inversion, an exterior room ring should be counter-clockwise and therefore have positive signed area under the OGC convention (geom.exterior.is_ccw == True). If rings come out clockwise, the flip was skipped or applied twice. This is far more reliable than a visual check, which hides double-inversions that happen to look plausible.
Why unify into GeoJSON instead of passing DWG and SVG geometry downstream separately?
A single envelope means the topology validator, routing-graph builder, and client SDK all read one schema and never branch on origin format. It also lets you attach a topology_hash once, so the delivery layer can detect a genuine geometry change versus a no-op re-export and invalidate caches precisely.
What tolerance should I use for snapping micro-gaps?
Start at 0.05 m (5 cm) — large enough to close anti-aliasing and drafting gaps, small enough not to merge a real door opening (≥ 0.7 m) into a wall. Tune per data source: clean BIM exports tolerate tighter snapping, while scanned-then-traced drawings need looser values. Always run the connectivity audit afterwards to confirm no real opening was swallowed.
Related
- Parsing DWG files with Python ezdxf
- Extracting room boundaries from SVG floor plans
- Wall & Door Detection Algorithms
- Attribute Mapping from Blueprints
- Async Batch Processing Pipelines
This page is part of the Automated Floor Plan Parsing & Vectorization section of the Indoor Mapping & Wayfinding Automation reference.