Skip to content

Commit adb3e87

Browse files
committed
[GEO] Fix hole intersection at tangential coordinate
OGC SFA 2.1.10 assertion 3 allows interior boundaries to touch exterior boundaries provided they intersect at a single point. Issue elastic#9511 provides an example where a valid shape is incorrectly interpreted as invalid (a false violation of assertion 3). When the intersecting point appears as the first and last coordinate of the interior boundary in a polygon, the ShapeBuilder incorrectly counted this as multiple intersecting vertices. The fix required a little more than just a logic check. Passing the duplicate vertices resulted in a connected component in the edge graph causing an invalid self crossing polygon. This required additional logic to the edge assignment in order to correctly segment the connected components. Finally, an additional hole validation has been added along with proper unit tests for testing valid and invalid conditions (including dateline crossing polys). closes elastic#9511
1 parent 1a8699d commit adb3e87

File tree

4 files changed

+195
-59
lines changed

4 files changed

+195
-59
lines changed

src/main/java/org/elasticsearch/common/geo/builders/BasePolygonBuilder.java

Lines changed: 67 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,18 @@
1919

2020
package org.elasticsearch.common.geo.builders;
2121

22+
import com.google.common.collect.Sets;
23+
import com.spatial4j.core.exception.InvalidShapeException;
2224
import com.spatial4j.core.shape.Shape;
2325
import com.vividsolutions.jts.geom.*;
24-
import org.elasticsearch.ElasticsearchParseException;
26+
import org.apache.commons.lang3.tuple.Pair;
2527
import org.elasticsearch.common.xcontent.XContentBuilder;
2628

2729
import java.io.IOException;
2830
import java.util.ArrayList;
2931
import java.util.Arrays;
32+
import java.util.HashMap;
33+
import java.util.HashSet;
3034
import java.util.Iterator;
3135

3236
/**
@@ -111,6 +115,18 @@ public ShapeBuilder close() {
111115
return shell.close();
112116
}
113117

118+
/**
119+
* Validates only 1 vertex is tangential (shared) between the interior and exterior of a polygon
120+
*/
121+
protected void validateHole(BaseLineStringBuilder shell, BaseLineStringBuilder hole) {
122+
HashSet exterior = Sets.newHashSet(shell.points);
123+
HashSet interior = Sets.newHashSet(hole.points);
124+
exterior.retainAll(interior);
125+
if (exterior.size() >= 2) {
126+
throw new InvalidShapeException("Invalid polygon, interior cannot share more than one point with the exterior");
127+
}
128+
}
129+
114130
/**
115131
* The coordinates setup by the builder will be assembled to a polygon. The result will consist of
116132
* a set of polygons. Each of these components holds a list of linestrings defining the polygon: the
@@ -125,6 +141,7 @@ public Coordinate[][][] coordinates() {
125141
int numEdges = shell.points.size()-1; // Last point is repeated
126142
for (int i = 0; i < holes.size(); i++) {
127143
numEdges += holes.get(i).points.size()-1;
144+
validateHole(shell, this.holes.get(i));
128145
}
129146

130147
Edge[] edges = new Edge[numEdges];
@@ -253,28 +270,62 @@ private static int component(final Edge edge, final int id, final ArrayList<Edge
253270
}
254271
}
255272

256-
double shift = any.coordinate.x > DATELINE ? DATELINE : (any.coordinate.x < -DATELINE ? -DATELINE : 0);
273+
double shiftOffset = any.coordinate.x > DATELINE ? DATELINE : (any.coordinate.x < -DATELINE ? -DATELINE : 0);
257274
if (debugEnabled()) {
258-
LOGGER.debug("shift: {[]}", shift);
275+
LOGGER.debug("shift: {[]}", shiftOffset);
259276
}
260277

261278
// run along the border of the component, collect the
262279
// edges, shift them according to the dateline and
263280
// update the component id
264-
int length = 0;
281+
int length = 0, connectedComponents = 0;
282+
// if there are two connected components, splitIndex keeps track of where to split the edge array
283+
// start at 1 since the source coordinate is shared
284+
int splitIndex = 1;
265285
Edge current = edge;
286+
Edge prev = edge;
287+
// bookkeep the source and sink of each visited coordinate
288+
HashMap<Coordinate, Pair<Edge, Edge>> visitedEdge = new HashMap<>();
266289
do {
267-
268-
current.coordinate = shift(current.coordinate, shift);
290+
current.coordinate = shift(current.coordinate, shiftOffset);
269291
current.component = id;
270-
if(edges != null) {
292+
293+
if (edges != null) {
294+
// found a closed loop - we have two connected components so we need to slice into two distinct components
295+
if (visitedEdge.containsKey(current.coordinate)) {
296+
if (connectedComponents > 0 && current.next != edge) {
297+
throw new InvalidShapeException("Shape contains more than one shared point");
298+
}
299+
300+
// a negative id flags the edge as visited for the edges(...) method.
301+
// since we're splitting connected components, we want the edges method to visit
302+
// the newly separated component
303+
final int visitID = -id;
304+
Edge firstAppearance = visitedEdge.get(current.coordinate).getRight();
305+
// correct the graph pointers by correcting the 'next' pointer for both the
306+
// first appearance and this appearance of the edge
307+
Edge temp = firstAppearance.next;
308+
firstAppearance.next = current.next;
309+
current.next = temp;
310+
current.component = visitID;
311+
// backtrack until we get back to this coordinate, setting the visit id to
312+
// a non-visited value (anything positive)
313+
do {
314+
prev.component = visitID;
315+
prev = visitedEdge.get(prev.coordinate).getLeft();
316+
++splitIndex;
317+
} while (!current.coordinate.equals(prev.coordinate));
318+
++connectedComponents;
319+
} else {
320+
visitedEdge.put(current.coordinate, Pair.of(prev, current));
321+
}
271322
edges.add(current);
323+
prev = current;
272324
}
273-
274325
length++;
275-
} while((current = current.next) != edge);
326+
} while(connectedComponents == 0 && (current = current.next) != edge);
276327

277-
return length;
328+
return (splitIndex != 1) ? length-splitIndex: length;
278329
}
279330

280331
/**
@@ -364,11 +415,12 @@ private static void assign(Edge[] holes, Coordinate[][] points, int numHoles, Ed
364415
// if no intersection is found then the hole is not within the polygon, so
365416
// don't waste time calling a binary search
366417
final int pos;
367-
if (intersections == 0 ||
368-
(pos = Arrays.binarySearch(edges, 0, intersections, current, INTERSECTION_ORDER)) >= 0) {
369-
throw new ElasticsearchParseException("Invalid shape: Hole is not within polygon");
418+
boolean sharedVertex = false;
419+
if (intersections == 0 || ((pos = Arrays.binarySearch(edges, 0, intersections, current, INTERSECTION_ORDER)) >= 0)
420+
&& !(sharedVertex = (edges[pos].intersect.compareTo(current.coordinate) == 0)) ) {
421+
throw new InvalidShapeException("Invalid shape: Hole is not within polygon");
370422
}
371-
final int index = -(pos+2);
423+
final int index = -((sharedVertex) ? 0 : pos+2);
372424
final int component = -edges[index].component - numHoles - 1;
373425

374426
if(debugEnabled()) {
@@ -465,7 +517,7 @@ private static int createEdges(int component, Orientation orientation, BaseLineS
465517
Edge[] edges, int offset) {
466518
// inner rings (holes) have an opposite direction than the outer rings
467519
// XOR will invert the orientation for outer ring cases (Truth Table:, T/T = F, T/F = T, F/T = T, F/F = F)
468-
boolean direction = (component != 0 ^ orientation == Orientation.RIGHT);
520+
boolean direction = (component == 0 ^ orientation == Orientation.RIGHT);
469521
// set the points array accordingly (shell or hole)
470522
Coordinate[] points = (hole != null) ? hole.coordinates(false) : shell.coordinates(false);
471523
Edge.ring(component, direction, orientation == Orientation.LEFT, shell, points, 0, edges, offset, points.length-1);

src/main/java/org/elasticsearch/common/geo/builders/ShapeBuilder.java

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
package org.elasticsearch.common.geo.builders;
2121

2222
import com.spatial4j.core.context.jts.JtsSpatialContext;
23+
import com.spatial4j.core.exception.InvalidShapeException;
2324
import com.spatial4j.core.shape.Shape;
2425
import com.spatial4j.core.shape.jts.JtsGeometry;
2526
import com.vividsolutions.jts.geom.Coordinate;
@@ -446,7 +447,8 @@ protected static final class Edge {
446447

447448
protected Edge(Coordinate coordinate, Edge next, Coordinate intersection) {
448449
this.coordinate = coordinate;
449-
this.next = next;
450+
// use setter to catch duplicate point cases
451+
this.setNext(next);
450452
this.intersect = intersection;
451453
if (next != null) {
452454
this.component = next.component;
@@ -457,6 +459,17 @@ protected Edge(Coordinate coordinate, Edge next) {
457459
this(coordinate, next, Edge.MAX_COORDINATE);
458460
}
459461

462+
protected void setNext(Edge next) {
463+
// don't bother setting next if its null
464+
if (next != null) {
465+
// self-loop throws an invalid shape
466+
if (this.coordinate.equals(next.coordinate)) {
467+
throw new InvalidShapeException("Provided shape has duplicate consecutive coordinates at: " + this.coordinate);
468+
}
469+
this.next = next;
470+
}
471+
}
472+
460473
private static final int top(Coordinate[] points, int offset, int length) {
461474
int top = 0; // we start at 1 here since top points to 0
462475
for (int i = 1; i < length; i++) {
@@ -522,17 +535,19 @@ private static Edge[] concat(int component, boolean direction, Coordinate[] poin
522535
if (direction) {
523536
edges[edgeOffset + i] = new Edge(points[pointOffset + i], edges[edgeOffset + i - 1]);
524537
edges[edgeOffset + i].component = component;
525-
} else {
538+
} else if(!edges[edgeOffset + i - 1].coordinate.equals(points[pointOffset + i])) {
526539
edges[edgeOffset + i - 1].next = edges[edgeOffset + i] = new Edge(points[pointOffset + i], null);
527540
edges[edgeOffset + i - 1].component = component;
541+
} else {
542+
throw new InvalidShapeException("Provided shape has duplicate consecutive coordinates at: " + points[pointOffset + i]);
528543
}
529544
}
530545

531546
if (direction) {
532-
edges[edgeOffset].next = edges[edgeOffset + length - 1];
547+
edges[edgeOffset].setNext(edges[edgeOffset + length - 1]);
533548
edges[edgeOffset].component = component;
534549
} else {
535-
edges[edgeOffset + length - 1].next = edges[edgeOffset];
550+
edges[edgeOffset + length - 1].setNext(edges[edgeOffset]);
536551
edges[edgeOffset + length - 1].component = component;
537552
}
538553

src/test/java/org/elasticsearch/common/geo/GeoJSONShapeParserTests.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ public void testParse_invalidMultiPolygon() throws IOException {
267267

268268
XContentParser parser = JsonXContent.jsonXContent.createParser(multiPolygonGeoJson);
269269
parser.nextToken();
270-
ElasticsearchGeoAssertions.assertValidException(parser, ElasticsearchParseException.class);
270+
ElasticsearchGeoAssertions.assertValidException(parser, InvalidShapeException.class);
271271
}
272272

273273
public void testParse_OGCPolygonWithoutHoles() throws IOException {

0 commit comments

Comments
 (0)