Skip to content

groupRings

groupRings(subpaths): RingGroup[]

Defined in: core/rings.ts:96

Group a flat list of closed rings into filled polygons with holes, at ARBITRARY nesting depth (issue #73 — an island in a lake in land, and deeper).

PathRecorder emits independent subpaths and discards which ring is an outer boundary vs. a hole, so we recover it from the geometry. The rule is the NONZERO winding rule — the same rule Canvas (ctx.fill()) and SVG (fill-rule: nonzero) apply natively to the very same subpaths, so all three backends agree by construction (AGENTS.md “Backend compositing equivalence”). For each ring we compute the winding number of the region just OUTSIDE it (the signed sum of the rings that enclose it) and just INSIDE it (that sum plus the ring’s own direction). A ring is then:

  • an outer (starts a new RingGroup) when the fill turns on across it — outside 0, inside ≠ 0;
  • a hole of the nearest enclosing outer when the fill turns off — outside ≠ 0, inside 0;
  • dropped when both sides are filled or both empty, since it bounds nothing.

With the documented winding convention (exterior rings clockwise in [lon, lat], holes wound the opposite way, so nesting levels alternate — see AGENTS.md “GeoJSON winding”) this is exactly alternating solid/hole by depth: land → lake → island → pond. Rings that do NOT alternate (two nested rings wound the same way) stay solid, which is what the Canvas/SVG nonzero fill already draws.

Open or degenerate (<3 vertex, zero-area) subpaths are skipped — a fill needs a closed ring.

Cost: rings are prepared once (area + bbox), sorted by descending area, and each ring is tested against the larger ones. The bbox test rejects non-containers before the O(vertices) ray cast, so the ray cast runs only for the handful of rings that actually overlap the probe point — the common geo case (thousands of disjoint island rings in one MultiPolygon) stays at cheap coordinate compares.

readonly Subpath[]

RingGroup[]