Computing Boolean Operations on CAD Footprints with Shapely

To union, difference or intersect CAD-derived footprints reliably, repair every ring before the operation, shift the coordinates to a local origin, use a single collection-wide call rather than a pairwise fold, and handle the multi-part results a CAD source will produce. Boolean algorithms assume valid input and are not obliged to detect that they did not get it. This page is part of Choosing a Geometry Engine for Python Pipelines.

Why CAD Input Breaks Boolean Operations

Geometry that came from a drawing arrives with three properties the algorithms dislike.

Three properties of CAD geometry and their remedies The three characteristics of drawing-derived geometry that boolean algorithms dislike, what each produces, and the step that removes it. All three are cheap to fix at the boundary and expensive to diagnose inside an operation, which is the argument for validating before computing rather than catching afterwards. Property Produces Remedy Self-intersecting ring undefined interior validate and repair Full projected coordinates precision loss shift to a local origin Near-coincident vertices nanometre slivers filter by area Each is one line at the boundary and a long afternoon inside a GEOS exception.

Rings are frequently not simple. A closed polyline drafted by a person can cross itself, double back, or repeat a vertex, and none of that is visible at drawing scale. A self-intersecting ring has no well-defined interior, so an operation on it has no well-defined answer.

Coordinates are large. Full projected easting and northing values put the arithmetic at seven significant figures before the decimal point, where the numerical margins that make the predicates robust are proportionally much smaller. Shifting to a local origin costs one subtraction and moves the whole computation into a range where it behaves.

Vertices are nearly coincident rather than coincident. Two footprints that share an edge in the drawing usually share it to within a micron rather than exactly, which produces slivers — zero-width polygons a few nanometres across — in the output. They are valid geometry and they are noise.

Production-Ready Script

# shapely>=2.0, numpy>=1.24, Python 3.9+
from __future__ import annotations

from dataclasses import dataclass
import numpy as np
from shapely.geometry import Polygon, MultiPolygon
from shapely.geometry.base import BaseGeometry
from shapely.ops import unary_union
from shapely.validation import make_valid, explain_validity
from shapely.affinity import translate


@dataclass(frozen=True)
class MergeReport:
    inputs: int
    repaired: int
    dropped: int
    parts_out: int


def _clean(poly: Polygon) -> BaseGeometry | None:
    """Repair a ring, or report it unusable. Never returns invalid geometry."""
    if poly.is_empty or poly.area <= 0:
        return None
    if poly.is_valid:
        return poly
    fixed = make_valid(poly)
    if fixed.is_empty:
        return None
    return fixed


def merge_footprints(
    polygons: list[Polygon], *, sliver_area: float = 1e-6
) -> tuple[BaseGeometry, MergeReport]:
    """Union a collection of CAD footprints, robustly."""
    if not polygons:
        raise ValueError("nothing to merge")

    # A common local origin keeps the arithmetic at small magnitudes.
    all_coords = np.vstack([np.array(p.exterior.coords) for p in polygons if not p.is_empty])
    ox, oy = all_coords.mean(axis=0)

    cleaned, repaired, dropped = [], 0, 0
    for p in polygons:
        shifted = translate(p, xoff=-ox, yoff=-oy)
        was_valid = shifted.is_valid
        fixed = _clean(shifted)
        if fixed is None:
            dropped += 1
            continue
        if not was_valid:
            repaired += 1
        cleaned.append(fixed)

    if not cleaned:
        raise ValueError("every input polygon was empty or unrepairable")

    merged = unary_union(cleaned)          # one pass, not a pairwise fold

    # Drop slivers produced by near-coincident edges.
    if isinstance(merged, MultiPolygon):
        keep = [g for g in merged.geoms if g.area > sliver_area]
        merged = MultiPolygon(keep) if len(keep) > 1 else (keep[0] if keep else merged)

    parts = len(merged.geoms) if hasattr(merged, "geoms") else 1
    return translate(merged, xoff=ox, yoff=oy), MergeReport(
        inputs=len(polygons), repaired=repaired, dropped=dropped, parts_out=parts)


def difference_with_report(a: Polygon, b: Polygon) -> BaseGeometry:
    """Difference with the post-condition a boolean will not check for you."""
    for name, g in (("a", a), ("b", b)):
        if not g.is_valid:
            raise ValueError(f"{name} is invalid: {explain_validity(g)}")
    result = a.difference(b)
    if result.area > a.area + 1e-9:
        raise ValueError("difference increased the area — check ring orientation")
    return result


if __name__ == "__main__":
    merged, report = merge_footprints([...])
    print(report, merged.geom_type)
Folding pairwise against merging the collection Two ways of unioning a set of footprints. Folding performs one operation per input on a progressively larger intermediate, and every intermediate is an opportunity for a validity problem to compound. Merging the collection in one call works over a spatially indexed structure and produces no intermediates at all. Fold with union() — n operations — intermediates grow — invalidity compounds — slow at scale unary_union() — one call over the collection — spatially indexed internally — no intermediates — the default Fold only when you actually need the intermediates.

Key implementation notes:

  • Validation happens before every operation, and repair is counted. A run that repaired 300 of 400 inputs is telling you something about the source drawing that a silent repair would hide.
  • The origin shift is applied to the inputs and reversed on the output, so the caller sees no difference except robustness.
  • unary_union merges the whole collection at once. The pairwise fold is the common first implementation and is markedly slower and less robust at scale.
  • Sliver filtering uses an area threshold rather than a buffer trick, because a zero-buffer round trip is itself a source of new invalidity.
  • The area post-condition on difference is three lines and catches a class of orientation bug the operation will not report.

Compatibility Matrix

Component Supported range Notes
shapely >=2.0 make_valid, vectorised predicates, GEOS 3.10+
GEOS >=3.10 make_valid behaviour and robustness improvements
numpy >=1.24 origin computation
Dimensionality planar only Z is carried, never considered
Input Polygon with closed rings closure is the caller’s responsibility

Fallback Strategies

1. TopologyException from GEOS. Almost always invalid input that slipped past validation, or coordinates large enough to lose precision. Confirm the origin shift is applied and that every input passed _clean.

When a planar engine is answering a different question A branch on whether elevation participates in the answer. Where the features genuinely lie in one plane, a planar library is fast, robust and correct. Where elevation separates them — floors of a building, a bridge over a road — the planar result is a correct answer to a question about plan, and the question asked was about space. Does elevation separate the features? Shapely fast and correct no A 3D engine plan answer is wrong yes

2. The union result is a GeometryCollection. Inputs touch along edges rather than overlapping, producing lines alongside polygons. Filter to polygons, and treat the collection as a signal that the drawing’s edges are coincident-ish rather than shared.

3. Slivers survive the filter. The threshold is too small for the coordinate scale. Set it relative to the smallest meaningful feature area rather than to an absolute constant.

4. Everything merges into one shape. Footprints at different elevations, unioned in plan. This is Shapely working correctly on the wrong question — see the parent page for when a planar engine is the wrong choice.

5. The result has fewer parts than expected. Near-coincident edges bridged features that are separate in reality. Snap the inputs to a deliberate tolerance grid before merging, so the bridging is a decision rather than an accident of precision.

FAQ

Why does unary_union beat folding with union in a loop?

Because it merges the whole collection with one pass over a spatially indexed structure, whereas a fold performs n operations on progressively larger intermediate geometries. The fold is slower by a wide margin at scale, and every intermediate is an opportunity for a validity problem to compound. Reach for the fold only when you need the intermediates.

Does Shapely consider Z in a union?

No. Shapely is planar: it will carry a Z ordinate through and it will not use it in any predicate or constructive operation. Two footprints at different elevations union in plan. Where elevation separates the features — floors of a building, a bridge over a road — a planar engine is answering a different question from the one you asked.

What should I do with a GeometryCollection result?

Decide deliberately rather than indexing into it. A collection appears when an operation produces mixed dimensionality — a polygon plus a line where two shapes touch along an edge. Filter to the dimension you want, and treat the presence of lower-dimensional parts as a signal that the input rings were touching rather than overlapping.