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.

One clash result and what each field decides The fields of a single result and the pipeline decision each one drives. Status separates outstanding work from closed; distance separates a genuine conflict from a tolerance overlap; the position is in model coordinates and inherits the model georeferencing. Mapping every result regardless of status presents resolved work as open. name Clash1247 the report identifier, kept for linking status active separates outstanding from closed distance 0.412 conflict, not tolerance overlap pos3f x, y, z model coordinates — needs the transform Filter on status and distance before mapping, and keep both on the feature.

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}")
From a list of points to a finding Four stages. Results are filtered to those that are open and above the coordination tolerance, transformed from model coordinates into the project system, binned onto a regular grid, and reported by cell. The binning is what turns thousands of individually uninformative points into a statement about where coordination is failing. Filter open, above tolerance Project system model-to-project 4×4 Grid cells count per cell The finding where it is failing transform bin rank

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_grid returns cells sorted by count, so the first few rows are the finding.
  • min_distance_m filters 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.

Two clash distributions that sum to the same total The count in the busiest grid cell for two reports with similar totals. A distribution concentrated in one cell is a systematic design conflict — a service route through a structural zone — and a single fix closes most of it. A flat distribution across many cells is tolerance noise. The total alone does not distinguish them, which is the reason for binning. busiest cell — concentrated 214 clashes busiest cell — dispersed 6 clashes 0 214 Both reports carry roughly 900 results; only one of them describes a single problem.

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.