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.
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.
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
- Members carry their role and coordinates — the
Memberdataclass keeps the OSMroletag beside the resolved geometry, but note the role is a hint, not the source of truth for nesting. - Chaining tolerates reversal —
chain_membersgrows 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. - Endpoint matching uses a tolerance —
_endpoints_matchcompares withintolrather than requiring exact equality, because shared nodes across ways can differ by a floating-point epsilon after coordinate reconstruction. - Closing snaps only sub-tolerance gaps —
close_ringcloses a ring by replacing its last coordinate with its first when the gap is within tolerance, and returnsNone(a quarantine signal) when the gap is too wide to close honestly. - Degenerate rings are rejected early — a ring with fewer than four coordinates cannot bound an area and is dropped before it reaches the
LinearRingconstructor. - Nesting comes from containment, not tags —
outersare the rings not contained by any other ring, and each outer’s holes are the rings it contains. This is what fixes a mislabelledinner/outerrole: geometry decides. - Orientation is set explicitly —
orient(..., 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. make_validis the final gate — after assembly,make_validresolves 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_validmust beTrue, andis_emptymust beFalse. - Holes are present. For a relation with inner members, the result’s
interiors(or each part’sinteriors) must be non-empty; a lost hole means containment classification failed. - Winding is correct.
assembled.exterior.is_ccwmust beTrueand each interior ring’sis_ccwmust beFalse. - 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
assemblelogs the tolerance warning and returnsNonerather 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
outerandinnerroles; 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’smake_validenforces.
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.
Related Jump to heading
- Geometry Validation & Repair — the parent guide framing when to repair versus quarantine and the full defect taxonomy.
- Detecting Self-Intersecting OSM Polygons with Shapely — the sibling detection pass that flags the crossings this repair may resolve.
- Understanding OSM Multipolygon Relations for GIS — the relation model and role semantics behind member assembly.
- Error Handling in Large OSM Extracts — where relations that will not assemble are quarantined.
- OSM Data Quality & Validation — the quality section this repair step belongs to.
Up one level: Geometry Validation & Repair.