Skip to content

Commit 79a14a7

Browse files
wangiclaude
andcommitted
Fix "Hole lies outside shell" for reversed islands
organizePolygons() decides which rings are holes based on their direction only and uses just a bounding box test when assigning a hole to a polygon. A ring whose coastline was mapped the wrong way round can therefore end up as a hole of a polygon it is not inside of, or as a hole nested inside another hole. GEOS then reports "Hole lies outside shell" or "Holes are nested" and the whole polygon, possibly a whole continent, is invalid and ends up in the error_lines table. The existing direction fix runs after the polygons have been created and only looks at exterior rings, so it never caught these cases. Those rings are not holes at all but land mapped the wrong way round. They are now taken out of the polygon, turned around into land polygons of their own and reported in the error_lines table with the error "direction". While at it, invalid polygons that are not part of a multipolygon are now repaired in the same way as those that are, instead of being dropped. Fixes #41 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2c6dd17 commit 79a14a7

5 files changed

Lines changed: 326 additions & 35 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ This project adheres to [Semantic Versioning](https://semver.org/).
1212

1313
### Fixed
1414

15+
- Coastlines mapped the wrong way round can end up as holes of a land polygon
16+
they are not inside of ("Hole lies outside shell") or as holes inside
17+
another hole ("Holes are nested"). This made the (possibly very large) land
18+
polygon invalid. Those rings are now turned around into land polygons of
19+
their own and reported in the `error_lines` table with the error
20+
`direction` (#41).
21+
- Land polygons that are a single (invalid) polygon are now repaired in the
22+
same way as polygons that are part of a multipolygon instead of being
23+
dropped.
24+
1525

1626
## [2.5.0] - 2026-01-18
1727

src/osmcoastline.cpp

Lines changed: 166 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,10 @@
3838
#include <ogr_core.h>
3939
#include <ogr_geometry.h>
4040

41+
#include <algorithm>
4142
#include <cassert>
4243
#include <cerrno>
44+
#include <cstddef>
4345
#include <cstdlib>
4446
#include <cstring>
4547
#include <exception>
@@ -71,10 +73,166 @@ const unsigned int max_warnings = 500;
7173

7274
/* ================================================== */
7375

76+
/**
77+
* Is the point inside the ring? The envelope of the ring has to be given,
78+
* because calculating it is expensive and we need it several times.
79+
*
80+
* This uses the simple point-in-ring test instead of a GEOS operation,
81+
* because it has to work on invalid geometries, too.
82+
*/
83+
[[nodiscard]] bool point_in_ring(const OGRLinearRing* ring, const OGREnvelope& envelope, const OGRPoint& point) {
84+
if (point.getX() < envelope.MinX || point.getX() > envelope.MaxX ||
85+
point.getY() < envelope.MinY || point.getY() > envelope.MaxY) {
86+
return false;
87+
}
88+
89+
return ring->isPointInRing(&point, false);
90+
}
91+
92+
/**
93+
* Find the interior rings of the polygon that can not be holes of that
94+
* polygon, because they are outside the exterior ring or inside another
95+
* hole. Only the first point of each interior ring is looked at, so rings
96+
* that are only partly outside are not detected. Those cases show up as
97+
* intersections and are reported elsewhere.
98+
*/
99+
[[nodiscard]] std::vector<bool> find_misplaced_holes(const OGRPolygon& polygon) {
100+
const auto num_rings = static_cast<std::size_t>(polygon.getNumInteriorRings());
101+
102+
std::vector<OGREnvelope> envelopes(num_rings);
103+
std::vector<OGRPoint> first_points(num_rings);
104+
for (std::size_t i = 0; i < num_rings; ++i) {
105+
const OGRLinearRing* ring = polygon.getInteriorRing(static_cast<int>(i));
106+
assert(ring);
107+
ring->getEnvelope(&envelopes[i]);
108+
ring->getPoint(0, &first_points[i]);
109+
}
110+
111+
const OGRLinearRing* exterior_ring = polygon.getExteriorRing();
112+
assert(exterior_ring);
113+
OGREnvelope exterior_envelope;
114+
exterior_ring->getEnvelope(&exterior_envelope);
115+
116+
// rings outside the exterior ring ("Hole lies outside shell")
117+
std::vector<bool> outside(num_rings, false);
118+
for (std::size_t i = 0; i < num_rings; ++i) {
119+
outside[i] = !point_in_ring(exterior_ring, exterior_envelope, first_points[i]);
120+
}
121+
122+
// rings inside another hole ("Holes are nested"). Rings that are outside
123+
// the exterior ring are no holes at all, so they are not considered as
124+
// the containing ring here.
125+
std::vector<bool> misplaced{outside};
126+
for (std::size_t i = 0; i < num_rings; ++i) {
127+
if (misplaced[i]) {
128+
continue;
129+
}
130+
for (std::size_t j = 0; j < num_rings; ++j) {
131+
if (i != j && !outside[j] && envelopes[j].Contains(envelopes[i]) &&
132+
point_in_ring(polygon.getInteriorRing(static_cast<int>(j)), envelopes[j], first_points[i])) {
133+
misplaced[i] = true;
134+
break;
135+
}
136+
}
137+
}
138+
139+
return misplaced;
140+
}
141+
142+
/**
143+
* organizePolygons() decides which rings are holes based on their direction
144+
* and only checks the bounding box when assigning a hole to a polygon. So a
145+
* ring whose coastline was mapped the wrong way round can end up as a hole
146+
* of a polygon it isn't inside of or as a hole inside another hole. GEOS
147+
* then reports "Hole lies outside shell" or "Holes are nested" and the whole
148+
* polygon (possibly a whole continent) is invalid.
149+
*
150+
* Those rings are not holes but land mapped the wrong way round. Take them
151+
* out of the polygon, report them, and turn them around into land polygons
152+
* of their own.
153+
*/
154+
[[nodiscard]] std::unique_ptr<OGRPolygon> fix_misplaced_holes(std::unique_ptr<OGRPolygon> polygon,
155+
polygon_vector_type* polygons,
156+
OutputDatabase& output,
157+
unsigned int* turned_around) {
158+
const std::vector<bool> misplaced = find_misplaced_holes(*polygon);
159+
160+
if (std::find(misplaced.cbegin(), misplaced.cend(), true) == misplaced.cend()) {
161+
return polygon;
162+
}
163+
164+
auto fixed_polygon = std::make_unique<OGRPolygon>();
165+
fixed_polygon->addRingDirectly(polygon->getExteriorRing()->clone());
166+
167+
for (int i = 0; i < polygon->getNumInteriorRings(); ++i) {
168+
const OGRLinearRing* interior_ring = polygon->getInteriorRing(i);
169+
assert(interior_ring);
170+
171+
if (!misplaced[static_cast<std::size_t>(i)]) {
172+
fixed_polygon->addRingDirectly(interior_ring->clone());
173+
continue;
174+
}
175+
176+
auto ring = std::unique_ptr<OGRLinearRing>(interior_ring->clone());
177+
ring->reversePoints();
178+
179+
auto ls = std::unique_ptr<OGRLineString>(OGRGeometryFactory::forceToLineString(ring->clone())->toLineString());
180+
output.add_error_line(std::move(ls), "direction");
181+
182+
auto island = std::make_unique<OGRPolygon>();
183+
island->addRingDirectly(ring.release());
184+
island->assignSpatialReference(srs.wgs84());
185+
polygons->push_back(std::move(island));
186+
187+
++(*turned_around);
188+
}
189+
190+
fixed_polygon->assignSpatialReference(srs.wgs84());
191+
return fixed_polygon;
192+
}
193+
194+
/**
195+
* Add the polygon to the list of polygons, trying to fix it if it isn't
196+
* valid.
197+
*/
198+
void add_polygon_to(polygon_vector_type* polygons,
199+
std::unique_ptr<OGRPolygon> polygon,
200+
OutputDatabase& output,
201+
unsigned int* warnings, unsigned int* errors,
202+
unsigned int* turned_around) {
203+
if (polygon->IsValid()) {
204+
polygons->push_back(std::move(polygon));
205+
return;
206+
}
207+
208+
if (polygon->getNumInteriorRings() > 0) {
209+
polygon = fix_misplaced_holes(std::move(polygon), polygons, output, turned_around);
210+
if (polygon->IsValid()) {
211+
polygons->push_back(std::move(polygon));
212+
return;
213+
}
214+
}
215+
216+
auto* ring = polygon->getExteriorRing()->clone();
217+
auto ls = std::unique_ptr<OGRLineString>(OGRGeometryFactory::forceToLineString(ring)->toLineString());
218+
output.add_error_line(std::move(ls), "invalid");
219+
220+
std::unique_ptr<OGRGeometry> buf0{polygon->Buffer(0)};
221+
if (buf0 && buf0->getGeometryType() == wkbPolygon && buf0->IsValid()) {
222+
buf0->assignSpatialReference(srs.wgs84());
223+
polygons->push_back(static_cast_unique_ptr<OGRPolygon>(std::move(buf0)));
224+
(*warnings)++;
225+
} else {
226+
std::cerr << "Ignoring invalid polygon geometry.\n";
227+
(*errors)++;
228+
}
229+
}
230+
74231
void add_polygons_in_multi_to(polygon_vector_type *polygons,
75232
std::unique_ptr<OGRGeometry> mega_geometry,
76233
OutputDatabase& output,
77-
unsigned int* warnings, unsigned int* errors) {
234+
unsigned int* warnings, unsigned int* errors,
235+
unsigned int* turned_around) {
78236
// This isn't an owning pointer on purpose. We are going to "steal" parts
79237
// of the geometry a few lines below but only mark them as unowned farther
80238
// below when we are calling removeGeometry() on it. If this was an
@@ -87,22 +245,7 @@ void add_polygons_in_multi_to(polygon_vector_type *polygons,
87245
assert(geom);
88246
assert(geom->getGeometryType() == wkbPolygon);
89247
std::unique_ptr<OGRPolygon> p{static_cast<OGRPolygon*>(geom)};
90-
if (p->IsValid()) {
91-
polygons->push_back(std::move(p));
92-
} else {
93-
auto* ring = p->getExteriorRing()->clone();
94-
auto ls = std::unique_ptr<OGRLineString>(OGRGeometryFactory::forceToLineString(ring)->toLineString());
95-
output.add_error_line(std::move(ls), "invalid");
96-
std::unique_ptr<OGRGeometry> buf0{p->Buffer(0)};
97-
if (buf0 && buf0->getGeometryType() == wkbPolygon && buf0->IsValid()) {
98-
buf0->assignSpatialReference(srs.wgs84());
99-
polygons->push_back(static_cast_unique_ptr<OGRPolygon>(std::move(buf0)));
100-
(*warnings)++;
101-
} else {
102-
std::cerr << "Ignoring invalid polygon geometry.\n";
103-
(*errors)++;
104-
}
105-
}
248+
add_polygon_to(polygons, std::move(p), output, warnings, errors, turned_around);
106249
}
107250

108251
mega_multipolygon->removeGeometry(-1, FALSE);
@@ -112,7 +255,7 @@ void add_polygons_in_multi_to(polygon_vector_type *polygons,
112255
/**
113256
* This function assembles all the coastline rings into one huge multipolygon.
114257
*/
115-
polygon_vector_type create_polygons(CoastlineRingCollection& coastline_rings, OutputDatabase& output, unsigned int* warnings, unsigned int* errors) {
258+
polygon_vector_type create_polygons(CoastlineRingCollection& coastline_rings, OutputDatabase& output, unsigned int* warnings, unsigned int* errors, unsigned int* turned_around) {
116259
std::vector<OGRGeometry*> all_polygons = coastline_rings.add_polygons_to_vector();
117260

118261
if (all_polygons.empty()) {
@@ -136,16 +279,11 @@ polygon_vector_type create_polygons(CoastlineRingCollection& coastline_rings, Ou
136279
polygon_vector_type polygons;
137280

138281
if (mega_geometry->getGeometryType() == wkbPolygon) {
139-
if (mega_geometry->IsValid()) {
140-
polygons.push_back(static_cast_unique_ptr<OGRPolygon>(std::move(mega_geometry)));
141-
} else {
142-
std::cerr << "Ignoring invalid polygon geometry.\n";
143-
(*errors)++;
144-
}
282+
add_polygon_to(&polygons, static_cast_unique_ptr<OGRPolygon>(std::move(mega_geometry)), output, warnings, errors, turned_around);
145283
} else if (mega_geometry->getGeometryType() != wkbMultiPolygon) {
146284
throw std::runtime_error{"mega geometry isn't a (multi)polygon. Something is very wrong!"};
147285
} else {
148-
add_polygons_in_multi_to(&polygons, std::move(mega_geometry), output, warnings, errors);
286+
add_polygons_in_multi_to(&polygons, std::move(mega_geometry), output, warnings, errors, turned_around);
149287
}
150288

151289
return polygons;
@@ -354,15 +492,16 @@ int main(int argc, char *argv[]) {
354492
if (options.output_polygons != output_polygon_type::none || options.output_lines) {
355493
try {
356494
vout << "Create polygons...\n";
357-
CoastlinePolygons coastline_polygons{create_polygons(coastline_rings, *output_database, &warnings, &errors), \
495+
unsigned int turned_around = 0;
496+
CoastlinePolygons coastline_polygons{create_polygons(coastline_rings, *output_database, &warnings, &errors, &turned_around), \
358497
*output_database, \
359498
options.bbox_overlap, \
360499
options.max_points_in_polygon};
361500

362501
stats.land_polygons_before_split = coastline_polygons.num_polygons();
363502

364503
vout << "Fixing coastlines going the wrong way...\n";
365-
stats.rings_turned_around = coastline_polygons.fix_direction();
504+
stats.rings_turned_around = turned_around + coastline_polygons.fix_direction();
366505
vout << " Turned " << stats.rings_turned_around << " polygons around.\n";
367506
warnings += stats.rings_turned_around;
368507

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/bin/sh
2+
#-----------------------------------------------------------------------------
3+
#
4+
# Same as invalid-direction-island-in-bbox, but with a second, correctly
5+
# mapped island. This means organizePolygons() returns a multipolygon which
6+
# is handled by a different code path.
7+
# See https://github.com/osmcode/osmcoastline/issues/41
8+
#
9+
#-----------------------------------------------------------------------------
10+
11+
# shellcheck source=test/init.sh
12+
. "$1/test/init.sh"
13+
14+
set -x
15+
16+
#-----------------------------------------------------------------------------
17+
18+
"$BIN_DIR/src/nodegrid2opl" << 'NODES' >"$INPUT"
19+
20+
0---------------1
21+
| |
22+
| | a---b
23+
| 3--------2 | |
24+
| | d---c
25+
| | 6---7
26+
| | | |
27+
| | 9---8
28+
| |
29+
5------4
30+
31+
NODES
32+
33+
cat <<'OSM' >>"$INPUT"
34+
w200 v1 Tnatural=coastline Nn100,n105,n104,n103,n102,n101,n100
35+
w201 v1 Tnatural=coastline Nn106,n107,n108,n109,n106
36+
w202 v1 Tnatural=coastline Nn110,n113,n112,n111,n110
37+
OSM
38+
39+
#-----------------------------------------------------------------------------
40+
41+
"$OSMC" --verbose --overwrite --srs="$SRID" --output-database="$DB" "$INPUT" >"$LOG" 2>&1
42+
RC=$?
43+
set -e
44+
45+
test $RC -eq 1
46+
47+
grep 'Turned 1 polygons around.$' "$LOG"
48+
49+
grep '^There were 1 warnings.$' "$LOG"
50+
grep '^There were 0 errors.$' "$LOG"
51+
52+
check_count land_polygons 3;
53+
check_count error_points 0;
54+
check_count error_lines 1;
55+
56+
echo "SELECT InsertEpsgSrid(4326);" | $SQL
57+
58+
# the island that was turned around
59+
echo "SELECT AsText(Transform(geometry, 4326)) FROM land_polygons;" | $SQL \
60+
| grep -F 'POLYGON((1.15 1.94, 1.19 1.94, 1.19 1.92, 1.15 1.92, 1.15 1.94))'
61+
62+
echo "SELECT AsText(Transform(geometry, 4326)), osm_id, error FROM error_lines;" | $SQL \
63+
| grep -F '|0|direction'
64+
65+
#-----------------------------------------------------------------------------
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/bin/sh
2+
#-----------------------------------------------------------------------------
3+
#
4+
# Invalid island with coastline going the wrong direction inside the
5+
# bounding box of (but outside) a larger land polygon. Used to result in a
6+
# "Hole lies outside shell" error from GEOS which made the larger polygon
7+
# invalid. See https://github.com/osmcode/osmcoastline/issues/41
8+
#
9+
#-----------------------------------------------------------------------------
10+
11+
# shellcheck source=test/init.sh
12+
. "$1/test/init.sh"
13+
14+
set -x
15+
16+
#-----------------------------------------------------------------------------
17+
18+
"$BIN_DIR/src/nodegrid2opl" << 'NODES' >"$INPUT"
19+
20+
0---------------1
21+
| |
22+
| |
23+
| 3--------2
24+
| |
25+
| | 6---7
26+
| | | |
27+
| | 9---8
28+
| |
29+
5------4
30+
31+
NODES
32+
33+
cat <<'OSM' >>"$INPUT"
34+
w200 v1 Tnatural=coastline Nn100,n105,n104,n103,n102,n101,n100
35+
w201 v1 Tnatural=coastline Nn106,n107,n108,n109,n106
36+
OSM
37+
38+
#-----------------------------------------------------------------------------
39+
40+
"$OSMC" --verbose --overwrite --srs="$SRID" --output-database="$DB" "$INPUT" >"$LOG" 2>&1
41+
RC=$?
42+
set -e
43+
44+
test $RC -eq 1
45+
46+
grep 'Turned 1 polygons around.$' "$LOG"
47+
48+
grep '^There were 1 warnings.$' "$LOG"
49+
grep '^There were 0 errors.$' "$LOG"
50+
51+
check_count land_polygons 2;
52+
check_count error_points 0;
53+
check_count error_lines 1;
54+
55+
echo "SELECT InsertEpsgSrid(4326);" | $SQL
56+
57+
echo "SELECT AsText(Transform(geometry, 4326)) FROM land_polygons;" | $SQL \
58+
| grep -F 'POLYGON((1.15 1.94, 1.19 1.94, 1.19 1.92, 1.15 1.92, 1.15 1.94))'
59+
60+
echo "SELECT AsText(Transform(geometry, 4326)), osm_id, error FROM error_lines;" | $SQL \
61+
| grep -F '|0|direction'
62+
63+
#-----------------------------------------------------------------------------

0 commit comments

Comments
 (0)