Repairing Unclosed Ways and Broken Multipolygons Jump to heading

Take the member ways of an OSM multipolygon relation — some open, some fragmented across several ways, some digitised a nanodegree short of closing — and assemble them into valid, correctly nested Polygon geometry with the right outer/inner roles and winding.

Prerequisites Jump to heading

Conceptual minimum Jump to heading

An OSM multipolygon relation does not store rings — it stores member ways, and a ring often spans several of them. A large lake boundary might be split into four ways that only form a closed loop when chained end-to-end; a building might be a single closed way used directly as an outer ring. Three things routinely go wrong. A way meant to close on itself ends a hair short, because the editor never snapped the final node to the first — the endpoints differ by a nanodegree, so a Polygon constructor rejects it. Fragmented members fail to chain because their shared endpoints do not match exactly, or because a member is reversed relative to its neighbour. And the outer/inner role tags may be missing or wrong, so a ring that should punch a hole is treated as a second outer, or vice versa.

Repair is therefore a pipeline: chain members into rings by matching endpoints, close each ring by snapping near-miss endpoints, classify rings as outer or inner by containment rather than trusting the role tags blindly, and orient each ring to the right-hand-rule winding that the OGC Simple Features model and GIS engines expect — exterior counter-clockwise, holes clockwise. Only then does make_valid get the last word, resolving any residual self-touch. This sits under Geometry Validation & Repair, which frames when to attempt this repair versus quarantine a relation whose members simply do not form coherent rings.

Multipolygon repair pipeline: chain, close, classify, orient, validate Member ways flow through chain, close, classify by containment, and orient stages into a valid nested polygon; rings that cannot be closed branch off to quarantine. Chain, close, classify by containment, orient, then validate Member ways open · fragmented near-miss ends Chain match endpoints Close snap to tolerance Classify outer / inner Orient CCW / CW Valid nested polygon make_valid gate gap > tolerance Quarantine ring will not close Trust containment over role tags; snap only sub-tolerance gaps; quarantine the rest.

Runnable solution Jump to heading

The module chains member ways into rings, closes near-miss rings by snapping, classifies rings as outer or inner by containment, orients them, and validates the assembled polygon. It uses Shapely 2.x, Python 3.10+ type hints, and the project logger convention.

python
from __future__ import annotations

import logging
from dataclasses import dataclass

from shapely import make_valid
from shapely.geometry import LinearRing, MultiPolygon, Point, Polygon
from shapely.geometry.polygon import orient

logger = logging.getLogger("osm.geometry.repair_multipolygon")

Coord = tuple[float, float]


@dataclass
class Member:
    role: str                 # "outer", "inner", or ""
    coords: list[Coord]       # resolved node coordinates in way order


def _endpoints_match(a: Coord, b: Coord, tol: float) -> bool:
    return abs(a[0] - b[0]) <= tol and abs(a[1] - b[1]) <= tol


def chain_members(members: list[Member], tol: float) -> list[list[Coord]]:
    """Chain member ways into ordered rings by matching shared endpoints."""
    pending = [list(m.coords) for m in members if len(m.coords) >= 2]
    rings: list[list[Coord]] = []
    while pending:
        ring = pending.pop(0)
        extended = True
        while extended and not _endpoints_match(ring[0], ring[-1], tol):
            extended = False
            for i, seg in enumerate(pending):
                if _endpoints_match(ring[-1], seg[0], tol):
                    ring.extend(seg[1:]); pending.pop(i); extended = True; break
                if _endpoints_match(ring[-1], seg[-1], tol):
                    ring.extend(reversed(seg[:-1])); pending.pop(i); extended = True; break
        rings.append(ring)
    return rings


def close_ring(ring: list[Coord], tol: float) -> list[Coord] | None:
    """Snap a near-miss ring closed; return None if the gap exceeds tolerance."""
    if len(ring) < 4:
        return None
    if ring[0] == ring[-1]:
        return ring
    if _endpoints_match(ring[0], ring[-1], tol):
        return [*ring[:-1], ring[0]]      # snap last onto first
    logger.warning("ring gap %.9f exceeds tolerance; cannot close", _gap(ring))
    return None


def _gap(ring: list[Coord]) -> float:
    return Point(ring[0]).distance(Point(ring[-1]))


def assemble(members: list[Member], tol: float = 5e-7) -> Polygon | MultiPolygon | None:
    """Assemble multipolygon members into a valid, correctly nested polygon."""
    closed: list[LinearRing] = []
    for ring in chain_members(members, tol):
        snapped = close_ring(ring, tol)
        if snapped is None:
            continue                      # send to quarantine upstream
        try:
            closed.append(LinearRing(snapped))
        except ValueError as exc:
            logger.warning("invalid ring discarded: %s", exc)

    if not closed:
        return None

    # Classify by containment: the largest ring not inside another is outer.
    polys = [Polygon(r) for r in closed]
    outers = [p for p in polys if not any(o.contains(p) and o is not p for o in polys)]
    result_parts: list[Polygon] = []
    for outer in outers:
        holes = [p.exterior.coords for p in polys if outer.contains(p) and p is not outer]
        poly = orient(Polygon(outer.exterior.coords, holes), sign=1.0)  # CCW shell
        result_parts.append(poly)

    assembled = result_parts[0] if len(result_parts) == 1 else MultiPolygon(result_parts)
    fixed = make_valid(assembled)
    if not fixed.is_valid or fixed.is_empty:
        logger.error("assembled multipolygon failed final validity gate")
        return None
    return fixed

Step-by-step walkthrough Jump to heading

  1. Members carry their role and coordinates — the Member dataclass keeps the OSM role tag beside the resolved geometry, but note the role is a hint, not the source of truth for nesting.
  2. Chaining tolerates reversalchain_members grows a ring by appending any member whose endpoint matches the ring’s tail, reversing the member when only its far end matches. This handles the common case where members were digitised in inconsistent directions.
  3. Endpoint matching uses a tolerance_endpoints_match compares within tol rather than requiring exact equality, because shared nodes across ways can differ by a floating-point epsilon after coordinate reconstruction.
  4. Closing snaps only sub-tolerance gapsclose_ring closes a ring by replacing its last coordinate with its first when the gap is within tolerance, and returns None (a quarantine signal) when the gap is too wide to close honestly.
  5. Degenerate rings are rejected early — a ring with fewer than four coordinates cannot bound an area and is dropped before it reaches the LinearRing constructor.
  6. Nesting comes from containment, not tagsouters are the rings not contained by any other ring, and each outer’s holes are the rings it contains. This is what fixes a mislabelled inner/outer role: geometry decides.
  7. Orientation is set explicitlyorient(..., sign=1.0) forces the exterior counter-clockwise and holes clockwise, the right-hand-rule winding GIS consumers assume, regardless of how the source ways wound.
  8. make_valid is the final gate — after assembly, make_valid resolves any residual self-touch between a hole and its shell, and the validity check rejects the result rather than emitting a silently broken polygon.

Verification Jump to heading

  • Closed and valid. assemble(members).is_valid must be True, and is_empty must be False.
  • Holes are present. For a relation with inner members, the result’s interiors (or each part’s interiors) must be non-empty; a lost hole means containment classification failed.
  • Winding is correct. assembled.exterior.is_ccw must be True and each interior ring’s is_ccw must be False.
  • Area is plausible. Compare the assembled area against a rough expected value; a hole treated as a second outer inflates the area, a missing hole also inflates it.
  • Quarantine fires on a wide gap. Feed a member set with a deliberately large end-to-end gap and confirm assemble logs the tolerance warning and returns None rather than a torn ring.

Common errors and fixes Jump to heading

Symptom Root cause One-line fix
ValueError: A LinearRing must have at least 3 coordinate tuples Ring chained to fewer than 4 points The len(ring) < 4 guard drops it; verify members actually share endpoints
Hole rendered as a separate polygon Containment check skipped; role tag trusted Classify by outer.contains(p), not by the inner role tag
Ring never closes Endpoints differ beyond tolerance Widen tol cautiously, or quarantine — a wide gap means a genuinely missing member
Polygon valid but hole is filled Interior ring wound the same way as the shell Use orient so holes are clockwise relative to a CCW shell
Members chain in the wrong order Only forward matching attempted Keep the reversed-endpoint branch in chain_members
make_valid returns a GeometryCollection Rings self-touch after assembly Extract polygonal parts; if none survive, quarantine the relation

Specification reference Jump to heading

An OSM multipolygon relation carries member ways with outer and inner roles; the ways must be assembled into closed rings, and a single ring may be split across several member ways that share end nodes. The authoritative rules for ring assembly, role semantics, and the requirement that inner rings lie within outer rings are documented on the OSM Wiki at Relation:multipolygon. Ring closure and validity follow the OGC Simple Features polygon rules that Shapely’s make_valid enforces.

Frequently Asked Questions Jump to heading

Should I trust the inner and outer role tags?

Treat them as hints, not truth. Roles are frequently missing or swapped in real OSM data, and a wrong role turns a hole into a second outer ring or drops it entirely. Classify nesting by geometric containment instead: the rings not contained by any other ring are outers, and each outer’s holes are the rings it contains. Geometry is self-consistent in a way that hand-entered role tags are not.

How large should the closing tolerance be?

Small — a few times 1e-7 degrees, which is roughly a few centimetres at the equator, is enough to absorb the floating-point epsilon between nodes that should coincide. A larger tolerance risks snapping across a genuinely missing member and fabricating a ring that was never mapped. When the gap exceeds tolerance, quarantining the relation is safer than widening the tolerance to force closure.

Why chain member ways instead of polygonizing them directly?

A single ring is often split across several member ways, so no individual member is a closed ring on its own. Chaining links members by shared endpoints, handling members digitised in opposite directions, so the loop closes. Shapely’s polygonize can help once you have a clean noded edge set, but explicit endpoint chaining gives you control over the reversal and tolerance behaviour that raw OSM members demand.

What if make_valid returns a GeometryCollection after assembly?

That means the assembled rings still self-touch or overlap in a way with no single valid interpretation. Extract the polygonal parts from the collection and keep those; if no polygon survives, the relation’s members do not form coherent rings and the whole relation should be quarantined for review rather than forced into a shape the source data does not support.

Up one level: Geometry Validation & Repair.