Revit and Navisworks Export Paths

Getting design data out of Revit and Navisworks is an export problem rather than a parsing problem, and it is the stage of the Python Parsing & Geometry Extraction pipeline that most often determines what the rest of the pipeline is capable of.

The reason is structural. Both native formats are closed, version-specific and readable only by the applications that write them, so a Python pipeline never touches them; it consumes an export. That export is where the model’s semantics are either preserved or thrown away, and the decision is made by whoever configured the exporter — often in a dialog, often once, often years ago. A pipeline that treats the export as a given inherits those decisions without knowing what they were.

Prerequisites

  • A machine that has the authoring application installed, if exports are to be automated. There is no way around this: the export must be performed by the application.
  • Python 3.9+ on the consuming side, which does not need to be the same machine.
  • ifcopenshell>=0.7.0 for the IFC route, ezdxf>=1.1.0 for the DXF route, and the conversion tooling described in DWG-to-Python Integration for DWG output.
  • lxml>=4.9 for reading Navisworks clash reports, which are XML.
  • Agreement with whoever owns the model about the export configuration. This is a project-governance prerequisite and it is the one most often skipped.

Architectural Overview

Three export routes exist, and they preserve different things.

The export route follows from what the pipeline reads A three-way branch on what the consuming pipeline actually needs. Anything beyond geometry — classification, quantities, property sets — points at the IFC route, the only one that preserves semantics. Drawings point at the CAD route. Clash results point at the coordination export, which carries the analysis and almost nothing of the model. What does the pipeline read? IFC typed products + psets properties DXF / DWG layer names only linework Clash XML analysis, not model clash results

The IFC route produces a typed product model: elements with classes, property sets, quantity sets, a spatial hierarchy and — when the model is set up for it — georeferencing. It is the only route that preserves semantics, and it is the route a pipeline should default to whenever the downstream work reads anything other than geometry.

The DXF or DWG route produces linework and solids organised by layer. Everything semantic is discarded; classification survives only as a layer-naming convention, which puts the downstream pipeline back in the position described in Layer Mapping Logic. It is the right route when the deliverable genuinely is drawings, and the wrong one whenever a property is needed later.

The coordination route — Navisworks exports and the formats around them — carries clash results, viewpoints and appearance overrides. It preserves almost nothing about the model itself, and everything about the analysis performed on it.

Route Preserves Loses Automatable
IFC typed products, psets, hierarchy, georeferencing native parametrics, families yes
DXF / DWG geometry, layer names all semantics yes
Clash XML clash results, viewpoints the model yes
Native RVT / NWD everything not readable outside the application

The fourth row is included because it is the assumption people arrive with. It is worth stating plainly: there is no supported route that reads a native file in Python, and building a pipeline on a partial reverse-engineered reader is a decision to be surprised by a version upgrade.

Step-by-Step Implementation

1. Decide what the downstream pipeline actually reads

Four export settings the downstream pipeline inherits Four configuration decisions made in the exporter that the consuming pipeline lives with and cannot see in the file. The schema release fixes where attributes live; the model view decides which elements are present; the property set mapping decides which parameters survive; the coordinate base decides whether the model is georeferenced at all. The export configuration is part of the output Schema release where attributes live Model view which elements exist Pset mapping which parameters survive Coordinate base georeferenced or not

This sounds procedural and is the step that saves the most work. Write down what the consuming stages need — geometry only, geometry plus classification, geometry plus quantities, clash results — and the route follows without further argument.

# Python 3.9+
ROUTE_FOR = {
    frozenset({"geometry"}):                       "dxf",
    frozenset({"geometry", "classification"}):     "ifc",
    frozenset({"geometry", "properties"}):         "ifc",
    frozenset({"clash"}):                          "clash_xml",
}

def choose_route(needs: set[str]) -> str:
    try:
        return ROUTE_FOR[frozenset(needs)]
    except KeyError:
        raise ValueError(f"no single export route covers {sorted(needs)} — split the pipeline")

The exception matters more than the lookup. A requirement set that no single route covers is a real finding, and the honest response is two exports rather than one export and a workaround.

2. Put the export configuration under version control

An IFC export is governed by a setup that decides the schema version, the model view definition, which property sets are written and how Revit categories map to IFC classes. A DWG export is governed by a layer mapping table. Both are files, both are exportable from the application, and neither is usually stored anywhere durable.

Commit them next to the pipeline, reference them by path in the automation, and record in the pipeline’s output which configuration produced the input. Two exports of one model that differ are then a diff rather than a mystery.

3. Automate the export

Automation runs on the machine with the application, triggered on a schedule or by a model check-in, and drops its output where the pipeline collects it. The details are application-specific, but the shape is the same and the failure modes are the shape’s:

# Python 3.9+ — the collector side, running anywhere
import subprocess, pathlib

def run_export(script: pathlib.Path, model: pathlib.Path, out: pathlib.Path,
               timeout_s: int = 3600) -> pathlib.Path:
    """Drive an export and verify a file actually appeared."""
    out.parent.mkdir(parents=True, exist_ok=True)
    before = out.stat().st_mtime if out.exists() else 0
    subprocess.run([str(script), str(model), str(out)], check=True, timeout=timeout_s)
    if not out.exists() or out.stat().st_mtime <= before:
        raise RuntimeError(f"export reported success but {out} was not written")
    return out

The mtime check is not defensive programming for its own sake. A GUI application driven headlessly exiting zero without writing anything is a routine occurrence, and it is the same failure the ODA converter exhibits — covered in Batch Converting DWG to DXF with the ODA File Converter.

4. Verify the export before consuming it

# ifcopenshell>=0.7.0
import ifcopenshell

def verify_ifc_export(path: str, expected_min_elements: int = 1) -> dict:
    model = ifcopenshell.open(path)
    elements = model.by_type("IfcElement")
    units = model.by_type("IfcUnitAssignment")
    report = {
        "schema": model.schema,
        "elements": len(elements),
        "has_units": bool(units),
        "has_georeferencing": bool(model.by_type("IfcMapConversion")),
    }
    if report["elements"] < expected_min_elements:
        raise ValueError(f"{path}: {report['elements']} elements — export looks truncated")
    if not report["has_units"]:
        raise ValueError(f"{path}: no unit assignment — geometry is unscaled")
    return report

Run this at the boundary, before any downstream stage. An export that is empty, unscaled or on an unexpected schema is a problem with the export, and diagnosing it three stages later costs an order of magnitude more.

5. Read clash results as data, not as a report

# lxml>=4.9
from lxml import etree

def clashes(xml_path: str):
    tree = etree.parse(xml_path)
    for result in tree.findall(".//clashresult"):
        yield {
            "name": result.get("name"),
            "status": result.get("status"),
            "distance": float(result.get("distance", "0")),
            "position": tuple(
                float(result.find(f"clashpoint/pos3f").get(a)) for a in ("x", "y", "z")
            ),
        }

Clash positions are model coordinates, so they inherit the model’s coordinate system and its origin. Reprojecting them into the project’s spatial system makes clash density mappable alongside everything else the pipeline produces, which is usually the reason the results were wanted in the first place.

Edge Cases and Gotchas

The export configuration is invisible in the output. An IFC file does not record which mapping table produced it. Two files from the same model with different configurations are indistinguishable except by their content, so a pipeline that consumes exports from more than one source needs the configuration recorded upstream — the file cannot tell you.

Export faults and what each one looks like downstream Four export configuration faults, the symptom each produces in the consuming pipeline, and the check that attributes it to the export rather than to the parser. All four produce a file that opens cleanly, which is why the acceptance checks compare counts and ratios rather than testing for exceptions. Fault Looks like Check that catches it Pset not mapped a field that is always None required pset list Links excluded a whole discipline missing element count per class Category unmapped elements as generic proxies proxy ratio Internal coordinates model at the origin map conversion present Every one of these opens cleanly — the exit status of an export proves nothing.

Shared coordinates have to exist before they can be exported. A model authored around an internal origin has no georeferencing to write, and no exporter setting will invent one. The fix is in the model, not in the export, and it is usually a conversation rather than a setting.

Linked models may or may not be included. An export can cover the host model only, or the host plus its links, and the setting is easy to overlook. The symptom is a coherent-looking model missing an entire discipline — a building with no services — which reads as an extraction failure rather than as an export scope.

Category-to-class mapping is lossy and configurable. Elements whose category has no natural IFC class land on a generic class such as a building element proxy. A pipeline filtering on specific classes then silently omits them. Count proxies in the verification step; a rising proxy count is the signal that a mapping needs attention.

Family parameters are not property sets by default. Parameters that a modelling team relies on may not be exported unless they are explicitly mapped to property sets. The elements arrive, the geometry is right, and the field the pipeline needs is absent — a failure that looks like a parsing bug and is an export configuration.

Navisworks appearance overrides are not model data. Colour and transparency applied in a federated view describe the view, not the elements. Reading them as element attributes attributes a reviewer’s highlighting to the design.

Validation and Testing

The tests worth automating compare an export against the model it came from, at the level of counts rather than geometry.

# pytest, ifcopenshell>=0.7.0
BASELINE = {"IfcWall": 812, "IfcSlab": 96, "IfcDoor": 214}

def test_export_matches_baseline_counts(exported_ifc):
    model = ifcopenshell.open(exported_ifc)
    for cls, expected in BASELINE.items():
        actual = len(model.by_type(cls))
        assert actual >= expected * 0.95, f"{cls}: {actual} vs baseline {expected}"

def test_proxy_ratio_is_bounded(exported_ifc):
    model = ifcopenshell.open(exported_ifc)
    total = len(model.by_type("IfcElement"))
    proxies = len(model.by_type("IfcBuildingElementProxy"))
    assert proxies / total < 0.05, (
        f"{proxies}/{total} elements exported as proxies — check the category mapping"
    )

The proxy ratio is the more informative of the two over time. Element counts drift as a model develops and the baseline needs maintaining; the proxy ratio should not drift at all, so a change in it is a change in the export rather than in the design.

Performance and Scale

Export is slow — minutes to tens of minutes for a large federated model — and it is slow on a licensed machine that cannot be scaled horizontally the way a container fleet can. That single fact shapes the architecture.

Export on a schedule, not on demand. A pipeline stage that triggers an export and waits couples its latency to the authoring application and its availability to a licence. Export nightly, publish the result to shared storage, and let the pipeline consume whatever is current. Where freshness matters, trigger on model check-in rather than on pipeline start.

Export once, consume many times. The expensive artefact is the export; the cheap artefacts are everything derived from it. A single IFC export feeding a footprint extraction, a quantity extraction and a clash overlay costs one export, whereas three pipelines each triggering their own costs three.

Split large federations by discipline. A federated model exported whole produces a file large enough to be awkward everywhere downstream. Per-discipline exports are faster to produce, faster to parse, independently re-runnable when one discipline changes, and they fail in isolation — one broken structural export does not deprive the pipeline of the architectural model.

FAQ

Can Python read an RVT file directly?

Not usefully. RVT is a closed, undocumented, version-specific format, and while its structured-storage container can be opened enough to read the version and some basic metadata, the model content is not accessible without the Revit API. Every production route out of Revit therefore involves an export performed by Revit itself. Plan for that dependency rather than looking for a parser.

Which export route preserves property sets?

IFC, and only IFC. A DWG or DXF export from Revit produces geometry organised by layer with the semantic model discarded, so property sets, type relationships and the spatial hierarchy do not survive. If the downstream pipeline reads properties — quantities, fire ratings, asset identifiers — the route is IFC and the export configuration decides which property sets are included.

Why do two exports of the same model differ?

Because the export configuration is part of the output and it usually lives in a dialog rather than in version control. IFC exports differ by mapping table, by which property sets are included and by the model view definition; DWG exports differ by layer mapping. Store the configuration file alongside the pipeline and reference it explicitly, and the exports become reproducible.

Does an export carry the project georeferencing?

Only if the model has it and the export is configured to include it. A Revit model that has a defined survey point and a specified coordinate base can export shared coordinates into IFC georeferencing, but a model set up with an internal origin only has nothing to export. Check the model rather than the exporter when georeferencing is missing.

Is Navisworks worth exporting from at all?

For geometry, rarely — the federated model it holds is assembled from source models that are better read directly. For clash results and the appearance and selection metadata that Navisworks itself produces, it is the only source, and those export as XML that a Python pipeline can read without the application. Treat it as a source of results rather than a source of models.