A GIS analyst exports a parcel layer from QGIS as DXF, opens it in Rhino for an urban design project, and a third of the parcels are missing. The ones with courtyards — inner holes — come in as the outer boundary only. A few with weird digitizing errors don’t come in at all. The analyst checks the QGIS layer: everything is there. The DXF is the problem, and so is the path that was used to produce it.

There are two different DXF exporters inside QGIS, and the one most people reach for — Save Features As… — is the wrong one for polygon data headed to Rhino. This post walks through why, what the right path looks like, and the geometry prechecks that stop the silent drops before they happen.

The two DXF paths in QGIS

QGIS exposes DXF export through two completely different code paths. They look similar in the menu, but they produce different files.

Save Features As… (right-click on a layer → Export → Save Features As…). This dialog lists DXF in the format dropdown. Behind the scenes, it routes the layer through GDAL’s OGR abstraction and writes the file with the OGR DXF driver. This driver is documented at gdal.org/drivers/vector/dxf.html and its source lives in gdal/ogr/ogrsf_frmts/dxf/ogrdxfwriterlayer.cpp.

Project → Import/Export → Export Project to DXF…. This dialog uses QGIS’s own QgsDxfExport class, source at src/core/dxf/qgsdxfexport.cpp in the QGIS repo. It was written specifically for cartographic output — it understands symbology, labels, and the AutoCAD HATCH entity.

The difference matters because the OGR writer was never designed to preserve polygon topology. The QGIS native writer was.

What the OGR DXF writer actually does with polygons

Reading ogrdxfwriterlayer.cpp, the polygon path is straightforward and limited. For each feature of type wkbPolygon:

  1. Take the exterior ring
  2. Write it as a closed LWPOLYLINE (entity type 0 = LWPOLYLINE, flag bit 1 = closed)
  3. Stop

Interior rings — the holes — are simply not walked. The writer has no HATCH code path in its default configuration and no concept of a polygon-with-holes in DXF’s LWPOLYLINE model. The result is a file where your building footprint with a courtyard becomes a solid rectangle in Rhino.

For wkbMultiPolygon, each exterior ring of each part becomes its own LWPOLYLINE. Interior rings of each part are still dropped. For wkbGeometryCollection holding mixed types, the driver iterates members and writes each one individually, which can split a single feature into multiple entities that no longer share attributes.

Self-intersecting polygons get a different treatment. The OGR writer does not validate geometry — it writes the coordinate sequence as-is. Rhino’s DXF importer, which uses a Teigha-derived parser under the hood, will sometimes import these as open polylines instead of closed ones because the start and end points land near each other but the self-intersection breaks the topology check. You end up with an outline Rhino will not let you extrude or boolean against because Rhino’s own IsClosedCurve test fails.

What the native QgsDxfExport writer does instead

The native writer is a proper DXF cartographic export. For polygons, the relevant method is QgsDxfExport::writePolygon, and the code path follows the AutoCAD entity model more faithfully:

  1. If the polygon has no interior rings, it writes both an LWPOLYLINE for the outline and a HATCH entity for the fill (when symbology has a fill brush)
  2. If the polygon has interior rings, each ring becomes a boundary path within the HATCH entity, and the outline LWPOLYLINEs are written for both the exterior and interior rings
  3. Labels are written as TEXT entities at the centroid or wherever the layer’s label placement algorithm put them
  4. The CRS is reprojected to whatever you specify in the export dialog — including proper scale, so Rhino gets meters or feet instead of raw degrees

The key entity is HATCH. DXF’s HATCH entity (AutoCAD reference group code 92 for boundary paths, group code 93 for edges per path) is the only primitive in the format that can represent a polygon with holes as a single unit. LWPOLYLINE cannot.

Rhino’s importer understands HATCH and converts it to either a boundary representation or a hatch curve set, depending on Rhino version and import options. The interior rings survive the round trip.

The geometry prechecks that prevent silent drops

Even the native writer can produce weird Rhino output if the source geometry is already broken. GIS data is routinely broken — contractors digitize buildings with backtracking vertices, survey data comes in with unclosed polygons, format conversions introduce spike vertices.

Before any export to DXF, run the validity check. From the menu: Vector → Geometry Tools → Check Validity. From Processing: QGIS Geoalgorithms → Vector Geometry → Check Validity. Either one produces three output layers: valid features, invalid features, and error locations.

The check uses GEOS by default, which enforces the OGC Simple Features rules:

  • Rings must be closed (first and last vertex coincident)
  • Rings must not self-intersect
  • Interior rings must be strictly inside the exterior ring
  • Multi-part geometries’ parts must not overlap

Any failure here is a candidate for silent drops in Rhino. For the self-intersection case specifically — the most common issue in field-collected data — run Vector → Geometry Tools → Fix Geometries before export. This algorithm uses GEOS’s MakeValid operation, which splits self-intersecting polygons into multiple valid polygons. The output has the same area-on-area coverage as the input; the topology is just clean.

Null geometries and empty geometries are a separate gotcha. QGIS treats them as valid features but the OGR writer emits nothing for them. If your feature count in Rhino is lower than in QGIS, filter null geometries first: Processing → Vector Selection → Extract by Expression with geometry IS NOT NULL AND NOT is_empty($geometry).

We covered similar preflight checks for shapefile-bound data in shapefile validation in CI. The same three classes of problems — field truncation, invalid geometry, null/empty features — account for most silent conversion failures regardless of output format.

Coordinate Systems — The Most Common QGIS-to-Rhino Failure

After the geometry prechecks, this is the step most workflows miss. Rhino is a metric (or imperial) CAD tool. It expects coordinates in real-world units. QGIS layers frequently live in WGS84 (EPSG:4326) — latitude and longitude in decimal degrees. When you export a WGS84 layer to DXF and open it in Rhino with units set to meters, a parcel that is 50 meters wide arrives as 0.00045 meters wide. Rhino imports without error. The geometry is just completely the wrong scale, and the coordinates sit somewhere in the −180 to 180 range rather than at your site.

The fix is to reproject in QGIS before the export, not in Rhino after. Use Vector → Data Management Tools → Reproject Layer and pick a projected CRS in meters:

  • UTM (Universal Transverse Mercator): Covers the globe in 6° longitudinal zones. Find your zone at epsg.io by clicking the map. A site in central Berlin uses EPSG:32633 (UTM zone 33N). Units are meters.
  • State Plane (USA only): More accurate for small sites, breaks at zone boundaries. Good for survey-grade urban work.
  • Local national grids: OSGB36 for the UK (EPSG:27700), RD New for the Netherlands (EPSG:28992), GDA2020 for Australia. Use these when the project team is already working in that system.

If you are unsure and the site fits within a city, any UTM zone containing the site is accurate enough for Rhino-scale work. The goal is meters, not geodetic precision.

Resist using Rhino’s import Scale field to compensate. Rhino’s DXF import dialog has a scale factor. Applying 0.00001 to convert degrees to approximate meters gives you geometry at the right approximate size, but the origin is wrong and north is tilted by the latitude correction your projection would have applied. The part looks plausible on screen and fails in context. Reproject in QGIS first and import at scale 1.0.

For context on how DXF version choices interact with coordinate annotation entities when the file moves between GIS and CAD tools, see why DXF version defaults silently drop layout data.

The correct QGIS-to-Rhino DXF recipe

Put it together as a sequence that actually works:

Step 1. Reproject the layer to a projected CRS in meters. Rhino does not know about geographic coordinates. A lat/lon layer will import with vertex coordinates in the 0–180 range, and a 1000 m × 1000 m urban block will be microscopic when you set Rhino’s unit to meters. Use Vector → Data Management Tools → Reproject Layer to UTM, State Plane, or whatever projected CRS matches your site.

Step 2. Fix geometries. Vector → Geometry Tools → Fix Geometries. Save the output as a new layer. Do not skip this even if the source “looks fine” — invalid geometries are almost always invisible in the canvas.

Step 3. Filter out null and empty geometries. Save the filtered output.

Step 4. Open Project → Import/Export → Export Project to DXF. In the dialog:

  • Set symbology mode to No symbology if you want clean polylines with no fill, or Feature symbology if you want HATCH entities matching layer style.
  • Check Use layer title as name if you want a DXF layer per QGIS layer. Rhino shows DXF layers as Rhino layers on import.
  • Set Force 2D output — Rhino handles 3D DXF but for planar site data 2D is less error-prone.
  • Set Encoding to UTF-8. Rhino 7+ handles UTF-8 correctly; older versions may need Latin-1 if you have non-ASCII text labels.
  • Pick your projected CRS as the export CRS.

Step 5. Open the DXF in Rhino. Use File → Import, not drag-and-drop, so you see the import options. The relevant options:

  • Scale — should already be 1.0 if you exported in meters and Rhino is set to meters.
  • Convert to blocks — turn off for GIS data; you rarely want block references.
  • Join nested regions — useful for polygons with holes; this is where Rhino reconstructs the hole-in-polygon relationship from the HATCH boundary paths.

Step 6. Verify. In Rhino, use SelClosedPolyline to select everything that imported as a closed curve. The count should match your QGIS feature count (minus whatever you filtered for null/empty). If counts don’t match, something got dropped — usually a self-intersection that survived Fix Geometries, or a HATCH entity that Rhino decided to import as a separate hatch block instead of a curve.

When you still need a conversion step outside QGIS

There are cases where QGIS’s native exporter is not enough:

  • Very large datasets (millions of polygons). The native writer is single-threaded and memory-heavy. For these, export to GeoPackage or GeoParquet, then use a dedicated converter that streams features through GDAL with custom DXF writer hooks.
  • Rhino-specific layer metadata. If you need each polygon’s attribute to become a Rhino user text value, QGIS cannot write user data attributes into DXF entities. You need a post-processing pass in Rhino’s Grasshopper or a custom Python script.
  • Non-spatial attributes survive as features. QGIS writes the geometry; attribute values are lost entirely in the default DXF export. If you want the polygon’s parcel_id to travel to Rhino, either bake it into a TEXT entity at the centroid (the native writer does this for labels) or go through a different intermediate format.

This is where a format conversion tool with GIS-aware geometry handling earns its keep. GeoConvert was built around the observation that GIS-to-CAD pipelines almost always need one extra conversion step — not because GeoJSON or Shapefile or DXF are individually broken, but because the edges of each format’s data model don’t align. Polygons with holes, field truncation, CRS metadata — each crossing loses something unless the tool knows what to preserve. We covered the polygon-corruption side of this in why GeoJSON-to-shapefile conversions silently corrupt data; the CAD-side corollaries are the topic of this post.

The rule, stated plainly

If your DXF is going to Rhino and your source is QGIS, never use Save Features As… for polygon layers. That path routes through OGR, which drops interior rings. Use Project → Export Project to DXF instead — it writes HATCH entities that survive the Rhino round trip.

Before either path, run Fix Geometries and filter null features. Reproject to a metered projected CRS. Import into Rhino with File → Import (not drag-and-drop) so you see the options dialog. Check feature counts after import with SelClosedPolyline. If anything is missing, the geometry was already invalid and the fix is upstream in QGIS, not in Rhino.

Get the path right and a QGIS-to-Rhino DXF export is a solved problem. Get it wrong and you spend an afternoon chasing missing courtyards.