Skip to content

Why parcels need polygonization

The intuitive model of a parcel in a CAD file — a closed polyline you can just read the area of — is wrong for essentially every real plat drawing. Understanding why explains most of extract_parcels’ design, including its failure modes.

A legal boundary is described as a sequence of courses: “N 89°12’44” E, 330.00 feet“, then the next call, and so on around the parcel. Survey and civil software mirrors that structure in the linework: each course is its own entity — a line or arc — and a “parcel” is just the region those entities happen to enclose. On the real-world subdivision fixture this project was validated against, the property-line layers contained dozens of polylines and not one of them was closed.

Three further complications stack on top:

  • Shared boundaries are drawn once. The line between Lot 11 and Lot 12 belongs to both parcels but exists as one entity. No per-parcel closed loop can exist in that representation.
  • Courses cross without vertices. A road right-of-way line crosses a section line; the crossing point usually has no vertex on either entity. The topology exists visually, not in the data.
  • Layers fragment the picture. After xref binding, one boundary can span both C-PROP-LINE and XREF-PLAN$0$C-PROP-LINE (see Xref shells).

So “read the closed polylines” returns nothing, and “connect entities end-to-end” fails the moment two parcels share an edge. What’s needed is planar topology reconstruction.

extract_parcels (and measure_by_layer, which shares the pipeline so the two reconcile) does this with shapely:

  1. Flatten every matched entity — lines, arcs, polylines with bulges — into line strings, with curves approximated at a 0.5 ft sagitta tolerance, far below plat accuracy.
  2. Node the whole collection with unary_union. This is the key step: every segment is split at every intersection with every other segment, producing a fully noded arrangement where crossings become shared vertices whether or not the source data had them.
  3. Polygonize with polygonize_full, which assembles the noded edges into every enclosed face — each parcel emerges as a polygon even though no single input entity described it.
  4. Filter and report: faces below min_area_sqft are dropped as slivers (double-drawn linework creates hair-thin faces), and the survivors are returned largest-first with area in square feet and acres, perimeter, and centroid.

polygonize_full returns more than polygons — it returns the linework that couldn’t participate in a face, and the tool surfaces this instead of discarding it:

  • Dangles (dangling_segments) — segments with a free end touching nothing. A parcel missing from the output almost always traces to a dangle: a course that stops a tenth of a foot short of the next one, or a boundary that continues on a layer outside your pattern. The count tells you the linework doesn’t fully enclose, before you trust the acreage.
  • Cut edges and invalid rings (invalid_rings) — edges connected at both ends that still bound no face, and rings polygonization rejected. These usually indicate non-boundary geometry that leaked onto a boundary layer.

This is a deliberate epistemic stance: an area figure computed from linework that mostly closes is not the parcel’s area, and pretending otherwise would be worse than failing. The diagnostics let the calling agent distinguish “38 clean parcels” from “38 parcels and 6 dangling courses you should look at” — and the fix loop (render the pattern, look at the gaps) is cheap.

Areas and lengths are computed in drawing units and reported as feet/acres because that’s what $INSUNITS = 2 declares. The tool reads the header and attaches a units_note warning when the drawing claims anything else — metric drawings get correct numbers with an explicit “these are not feet” flag rather than silently wrong acreage.