Extracting Navisworks Clash Data for GIS
A Navisworks clash report exports as XML, which makes it the one thing in the Navisworks ecosystem a Python pipeline can read without the application. Parse the results, filter by status, transform the positions from model coordinates into the project system, and aggregate onto a grid so density rather than individual points becomes the finding. This page is part of Revit and Navisworks Export Paths.
What a Clash Report Contains
Each result records a name, a status, a distance of intersection, the two objects involved, and a position — the point the application computed as representative of the intersection. That position is in the federated model’s coordinate system, which is inherited from whichever source model was appended first and is therefore the same system the model itself needs georeferencing from.
Status matters more than it appears to. A report exported from a live coordination process contains results at every stage of review, and treating them uniformly presents closed work as open. Status is also the field most likely to have project-specific conventions layered on top of the standard values, so it is worth carrying through rather than collapsing.
Distance is the depth of intersection, and it separates a genuine conflict from a tolerance overlap. A 2 mm intersection between a duct and a structural zone is usually noise; a 400 mm one is not. Filtering on distance before mapping removes most of the volume without removing any of the findings.
Production-Ready Script
# lxml>=4.9, numpy>=1.24, shapely>=2.0, Python 3.9+
from __future__ import annotations
from dataclasses import dataclass
from collections import Counter
import numpy as np
from lxml import etree
from shapely.geometry import Point, box
@dataclass(frozen=True)
class Clash:
name: str
status: str
distance_m: float
x: float
y: float
z: float
def read_clashes(xml_path: str) -> list[Clash]:
tree = etree.parse(xml_path)
out: list[Clash] = []
for result in tree.iter("clashresult"):
pos = result.find("clashpoint/pos3f")
if pos is None:
continue # a grouped result with no representative point
out.append(Clash(
name=result.get("name", ""),
status=(result.get("status") or "unknown").lower(),
distance_m=abs(float(result.get("distance", "0"))),
x=float(pos.get("x")), y=float(pos.get("y")), z=float(pos.get("z")),
))
return out
def to_project(clashes: list[Clash], model_to_project: np.ndarray) -> np.ndarray:
"""Apply the 4x4 model-to-project transform to every clash position."""
pts = np.array([[c.x, c.y, c.z, 1.0] for c in clashes], dtype=float)
return (pts @ np.asarray(model_to_project).T)[:, :3]
def density_grid(xy: np.ndarray, cell_m: float = 5.0) -> list[tuple]:
"""Bin positions onto a regular grid; returns (polygon, count) per occupied cell."""
if len(xy) == 0:
return []
origin = np.floor(xy.min(axis=0) / cell_m) * cell_m
idx = np.floor((xy - origin) / cell_m).astype(int)
counts = Counter(map(tuple, idx))
cells = []
for (i, j), n in sorted(counts.items(), key=lambda kv: -kv[1]):
x0, y0 = origin + np.array([i, j]) * cell_m
cells.append((box(x0, y0, x0 + cell_m, y0 + cell_m), n))
return cells
def outstanding(clashes: list[Clash], *, min_distance_m: float = 0.01) -> list[Clash]:
open_states = {"new", "active"}
return [c for c in clashes
if c.status in open_states and c.distance_m >= min_distance_m]
if __name__ == "__main__":
all_clashes = read_clashes("clashes.xml")
live = outstanding(all_clashes)
print(f"{len(live)} outstanding of {len(all_clashes)} results")
xyz = to_project(live, model_to_project=np.eye(4))
for cell, n in density_grid(xyz[:, :2], cell_m=5.0)[:5]:
print(f"{n:4d} clashes in cell centred {cell.centroid.x:.1f}, {cell.centroid.y:.1f}")
Key implementation notes:
- Results without a representative point are skipped rather than defaulted to the origin, which would put a phantom cluster at model zero.
- Status is lower-cased once at parse time so downstream filters do not have to care about the report’s casing.
- The transform is applied to whole arrays rather than per clash, and it is the same 4×4 the model georeferencing produces — clash positions are model coordinates, not a separate system.
density_gridreturns cells sorted by count, so the first few rows are the finding.min_distance_mfilters tolerance-level intersections. Set it from the project’s coordination tolerance rather than leaving it at a default.
Compatibility Matrix
| Component | Supported range | Notes |
|---|---|---|
lxml |
>=4.9 |
iter over clash results |
| Report format | Navisworks clash XML | element names stable across recent releases |
numpy |
>=1.24 |
transform application and binning |
shapely |
>=2.0 |
grid cell polygons |
| Coordinate input | model coordinates | transform supplied by the caller |
Fallback Strategies
1. Positions cluster at the origin. Results without a representative point were defaulted rather than skipped, or the transform was not applied. Check the skip count first.
2. Status values are project-specific. Extend open_states from the project’s own vocabulary rather than assuming the standard set, and log the distinct values found so an unexpected one is visible.
3. Grouped clashes collapse detail. A grouped result may represent many intersections and carries one point. Where counts matter, export ungrouped, or weight the grid by the group size where it is recorded.
4. Density map dominated by one area. Usually a single systematic conflict — a service route through a structural zone — producing hundreds of results. That is a finding, not a distortion; report the count and the cell rather than smoothing it away.
5. The transform is unknown. Clash positions are only mappable once the model’s own georeferencing is resolved. Do that first; the same transform serves both.
FAQ
What coordinate system are clash positions in?
Model coordinates — the coordinate system of the federated model in Navisworks, which is usually the coordinate system of whichever source model was appended first. They are not projected coordinates, and they carry the same origin and rotation as that source model, so the transform to the project system is the same one that georeferences the model itself.
Should I map every clash?
No. A clash report contains results at several statuses — new, active, reviewed, approved, resolved — and mapping all of them presents resolved work as outstanding. Filter by status first, and keep the status on the feature so a map can be re-filtered without re-extracting.
Why aggregate onto a grid at all?
Because a clash report is usually thousands of points that are individually uninformative and collectively very informative. Density on a grid shows where coordination is failing — one grid square with two hundred clashes is a design conflict, two hundred squares with one clash each is normal tolerance noise — and that distinction is invisible in a point plot.
Related Pages
- Revit and Navisworks Export Paths — parent reference on which export carries what
- Writing CAD Geometry to PostGIS with GeoAlchemy2 — storing the resulting features
- Aligning BIM Models with GIS Survey Data — the transform that takes model coordinates into the project system