You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@lucene.apache.org by rm...@apache.org on 2016/04/01 05:18:15 UTC

lucene-solr:branch_6x: LUCENE-7153: give GeoPointField and LatLonPoint full polygon support

Repository: lucene-solr
Updated Branches:
  refs/heads/branch_6x 115326478 -> 2c0a8ed41


LUCENE-7153: give GeoPointField and LatLonPoint full polygon support


Project: http://git-wip-us.apache.org/repos/asf/lucene-solr/repo
Commit: http://git-wip-us.apache.org/repos/asf/lucene-solr/commit/2c0a8ed4
Tree: http://git-wip-us.apache.org/repos/asf/lucene-solr/tree/2c0a8ed4
Diff: http://git-wip-us.apache.org/repos/asf/lucene-solr/diff/2c0a8ed4

Branch: refs/heads/branch_6x
Commit: 2c0a8ed418934732fafa4d2bca0c757cca1a42b0
Parents: 1153264
Author: Robert Muir <rm...@apache.org>
Authored: Thu Mar 31 22:28:46 2016 -0400
Committer: Robert Muir <rm...@apache.org>
Committed: Thu Mar 31 22:29:44 2016 -0400

----------------------------------------------------------------------
 lucene/CHANGES.txt                              |   4 +
 .../org/apache/lucene/document/LatLonPoint.java |  15 +-
 .../document/LatLonPointInPolygonQuery.java     | 163 +++++----
 .../lucene/search/TestLatLonPointQueries.java   |   5 +-
 .../geopoint/search/GeoPointInPolygonQuery.java | 102 +++---
 .../search/GeoPointInPolygonQueryImpl.java      |  40 +--
 .../lucene/spatial/util/GeoRelationUtils.java   | 176 ----------
 .../apache/lucene/spatial/util/GeoUtils.java    |  49 ---
 .../org/apache/lucene/spatial/util/Polygon.java | 330 +++++++++++++++++++
 .../geopoint/search/TestGeoPointQuery.java      |   5 +-
 .../search/TestLegacyGeoPointQuery.java         |   7 +-
 .../spatial/util/BaseGeoPointTestCase.java      | 151 +++++----
 .../apache/lucene/spatial/util/GeoTestUtil.java |  17 +-
 .../lucene/spatial/util/TestGeoUtils.java       |  59 ----
 .../apache/lucene/spatial/util/TestPolygon.java | 141 ++++++++
 15 files changed, 733 insertions(+), 531 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/lucene-solr/blob/2c0a8ed4/lucene/CHANGES.txt
----------------------------------------------------------------------
diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt
index 7b7c9d3..636b642 100644
--- a/lucene/CHANGES.txt
+++ b/lucene/CHANGES.txt
@@ -43,6 +43,10 @@ Optimizations
 * LUCENE-7147: Improve disjoint check for geo distance query traversal
   (Ryan Ernst, Robert Muir, Mike McCandless)
 
+* LUCENE-7153: GeoPointField and LatLonPoint polygon queries now support
+  multiple polygons and holes, with memory usage independent of
+  polygon complexity. (Karl Wright, Mike McCandless, Robert Muir)
+
 Bug Fixes
 
 * LUCENE-7127: Fix corner case bugs in GeoPointDistanceQuery. (Robert Muir)

http://git-wip-us.apache.org/repos/asf/lucene-solr/blob/2c0a8ed4/lucene/sandbox/src/java/org/apache/lucene/document/LatLonPoint.java
----------------------------------------------------------------------
diff --git a/lucene/sandbox/src/java/org/apache/lucene/document/LatLonPoint.java b/lucene/sandbox/src/java/org/apache/lucene/document/LatLonPoint.java
index 60e8300..6e9e715 100644
--- a/lucene/sandbox/src/java/org/apache/lucene/document/LatLonPoint.java
+++ b/lucene/sandbox/src/java/org/apache/lucene/document/LatLonPoint.java
@@ -29,6 +29,7 @@ import org.apache.lucene.search.PointRangeQuery;
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.SortField;
 import org.apache.lucene.spatial.util.GeoUtils;
+import org.apache.lucene.spatial.util.Polygon;
 
 /** 
  * An indexed location field.
@@ -302,18 +303,14 @@ public class LatLonPoint extends Field {
   /** 
    * Create a query for matching a polygon.
    * <p>
-   * The supplied {@code polyLats}/{@code polyLons} must be clockwise or counter-clockwise.
+   * The supplied {@code polygon} must be clockwise or counter-clockwise.
    * @param field field name. must not be null.
-   * @param polyLats latitude values for points of the polygon: must be within standard +/-90 coordinate bounds.
-   * @param polyLons longitude values for points of the polygon: must be within standard +/-180 coordinate bounds.
+   * @param polygons array of polygons. must not be null or empty
    * @return query matching points within this polygon
-   * @throws IllegalArgumentException if {@code field} is null, {@code polyLats} is null or has invalid coordinates, 
-   *                                  {@code polyLons} is null or has invalid coordinates, if {@code polyLats} has a different
-   *                                  length than {@code polyLons}, if the polygon has less than 4 points, or if polygon is 
-   *                                  not closed (first and last points should be the same)
+   * @throws IllegalArgumentException if {@code field} is null, {@code polygons} is null or empty
    */
-  public static Query newPolygonQuery(String field, double[] polyLats, double[] polyLons) {
-    return new LatLonPointInPolygonQuery(field, polyLats, polyLons);
+  public static Query newPolygonQuery(String field, Polygon... polygons) {
+    return new LatLonPointInPolygonQuery(field, polygons);
   }
 
   /**

http://git-wip-us.apache.org/repos/asf/lucene-solr/blob/2c0a8ed4/lucene/sandbox/src/java/org/apache/lucene/document/LatLonPointInPolygonQuery.java
----------------------------------------------------------------------
diff --git a/lucene/sandbox/src/java/org/apache/lucene/document/LatLonPointInPolygonQuery.java b/lucene/sandbox/src/java/org/apache/lucene/document/LatLonPointInPolygonQuery.java
index 454a3dd..2875e2f 100644
--- a/lucene/sandbox/src/java/org/apache/lucene/document/LatLonPointInPolygonQuery.java
+++ b/lucene/sandbox/src/java/org/apache/lucene/document/LatLonPointInPolygonQuery.java
@@ -39,12 +39,13 @@ import org.apache.lucene.index.LeafReaderContext;
 import org.apache.lucene.util.BitSet;
 import org.apache.lucene.util.DocIdSetBuilder;
 import org.apache.lucene.util.FixedBitSet;
+import org.apache.lucene.util.NumericUtils;
 import org.apache.lucene.util.SparseFixedBitSet;
+import org.apache.lucene.util.StringHelper;
 import org.apache.lucene.spatial.util.GeoRect;
-import org.apache.lucene.spatial.util.GeoRelationUtils;
-import org.apache.lucene.spatial.util.GeoUtils;
+import org.apache.lucene.spatial.util.Polygon;
 
-/** Finds all previously indexed points that fall within the specified polygon.
+/** Finds all previously indexed points that fall within the specified polygons.
  *
  *  <p>The field must be indexed with using {@link org.apache.lucene.document.LatLonPoint} added per document.
  *
@@ -52,29 +53,27 @@ import org.apache.lucene.spatial.util.GeoUtils;
 
 final class LatLonPointInPolygonQuery extends Query {
   final String field;
-  final double minLat;
-  final double maxLat;
-  final double minLon;
-  final double maxLon;
-  final double[] polyLats;
-  final double[] polyLons;
+  final Polygon[] polygons;
 
   /** The lats/lons must be clockwise or counter-clockwise. */
-  public LatLonPointInPolygonQuery(String field, double[] polyLats, double[] polyLons) {
+  public LatLonPointInPolygonQuery(String field, Polygon[] polygons) {
     if (field == null) {
       throw new IllegalArgumentException("field must not be null");
     }
-    GeoUtils.checkPolygon(polyLats,  polyLons);
+    if (polygons == null) {
+      throw new IllegalArgumentException("polygons must not be null");
+    }
+    if (polygons.length == 0) {
+      throw new IllegalArgumentException("polygons must not be empty");
+    }
+    for (int i = 0; i < polygons.length; i++) {
+      if (polygons[i] == null) {
+        throw new IllegalArgumentException("polygon[" + i + "] must not be null");
+      }
+    }
     this.field = field;
-    this.polyLats = polyLats;
-    this.polyLons = polyLons;
-
+    this.polygons = polygons.clone();
     // TODO: we could also compute the maximal inner bounding box, to make relations faster to compute?
-    GeoRect box = GeoUtils.polyToBBox(polyLats, polyLons);
-    this.minLon = box.minLon;
-    this.maxLon = box.maxLon;
-    this.minLat = box.minLat;
-    this.maxLat = box.maxLat;
   }
 
   @Override
@@ -82,6 +81,25 @@ final class LatLonPointInPolygonQuery extends Query {
 
     // I don't use RandomAccessWeight here: it's no good to approximate with "match all docs"; this is an inverted structure and should be
     // used in the first pass:
+    
+    // bounding box over all polygons, this can speed up tree intersection/cheaply improve approximation for complex multi-polygons
+    // these are pre-encoded with LatLonPoint's encoding
+    final GeoRect box = Polygon.getBoundingBox(polygons);
+    final byte minLat[] = new byte[Integer.BYTES];
+    final byte maxLat[] = new byte[Integer.BYTES];
+    final byte minLon[] = new byte[Integer.BYTES];
+    final byte maxLon[] = new byte[Integer.BYTES];
+    NumericUtils.intToSortableBytes(LatLonPoint.encodeLatitude(box.minLat), minLat, 0);
+    NumericUtils.intToSortableBytes(LatLonPoint.encodeLatitude(box.maxLat), maxLat, 0);
+    NumericUtils.intToSortableBytes(LatLonPoint.encodeLongitude(box.minLon), minLon, 0);
+    NumericUtils.intToSortableBytes(LatLonPoint.encodeLongitude(box.maxLon), maxLon, 0);
+
+    // TODO: make this fancier, but currently linear with number of vertices
+    float cumulativeCost = 0;
+    for (Polygon polygon : polygons) {
+      cumulativeCost += 20 * (polygon.getPolyLats().length + polygon.getHoles().length);
+    }
+    final float matchCost = cumulativeCost;
 
     return new ConstantScoreWeight(this) {
 
@@ -120,27 +138,36 @@ final class LatLonPointInPolygonQuery extends Query {
 
                            @Override
                            public void visit(int docID, byte[] packedValue) {
-                             // TODO: range checks
+                             // we bounds check individual values, as subtrees may cross, but we are being sent the values anyway:
+                             // this reduces the amount of docvalues fetches (improves approximation)
+                             if (StringHelper.compare(Integer.BYTES, packedValue, 0, maxLat, 0) > 0 ||
+                                 StringHelper.compare(Integer.BYTES, packedValue, 0, minLat, 0) < 0 ||
+                                 StringHelper.compare(Integer.BYTES, packedValue, Integer.BYTES, maxLon, 0) > 0 ||
+                                 StringHelper.compare(Integer.BYTES, packedValue, Integer.BYTES, minLon, 0) < 0) {
+                               // outside of global bounding box range
+                               return;
+                             }
                              result.add(docID);
                            }
 
                            @Override
                            public Relation compare(byte[] minPackedValue, byte[] maxPackedValue) {
+                             if (StringHelper.compare(Integer.BYTES, minPackedValue, 0, maxLat, 0) > 0 ||
+                                 StringHelper.compare(Integer.BYTES, maxPackedValue, 0, minLat, 0) < 0 ||
+                                 StringHelper.compare(Integer.BYTES, minPackedValue, Integer.BYTES, maxLon, 0) > 0 ||
+                                 StringHelper.compare(Integer.BYTES, maxPackedValue, Integer.BYTES, minLon, 0) < 0) {
+                               // outside of global bounding box range
+                               return Relation.CELL_OUTSIDE_QUERY;
+                             }
+                             
                              double cellMinLat = LatLonPoint.decodeLatitude(minPackedValue, 0);
                              double cellMinLon = LatLonPoint.decodeLongitude(minPackedValue, Integer.BYTES);
                              double cellMaxLat = LatLonPoint.decodeLatitude(maxPackedValue, 0);
                              double cellMaxLon = LatLonPoint.decodeLongitude(maxPackedValue, Integer.BYTES);
 
-                             if (cellMinLat <= minLat && cellMaxLat >= maxLat && cellMinLon <= minLon && cellMaxLon >= maxLon) {
-                               // Cell fully encloses the query
-                               return Relation.CELL_CROSSES_QUERY;
-                             } else  if (GeoRelationUtils.rectWithinPolyPrecise(cellMinLat, cellMaxLat, cellMinLon, cellMaxLon,
-                                                                                polyLats, polyLons,
-                                                                                minLat, maxLat, minLon, maxLon)) {
+                             if (Polygon.contains(polygons, cellMinLat, cellMaxLat, cellMinLon, cellMaxLon)) {
                                return Relation.CELL_INSIDE_QUERY;
-                             } else if (GeoRelationUtils.rectCrossesPolyPrecise(cellMinLat, cellMaxLat, cellMinLon, cellMaxLon,
-                                                                                polyLats, polyLons,
-                                                                                minLat, maxLat, minLon, maxLon)) {
+                             } else if (Polygon.crosses(polygons, cellMinLat, cellMaxLat, cellMinLon, cellMaxLon)) {
                                return Relation.CELL_CROSSES_QUERY;
                              } else {
                                return Relation.CELL_OUTSIDE_QUERY;
@@ -156,6 +183,7 @@ final class LatLonPointInPolygonQuery extends Query {
 
         // return two-phase iterator using docvalues to postfilter candidates
         SortedNumericDocValues docValues = DocValues.getSortedNumeric(reader, field);
+
         TwoPhaseIterator iterator = new TwoPhaseIterator(disi) {
           @Override
           public boolean matches() throws IOException {
@@ -169,7 +197,7 @@ final class LatLonPointInPolygonQuery extends Query {
                 long encoded = docValues.valueAt(i);
                 double docLatitude = LatLonPoint.decodeLatitude((int)(encoded >> 32));
                 double docLongitude = LatLonPoint.decodeLongitude((int)(encoded & 0xFFFFFFFF));
-                if (GeoRelationUtils.pointInPolygon(polyLats, polyLons, docLatitude, docLongitude)) {
+                if (Polygon.contains(polygons, docLatitude, docLongitude)) {
                   return true;
                 }
               }
@@ -179,7 +207,7 @@ final class LatLonPointInPolygonQuery extends Query {
 
           @Override
           public float matchCost() {
-            return 20 * polyLons.length; // TODO: make this fancier, but currently linear with number of vertices
+            return matchCost;
           }
         };
         return new ConstantScoreScorer(this, score(), iterator);
@@ -187,65 +215,37 @@ final class LatLonPointInPolygonQuery extends Query {
     };
   }
 
+  /** Returns the query field */
   public String getField() {
     return field;
   }
 
-  public double getMinLat() {
-    return minLat;
-  }
-
-  public double getMaxLat() {
-    return maxLat;
-  }
-
-  public double getMinLon() {
-    return minLon;
-  }
-
-  public double getMaxLon() {
-    return maxLon;
-  }
-
-  public double[] getPolyLats() {
-    return polyLats;
-  }
-
-  public double[] getPolyLons() {
-    return polyLons;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (this == o) return true;
-    if (o == null || getClass() != o.getClass()) return false;
-    if (!super.equals(o)) return false;
-
-    LatLonPointInPolygonQuery that = (LatLonPointInPolygonQuery) o;
-
-    if (field.equals(that.field) == false) {
-      return false;
-    }
-    if (Arrays.equals(polyLons, that.polyLons) == false) {
-      return false;
-    }
-    if (Arrays.equals(polyLats, that.polyLats) == false) {
-      return false;
-    }
-
-    return true;
+  /** Returns a copy of the internal polygon array */
+  public Polygon[] getPolygons() {
+    return polygons.clone();
   }
 
   @Override
   public int hashCode() {
+    final int prime = 31;
     int result = super.hashCode();
-    result = 31 * result + field.hashCode();
-    result = 31 * result + Arrays.hashCode(polyLons);
-    result = 31 * result + Arrays.hashCode(polyLats);
+    result = prime * result + field.hashCode();
+    result = prime * result + Arrays.hashCode(polygons);
     return result;
   }
 
   @Override
+  public boolean equals(Object obj) {
+    if (this == obj) return true;
+    if (!super.equals(obj)) return false;
+    if (getClass() != obj.getClass()) return false;
+    LatLonPointInPolygonQuery other = (LatLonPointInPolygonQuery) obj;
+    if (!field.equals(other.field)) return false;
+    if (!Arrays.equals(polygons, other.polygons)) return false;
+    return true;
+  }
+
+  @Override
   public String toString(String field) {
     final StringBuilder sb = new StringBuilder();
     sb.append(getClass().getSimpleName());
@@ -255,14 +255,7 @@ final class LatLonPointInPolygonQuery extends Query {
       sb.append(this.field);
       sb.append(':');
     }
-    sb.append(" Points: ");
-    for (int i=0; i<polyLons.length; ++i) {
-      sb.append("[")
-        .append(polyLats[i])
-        .append(", ")
-        .append(polyLons[i])
-        .append("] ");
-    }
+    sb.append(Arrays.toString(polygons));
     return sb.toString();
   }
 }

http://git-wip-us.apache.org/repos/asf/lucene-solr/blob/2c0a8ed4/lucene/sandbox/src/test/org/apache/lucene/search/TestLatLonPointQueries.java
----------------------------------------------------------------------
diff --git a/lucene/sandbox/src/test/org/apache/lucene/search/TestLatLonPointQueries.java b/lucene/sandbox/src/test/org/apache/lucene/search/TestLatLonPointQueries.java
index 3f4da8a..3bde389 100644
--- a/lucene/sandbox/src/test/org/apache/lucene/search/TestLatLonPointQueries.java
+++ b/lucene/sandbox/src/test/org/apache/lucene/search/TestLatLonPointQueries.java
@@ -19,6 +19,7 @@ package org.apache.lucene.search;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.document.LatLonPoint;
 import org.apache.lucene.spatial.util.BaseGeoPointTestCase;
+import org.apache.lucene.spatial.util.Polygon;
 
 public class TestLatLonPointQueries extends BaseGeoPointTestCase {
 
@@ -38,8 +39,8 @@ public class TestLatLonPointQueries extends BaseGeoPointTestCase {
   }
 
   @Override
-  protected Query newPolygonQuery(String field, double[] lats, double[] lons) {
-    return LatLonPoint.newPolygonQuery(field, lats, lons);
+  protected Query newPolygonQuery(String field, Polygon... polygons) {
+    return LatLonPoint.newPolygonQuery(field, polygons);
   }
 
   @Override

http://git-wip-us.apache.org/repos/asf/lucene-solr/blob/2c0a8ed4/lucene/spatial/src/java/org/apache/lucene/spatial/geopoint/search/GeoPointInPolygonQuery.java
----------------------------------------------------------------------
diff --git a/lucene/spatial/src/java/org/apache/lucene/spatial/geopoint/search/GeoPointInPolygonQuery.java b/lucene/spatial/src/java/org/apache/lucene/spatial/geopoint/search/GeoPointInPolygonQuery.java
index e75030c..4e6bdce 100644
--- a/lucene/spatial/src/java/org/apache/lucene/spatial/geopoint/search/GeoPointInPolygonQuery.java
+++ b/lucene/spatial/src/java/org/apache/lucene/spatial/geopoint/search/GeoPointInPolygonQuery.java
@@ -20,10 +20,11 @@ import java.util.Arrays;
 
 import org.apache.lucene.index.IndexReader;
 import org.apache.lucene.search.Query;
+import org.apache.lucene.spatial.geopoint.document.GeoPointField;
 import org.apache.lucene.spatial.geopoint.document.GeoPointField.TermEncoding;
 import org.apache.lucene.spatial.util.GeoEncodingUtils;
 import org.apache.lucene.spatial.util.GeoRect;
-import org.apache.lucene.spatial.util.GeoUtils;
+import org.apache.lucene.spatial.util.Polygon;
 
 /** Implements a simple point in polygon query on a GeoPoint field. This is based on
  * {@code GeoPointInBBoxQueryImpl} and is implemented using a
@@ -39,41 +40,55 @@ import org.apache.lucene.spatial.util.GeoUtils;
  *    1.  The polygon coordinates need to be in either clockwise or counter-clockwise order.
  *    2.  The polygon must not be self-crossing, otherwise the query may result in unexpected behavior
  *    3.  All latitude/longitude values must be in decimal degrees.
- *    4.  Complex computational geometry (e.g., dateline wrapping, polygon with holes) is not supported
+ *    4.  Complex computational geometry (e.g., dateline wrapping) is not supported
  *    5.  For more advanced GeoSpatial indexing and query operations see spatial module
  *
  * @lucene.experimental
  */
 public final class GeoPointInPolygonQuery extends GeoPointInBBoxQuery {
-  // polygon position arrays - this avoids the use of any objects or
-  // or geo library dependencies
-  /** array of y (latitude) values (in degrees) */
-  protected final double[] polyLats;
-  /** array of x (longitude) values (in degrees) */
-  protected final double[] polyLons;
+  /** array of polygons being queried */
+  final Polygon[] polygons;
+
+  /** 
+   * Constructs a new GeoPolygonQuery that will match encoded {@link org.apache.lucene.spatial.geopoint.document.GeoPointField} terms
+   * that fall within or on the boundary of the polygons defined by the input parameters. 
+   */
+  public GeoPointInPolygonQuery(String field, Polygon... polygons) {
+    this(field, TermEncoding.PREFIX, polygons);
+  }
 
   /**
    * Constructs a new GeoPolygonQuery that will match encoded {@link org.apache.lucene.spatial.geopoint.document.GeoPointField} terms
    * that fall within or on the boundary of the polygon defined by the input parameters.
+   * @deprecated Use {@link #GeoPointInPolygonQuery(String, Polygon[])}.
    */
+  @Deprecated
   public GeoPointInPolygonQuery(final String field, final double[] polyLats, final double[] polyLons) {
-    this(field, TermEncoding.PREFIX, GeoUtils.polyToBBox(polyLats, polyLons), polyLats, polyLons);
+    this(field, TermEncoding.PREFIX, polyLats, polyLons);
   }
 
   /**
    * Constructs a new GeoPolygonQuery that will match encoded {@link org.apache.lucene.spatial.geopoint.document.GeoPointField} terms
    * that fall within or on the boundary of the polygon defined by the input parameters.
+   * @deprecated Use {@link #GeoPointInPolygonQuery(String, GeoPointField.TermEncoding, Polygon[])} instead.
    */
+  @Deprecated
   public GeoPointInPolygonQuery(final String field, final TermEncoding termEncoding, final double[] polyLats, final double[] polyLons) {
-    this(field, termEncoding, GeoUtils.polyToBBox(polyLats, polyLons), polyLats, polyLons);
+    this(field, termEncoding, new Polygon(polyLats, polyLons));
   }
 
-  /** Common constructor, used only internally. */
-  private GeoPointInPolygonQuery(final String field, TermEncoding termEncoding, GeoRect bbox, final double[] polyLats, final double[] polyLons) {
-    super(field, termEncoding, bbox.minLat, bbox.maxLat, bbox.minLon, bbox.maxLon);
-    GeoUtils.checkPolygon(polyLats,  polyLons);
-    this.polyLons = polyLons;
-    this.polyLats = polyLats;
+  /** 
+   * Constructs a new GeoPolygonQuery that will match encoded {@link org.apache.lucene.spatial.geopoint.document.GeoPointField} terms
+   * that fall within or on the boundary of the polygon defined by the input parameters. 
+   */
+  public GeoPointInPolygonQuery(String field, TermEncoding termEncoding, Polygon... polygons) {
+    this(field, termEncoding, Polygon.getBoundingBox(polygons), polygons);
+  }
+  
+  // internal constructor
+  private GeoPointInPolygonQuery(String field, TermEncoding termEncoding, GeoRect boundingBox, Polygon... polygons) {
+    super(field, termEncoding, boundingBox.minLat, boundingBox.maxLat, boundingBox.minLon, boundingBox.maxLon);
+    this.polygons = polygons.clone();
   }
 
   /** throw exception if trying to change rewrite method */
@@ -83,32 +98,26 @@ public final class GeoPointInPolygonQuery extends GeoPointInBBoxQuery {
   }
 
   @Override
-  public boolean equals(Object o) {
-    if (this == o) return true;
-    if (o == null || getClass() != o.getClass()) return false;
-    if (!super.equals(o)) return false;
-
-    GeoPointInPolygonQuery that = (GeoPointInPolygonQuery) o;
-
-    if (!Arrays.equals(polyLats, that.polyLats)) return false;
-    if (!Arrays.equals(polyLons, that.polyLons)) return false;
-
-    return true;
-  }
-
-  @Override
   public int hashCode() {
+    final int prime = 31;
     int result = super.hashCode();
-    result = 31 * result + Arrays.hashCode(polyLats);
-    result = 31 * result + Arrays.hashCode(polyLons);
+    result = prime * result + Arrays.hashCode(polygons);
     return result;
   }
 
+  @Override
+  public boolean equals(Object obj) {
+    if (this == obj) return true;
+    if (!super.equals(obj)) return false;
+    if (getClass() != obj.getClass()) return false;
+    GeoPointInPolygonQuery other = (GeoPointInPolygonQuery) obj;
+    if (!Arrays.equals(polygons, other.polygons)) return false;
+    return true;
+  }
+
   /** print out this polygon query */
   @Override
   public String toString(String field) {
-    assert polyLats.length == polyLons.length;
-
     final StringBuilder sb = new StringBuilder();
     sb.append(getClass().getSimpleName());
     sb.append(':');
@@ -117,31 +126,16 @@ public final class GeoPointInPolygonQuery extends GeoPointInBBoxQuery {
       sb.append(getField());
       sb.append(':');
     }
-    sb.append(" Points: ");
-    for (int i=0; i<polyLats.length; ++i) {
-      sb.append("[")
-          .append(polyLats[i])
-          .append(", ")
-          .append(polyLons[i])
-          .append("] ");
-    }
+    sb.append(" Polygon: ");
+    sb.append(Arrays.toString(polygons));
 
     return sb.toString();
   }
 
   /**
-   * API utility method for returning the array of longitudinal values for this GeoPolygon
-   * The returned array is not a copy so do not change it!
-   */
-  public double[] getLons() {
-    return this.polyLons;
-  }
-
-  /**
-   * API utility method for returning the array of latitudinal values for this GeoPolygon
-   * The returned array is not a copy so do not change it!
+   * API utility method for returning copy of the polygon array
    */
-  public double[] getLats() {
-    return this.polyLats;
+  public Polygon[] getPolygons() {
+    return polygons.clone();
   }
 }

http://git-wip-us.apache.org/repos/asf/lucene-solr/blob/2c0a8ed4/lucene/spatial/src/java/org/apache/lucene/spatial/geopoint/search/GeoPointInPolygonQueryImpl.java
----------------------------------------------------------------------
diff --git a/lucene/spatial/src/java/org/apache/lucene/spatial/geopoint/search/GeoPointInPolygonQueryImpl.java b/lucene/spatial/src/java/org/apache/lucene/spatial/geopoint/search/GeoPointInPolygonQueryImpl.java
index acace24..d499a47 100644
--- a/lucene/spatial/src/java/org/apache/lucene/spatial/geopoint/search/GeoPointInPolygonQueryImpl.java
+++ b/lucene/spatial/src/java/org/apache/lucene/spatial/geopoint/search/GeoPointInPolygonQueryImpl.java
@@ -16,9 +16,11 @@
  */
 package org.apache.lucene.spatial.geopoint.search;
 
+import java.util.Objects;
+
 import org.apache.lucene.search.MultiTermQuery;
 import org.apache.lucene.spatial.geopoint.document.GeoPointField.TermEncoding;
-import org.apache.lucene.spatial.util.GeoRelationUtils;
+import org.apache.lucene.spatial.util.Polygon;
 
 /** Package private implementation for the public facing GeoPointInPolygonQuery delegate class.
  *
@@ -26,11 +28,13 @@ import org.apache.lucene.spatial.util.GeoRelationUtils;
  */
 final class GeoPointInPolygonQueryImpl extends GeoPointInBBoxQueryImpl {
   private final GeoPointInPolygonQuery polygonQuery;
+  private final Polygon[] polygons;
 
   GeoPointInPolygonQueryImpl(final String field, final TermEncoding termEncoding, final GeoPointInPolygonQuery q,
                              final double minLat, final double maxLat, final double minLon, final double maxLon) {
     super(field, termEncoding, minLat, maxLat, minLon, maxLon);
-    polygonQuery = q;
+    this.polygonQuery = Objects.requireNonNull(q);
+    this.polygons = Objects.requireNonNull(q.polygons);
   }
 
   @Override
@@ -54,14 +58,12 @@ final class GeoPointInPolygonQueryImpl extends GeoPointInBBoxQueryImpl {
 
     @Override
     protected boolean cellCrosses(final double minLat, final double maxLat, final double minLon, final double maxLon) {
-      return GeoRelationUtils.rectCrossesPolyApprox(minLat, maxLat, minLon, maxLon, polygonQuery.polyLats, polygonQuery.polyLons,
-                                                    polygonQuery.minLat, polygonQuery.maxLat, polygonQuery.minLon, polygonQuery.maxLon);
+      return Polygon.crosses(polygons, minLat, maxLat, minLon, maxLon);
     }
 
     @Override
     protected boolean cellWithin(final double minLat, final double maxLat, final double minLon, final double maxLon) {
-      return GeoRelationUtils.rectWithinPolyApprox(minLat, maxLat, minLon, maxLon, polygonQuery.polyLats, polygonQuery.polyLons,
-                                                   polygonQuery.minLat, polygonQuery.maxLat, polygonQuery.minLon, polygonQuery.maxLon);
+      return Polygon.contains(polygons, minLat, maxLat, minLon, maxLon);
     }
 
     @Override
@@ -79,25 +81,25 @@ final class GeoPointInPolygonQueryImpl extends GeoPointInBBoxQueryImpl {
      */
     @Override
     protected boolean postFilter(final double lat, final double lon) {
-      return GeoRelationUtils.pointInPolygon(polygonQuery.polyLats, polygonQuery.polyLons, lat, lon);
+      return Polygon.contains(polygons, lat, lon);
     }
   }
 
   @Override
-  public boolean equals(Object o) {
-    if (this == o) return true;
-    if (o == null || getClass() != o.getClass()) return false;
-    if (!super.equals(o)) return false;
-
-    GeoPointInPolygonQueryImpl that = (GeoPointInPolygonQueryImpl) o;
-
-    return !(polygonQuery != null ? !polygonQuery.equals(that.polygonQuery) : that.polygonQuery != null);
-  }
-
-  @Override
   public int hashCode() {
+    final int prime = 31;
     int result = super.hashCode();
-    result = 31 * result + (polygonQuery != null ? polygonQuery.hashCode() : 0);
+    result = prime * result + polygonQuery.hashCode();
     return result;
   }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (this == obj) return true;
+    if (!super.equals(obj)) return false;
+    if (getClass() != obj.getClass()) return false;
+    GeoPointInPolygonQueryImpl other = (GeoPointInPolygonQueryImpl) obj;
+    if (!polygonQuery.equals(other.polygonQuery)) return false;
+    return true;
+  }
 }

http://git-wip-us.apache.org/repos/asf/lucene-solr/blob/2c0a8ed4/lucene/spatial/src/java/org/apache/lucene/spatial/util/GeoRelationUtils.java
----------------------------------------------------------------------
diff --git a/lucene/spatial/src/java/org/apache/lucene/spatial/util/GeoRelationUtils.java b/lucene/spatial/src/java/org/apache/lucene/spatial/util/GeoRelationUtils.java
index ba94e9d..50f9446 100644
--- a/lucene/spatial/src/java/org/apache/lucene/spatial/util/GeoRelationUtils.java
+++ b/lucene/spatial/src/java/org/apache/lucene/spatial/util/GeoRelationUtils.java
@@ -99,180 +99,4 @@ public class GeoRelationUtils {
                                        final double bMinLat, final double bMaxLat, final double bMinLon, final double bMaxLon) {
     return !((aMaxLon < bMinLon || aMinLon > bMaxLon || aMaxLat < bMinLat || aMinLat > bMaxLat));
   }
-
-  /////////////////////////
-  // Polygon relations
-  /////////////////////////
-
-  /**
-   * Convenience method for accurately computing whether a rectangle crosses a poly
-   */
-  public static boolean rectCrossesPolyPrecise(final double rMinLat, final double rMaxLat,
-                                               final double rMinLon, final double rMaxLon,
-                                               final double[] shapeLat, final double[] shapeLon,
-                                               final double sMinLat, final double sMaxLat,
-                                               final double sMinLon, final double sMaxLon) {
-    // short-circuit: if the bounding boxes are disjoint then the shape does not cross
-    if (rectDisjoint(rMinLat, rMaxLat, rMinLon, rMaxLon, sMinLat, sMaxLat, sMinLon, sMaxLon)) {
-      return false;
-    }
-    return rectCrossesPoly(rMinLat, rMaxLat, rMinLon, rMaxLon, shapeLat, shapeLon);
-  }
-
-  /**
-   * Compute whether a rectangle crosses a shape. (touching not allowed) Includes a flag for approximating the
-   * relation.
-   */
-  public static boolean rectCrossesPolyApprox(final double rMinLat, final double rMaxLat,
-                                              final double rMinLon, final double rMaxLon,
-                                              final double[] shapeLat, final double[] shapeLon,
-                                              final double sMinLat, final double sMaxLat,
-                                              final double sMinLon, final double sMaxLon) {
-    // short-circuit: if the bounding boxes are disjoint then the shape does not cross
-    if (rectDisjoint(rMinLat, rMaxLat, rMinLon, rMaxLon, sMinLat, sMaxLat, sMinLon, sMaxLon)) {
-      return false;
-    }
-
-    final int polyLength = shapeLon.length-1;
-    for (short p=0; p<polyLength; ++p) {
-      if (lineCrossesRect(shapeLat[p], shapeLon[p], shapeLat[p+1], shapeLon[p+1], rMinLat, rMaxLat, rMinLon, rMaxLon) == true) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  /**
-   * Accurately compute (within restrictions of cartesian decimal degrees) whether a rectangle crosses a polygon
-   */
-  private static boolean rectCrossesPoly(final double rMinLat, final double rMaxLat,
-                                         final double rMinLon, final double rMaxLon,
-                                         final double[] shapeLats, final double[] shapeLons) {
-    final double[][] bbox = new double[][] { {rMinLon, rMinLat}, {rMaxLon, rMinLat}, {rMaxLon, rMaxLat}, {rMinLon, rMaxLat}, {rMinLon, rMinLat} };
-    final int polyLength = shapeLons.length-1;
-    double d, s, t, a1, b1, c1, a2, b2, c2;
-    double x00, y00, x01, y01, x10, y10, x11, y11;
-
-    // computes the intersection point between each bbox edge and the polygon edge
-    for (short b=0; b<4; ++b) {
-      a1 = bbox[b+1][1]-bbox[b][1];
-      b1 = bbox[b][0]-bbox[b+1][0];
-      c1 = a1*bbox[b+1][0] + b1*bbox[b+1][1];
-      for (int p=0; p<polyLength; ++p) {
-        a2 = shapeLats[p+1]-shapeLats[p];
-        b2 = shapeLons[p]-shapeLons[p+1];
-        // compute determinant
-        d = a1*b2 - a2*b1;
-        if (d != 0) {
-          // lines are not parallel, check intersecting points
-          c2 = a2*shapeLons[p+1] + b2*shapeLats[p+1];
-          s = (1/d)*(b2*c1 - b1*c2);
-          t = (1/d)*(a1*c2 - a2*c1);
-          x00 = StrictMath.min(bbox[b][0], bbox[b+1][0]) - GeoEncodingUtils.TOLERANCE;
-          x01 = StrictMath.max(bbox[b][0], bbox[b+1][0]) + GeoEncodingUtils.TOLERANCE;
-          y00 = StrictMath.min(bbox[b][1], bbox[b+1][1]) - GeoEncodingUtils.TOLERANCE;
-          y01 = StrictMath.max(bbox[b][1], bbox[b+1][1]) + GeoEncodingUtils.TOLERANCE;
-          x10 = StrictMath.min(shapeLons[p], shapeLons[p+1]) - GeoEncodingUtils.TOLERANCE;
-          x11 = StrictMath.max(shapeLons[p], shapeLons[p+1]) + GeoEncodingUtils.TOLERANCE;
-          y10 = StrictMath.min(shapeLats[p], shapeLats[p+1]) - GeoEncodingUtils.TOLERANCE;
-          y11 = StrictMath.max(shapeLats[p], shapeLats[p+1]) + GeoEncodingUtils.TOLERANCE;
-          // check whether the intersection point is touching one of the line segments
-          boolean touching = ((x00 == s && y00 == t) || (x01 == s && y01 == t))
-              || ((x10 == s && y10 == t) || (x11 == s && y11 == t));
-          // if line segments are not touching and the intersection point is within the range of either segment
-          if (!(touching || x00 > s || x01 < s || y00 > t || y01 < t || x10 > s || x11 < s || y10 > t || y11 < t)) {
-            return true;
-          }
-        }
-      } // for each poly edge
-    } // for each bbox edge
-    return false;
-  }
-
-  private static boolean lineCrossesRect(double aLat1, double aLon1,
-                                         double aLat2, double aLon2,
-                                         final double rMinLat, final double rMaxLat,
-                                         final double rMinLon, final double rMaxLon) {
-    // short-circuit: if one point inside rect, other outside
-    if (pointInRectPrecise(aLat1, aLon1, rMinLat, rMaxLat, rMinLon, rMaxLon)) {
-      if (pointInRectPrecise(aLat2, aLon2, rMinLat, rMaxLat, rMinLon, rMaxLon) == false) {
-        return true;
-      }
-    } else if (pointInRectPrecise(aLat2, aLon2, rMinLat, rMaxLat, rMinLon, rMaxLon)) {
-      return true;
-    }
-
-    return lineCrossesLine(aLat1, aLon1, aLat2, aLon2, rMinLat, rMinLon, rMaxLat, rMaxLon)
-        || lineCrossesLine(aLat1, aLon1, aLat2, aLon2, rMaxLat, rMinLon, rMinLat, rMaxLon);
-  }
-
-  private static boolean lineCrossesLine(final double aLat1, final double aLon1, final double aLat2, final double aLon2,
-                                         final double bLat1, final double bLon1, final double bLat2, final double bLon2) {
-    // determine if three points are ccw (right-hand rule) by computing the determinate
-    final double aX2X1d = aLon2 - aLon1;
-    final double aY2Y1d = aLat2 - aLat1;
-    final double bX2X1d = bLon2 - bLon1;
-    final double bY2Y1d = bLat2 - bLat1;
-
-    final double t1B = aX2X1d * (bLat2 - aLat1) - aY2Y1d * (bLon2 - aLon1);
-    final double test1 = (aX2X1d * (bLat1 - aLat1) - aY2Y1d * (bLon1 - aLon1)) * t1B;
-    final double t2B = bX2X1d * (aLat2 - bLat1) - bY2Y1d * (aLon2 - bLon1);
-    final double test2 = (bX2X1d * (aLat1 - bLat1) - bY2Y1d * (aLon1 - bLon1)) * t2B;
-
-    if (test1 < 0 && test2 < 0) {
-      return true;
-    }
-
-    if (test1 == 0 || test2 == 0) {
-      // vertically collinear
-      if (aLon1 == aLon2 || bLon1 == bLon2) {
-        final double minAy = Math.min(aLat1, aLat2);
-        final double maxAy = Math.max(aLat1, aLat2);
-        final double minBy = Math.min(bLat1, bLat2);
-        final double maxBy = Math.max(bLat1, bLat2);
-
-        return !(minBy >= maxAy || maxBy <= minAy);
-      }
-      // horizontally collinear
-      final double minAx = Math.min(aLon1, aLon2);
-      final double maxAx = Math.max(aLon1, aLon2);
-      final double minBx = Math.min(bLon1, bLon2);
-      final double maxBx = Math.max(bLon1, bLon2);
-
-      return !(minBx >= maxAx || maxBx <= minAx);
-    }
-    return false;
-  }
-
-  /**
-   * Computes whether a rectangle is within a polygon (shared boundaries not allowed) with more rigor than the
-   * {@link GeoRelationUtils#rectWithinPolyApprox} counterpart
-   */
-  public static boolean rectWithinPolyPrecise(final double rMinLat, final double rMaxLat, final double rMinLon, final double rMaxLon,
-                                              final double[] shapeLats, final double[] shapeLons, final double sMinLat,
-                                              final double sMaxLat, final double sMinLon, final double sMaxLon) {
-    // check if rectangle crosses poly (to handle concave/pacman polys), then check that all 4 corners
-    // are contained
-    return !(rectCrossesPolyPrecise(rMinLat, rMaxLat, rMinLon, rMaxLon, shapeLats, shapeLons, sMinLat, sMaxLat, sMinLon, sMaxLon) ||
-        !pointInPolygon(shapeLats, shapeLons, rMinLat, rMinLon) || !pointInPolygon(shapeLats, shapeLons, rMinLat, rMaxLon) ||
-        !pointInPolygon(shapeLats, shapeLons, rMaxLat, rMaxLon) || !pointInPolygon(shapeLats, shapeLons, rMaxLat, rMinLon));
-  }
-
-  /**
-   * Computes whether a rectangle is within a given polygon (shared boundaries allowed)
-   */
-  public static boolean rectWithinPolyApprox(final double rMinLat, final double rMaxLat, final double rMinLon, final double rMaxLon,
-                                             final double[] shapeLats, final double[] shapeLons, final double sMinLat,
-                                             final double sMaxLat, final double sMinLon, final double sMaxLon) {
-    // approximation: check if rectangle crosses poly (to handle concave/pacman polys), then check one of the corners
-    // are contained
-
-    // short-cut: if bounding boxes cross, rect is not within
-    if (rectCrosses(rMinLat, rMaxLat, rMinLon, rMaxLon, sMinLat, sMaxLat, sMinLon, sMaxLon) == true) {
-       return false;
-     }
-
-     return !(rectCrossesPolyApprox(rMinLat, rMaxLat, rMinLon, rMaxLon, shapeLats, shapeLons, sMinLat, sMaxLat, sMinLon, sMaxLon)
-         || !pointInPolygon(shapeLats, shapeLons, rMinLat, rMinLon));
-  }
 }

http://git-wip-us.apache.org/repos/asf/lucene-solr/blob/2c0a8ed4/lucene/spatial/src/java/org/apache/lucene/spatial/util/GeoUtils.java
----------------------------------------------------------------------
diff --git a/lucene/spatial/src/java/org/apache/lucene/spatial/util/GeoUtils.java b/lucene/spatial/src/java/org/apache/lucene/spatial/util/GeoUtils.java
index 52e9405..6d7f615 100644
--- a/lucene/spatial/src/java/org/apache/lucene/spatial/util/GeoUtils.java
+++ b/lucene/spatial/src/java/org/apache/lucene/spatial/util/GeoUtils.java
@@ -74,37 +74,6 @@ public final class GeoUtils {
     }
   }
   
-  /** validates polygon values are within standard +/-180 coordinate bounds, same
-   *  number of latitude and longitude, and is closed
-   */
-  public static void checkPolygon(double[] polyLats, double[] polyLons) {
-    if (polyLats == null) {
-      throw new IllegalArgumentException("polyLats must not be null");
-    }
-    if (polyLons == null) {
-      throw new IllegalArgumentException("polyLons must not be null");
-    }
-    if (polyLats.length != polyLons.length) {
-      throw new IllegalArgumentException("polyLats and polyLons must be equal length");
-    }
-    if (polyLats.length != polyLons.length) {
-      throw new IllegalArgumentException("polyLats and polyLons must be equal length");
-    }
-    if (polyLats.length < 4) {
-      throw new IllegalArgumentException("at least 4 polygon points required");
-    }
-    if (polyLats[0] != polyLats[polyLats.length-1]) {
-      throw new IllegalArgumentException("first and last points of the polygon must be the same (it must close itself): polyLats[0]=" + polyLats[0] + " polyLats[" + (polyLats.length-1) + "]=" + polyLats[polyLats.length-1]);
-    }
-    if (polyLons[0] != polyLons[polyLons.length-1]) {
-      throw new IllegalArgumentException("first and last points of the polygon must be the same (it must close itself): polyLons[0]=" + polyLons[0] + " polyLons[" + (polyLons.length-1) + "]=" + polyLons[polyLons.length-1]);
-    }
-    for (int i = 0; i < polyLats.length; i++) {
-      checkLatitude(polyLats[i]);
-      checkLongitude(polyLons[i]);
-    }
-  }
-
   /** Compute Bounding Box for a circle using WGS-84 parameters */
   public static GeoRect circleToBBox(final double centerLat, final double centerLon, final double radiusMeters) {
     final double radLat = TO_RADIANS * centerLat;
@@ -136,24 +105,6 @@ public final class GeoUtils {
 
     return new GeoRect(TO_DEGREES * minLat, TO_DEGREES * maxLat, TO_DEGREES * minLon, TO_DEGREES * maxLon);
   }
-
-  /** Compute Bounding Box for a polygon using WGS-84 parameters */
-  public static GeoRect polyToBBox(double[] polyLats, double[] polyLons) {
-    checkPolygon(polyLats, polyLons);
-
-    double minLon = Double.POSITIVE_INFINITY;
-    double maxLon = Double.NEGATIVE_INFINITY;
-    double minLat = Double.POSITIVE_INFINITY;
-    double maxLat = Double.NEGATIVE_INFINITY;
-
-    for (int i=0;i<polyLats.length;i++) {
-      minLat = min(polyLats[i], minLat);
-      maxLat = max(polyLats[i], maxLat);
-      minLon = min(polyLons[i], minLon);
-      maxLon = max(polyLons[i], maxLon);
-    }
-    return new GeoRect(minLat, maxLat, minLon, maxLon);
-  }
   
   // some sloppyish stuff, do we really need this to be done in a sloppy way?
   // unless it is performance sensitive, we should try to remove.

http://git-wip-us.apache.org/repos/asf/lucene-solr/blob/2c0a8ed4/lucene/spatial/src/java/org/apache/lucene/spatial/util/Polygon.java
----------------------------------------------------------------------
diff --git a/lucene/spatial/src/java/org/apache/lucene/spatial/util/Polygon.java b/lucene/spatial/src/java/org/apache/lucene/spatial/util/Polygon.java
new file mode 100644
index 0000000..5dca23d
--- /dev/null
+++ b/lucene/spatial/src/java/org/apache/lucene/spatial/util/Polygon.java
@@ -0,0 +1,330 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.lucene.spatial.util;
+
+import java.util.Arrays;
+
+/** 
+ * Represents a closed polygon on the earth's surface.
+ * @lucene.experimental 
+ */
+public final class Polygon {
+  private final double[] polyLats;
+  private final double[] polyLons;
+  private final Polygon[] holes;
+  
+  /** minimum latitude of this polygon's bounding box area */
+  public final double minLat;
+  /** maximum latitude of this polygon's bounding box area */
+  public final double maxLat;
+  /** minimum longitude of this polygon's bounding box area */
+  public final double minLon;
+  /** maximum longitude of this polygon's bounding box area */
+  public final double maxLon;
+  
+  // TODO: we could also compute the maximal inner bounding box, to make relations faster to compute?
+  
+  /** 
+   * Creates a new Polygon from the supplied latitude/longitude array, and optionally any holes.
+   */
+  public Polygon(double[] polyLats, double[] polyLons, Polygon... holes) {
+    if (polyLats == null) {
+      throw new IllegalArgumentException("polyLats must not be null");
+    }
+    if (polyLons == null) {
+      throw new IllegalArgumentException("polyLons must not be null");
+    }
+    if (holes == null) {
+      throw new IllegalArgumentException("holes must not be null");
+    }
+    if (polyLats.length != polyLons.length) {
+      throw new IllegalArgumentException("polyLats and polyLons must be equal length");
+    }
+    if (polyLats.length != polyLons.length) {
+      throw new IllegalArgumentException("polyLats and polyLons must be equal length");
+    }
+    if (polyLats.length < 4) {
+      throw new IllegalArgumentException("at least 4 polygon points required");
+    }
+    if (polyLats[0] != polyLats[polyLats.length-1]) {
+      throw new IllegalArgumentException("first and last points of the polygon must be the same (it must close itself): polyLats[0]=" + polyLats[0] + " polyLats[" + (polyLats.length-1) + "]=" + polyLats[polyLats.length-1]);
+    }
+    if (polyLons[0] != polyLons[polyLons.length-1]) {
+      throw new IllegalArgumentException("first and last points of the polygon must be the same (it must close itself): polyLons[0]=" + polyLons[0] + " polyLons[" + (polyLons.length-1) + "]=" + polyLons[polyLons.length-1]);
+    }
+    for (int i = 0; i < polyLats.length; i++) {
+      GeoUtils.checkLatitude(polyLats[i]);
+      GeoUtils.checkLongitude(polyLons[i]);
+    }
+    for (int i = 0; i < holes.length; i++) {
+      Polygon inner = holes[i];
+      if (inner.holes.length > 0) {
+        throw new IllegalArgumentException("holes may not contain holes: polygons may not nest.");
+      }
+    }
+    this.polyLats = polyLats.clone();
+    this.polyLons = polyLons.clone();
+    this.holes = holes.clone();
+
+    // compute bounding box
+    double minLat = Double.POSITIVE_INFINITY;
+    double maxLat = Double.NEGATIVE_INFINITY;
+    double minLon = Double.POSITIVE_INFINITY;
+    double maxLon = Double.NEGATIVE_INFINITY;
+
+    for (int i = 0;i < polyLats.length; i++) {
+      minLat = Math.min(polyLats[i], minLat);
+      maxLat = Math.max(polyLats[i], maxLat);
+      minLon = Math.min(polyLons[i], minLon);
+      maxLon = Math.max(polyLons[i], maxLon);
+    }
+    this.minLat = minLat;
+    this.maxLat = maxLat;
+    this.minLon = minLon;
+    this.maxLon = maxLon;
+  }
+  
+  /** Returns true if the point is contained within this polygon */
+  public boolean contains(double latitude, double longitude) {
+    // check bounding box
+    if (latitude < minLat || latitude > maxLat || longitude < minLon || longitude > maxLon) {
+      return false;
+    }
+    /* 
+     * simple even-odd point in polygon computation
+     *    1.  Determine if point is contained in the longitudinal range
+     *    2.  Determine whether point crosses the edge by computing the latitudinal delta
+     *        between the end-point of a parallel vector (originating at the point) and the
+     *        y-component of the edge sink
+     *
+     * NOTE: Requires polygon point (x,y) order either clockwise or counter-clockwise
+     */
+    boolean inPoly = false;
+    /*
+     * Note: This is using a euclidean coordinate system which could result in
+     * upwards of 110KM error at the equator.
+     * TODO convert coordinates to cylindrical projection (e.g. mercator)
+     */
+    for (int i = 1; i < polyLats.length; i++) {
+      if (polyLons[i] <= longitude && polyLons[i-1] >= longitude || polyLons[i-1] <= longitude && polyLons[i] >= longitude) {
+        if (polyLats[i] + (longitude - polyLons[i]) / (polyLons[i-1] - polyLons[i]) * (polyLats[i-1] - polyLats[i]) <= latitude) {
+          inPoly = !inPoly;
+        }
+      }
+    }
+    if (inPoly) {
+      for (Polygon hole : holes) {
+        if (hole.contains(latitude, longitude)) {
+          return false;
+        }
+      }
+      return true;
+    } else {
+      return false;
+    }
+  }
+  
+  /**
+   * Computes whether a rectangle is within a polygon (shared boundaries not allowed)
+   */
+  public boolean contains(double minLat, double maxLat, double minLon, double maxLon) {
+    // check if rectangle crosses poly (to handle concave/pacman polys), then check that all 4 corners
+    // are contained
+    boolean contains = crosses(minLat, maxLat, minLon, maxLon) == false &&
+                       contains(minLat, minLon) &&
+                       contains(minLat, maxLon) &&
+                       contains(maxLat, maxLon) && 
+                       contains(maxLat, minLon);
+    
+    if (contains) {
+      // if we intersect with any hole, game over
+      for (Polygon hole : holes) {
+        if (hole.crosses(minLat, maxLat, minLon, maxLon) || hole.contains(minLat, maxLat, minLon, maxLon)) {
+          return false;
+        }
+      }
+      return true;
+    } else {
+      return false;
+    }
+  }
+  
+  /**
+   * Convenience method for accurately computing whether a rectangle crosses a poly.
+   */
+  public boolean crosses(double minLat, double maxLat, final double minLon, final double maxLon) {
+    // if the bounding boxes are disjoint then the shape does not cross
+    if (maxLon < this.minLon || minLon > this.maxLon || maxLat < this.minLat || minLat > this.maxLat) {
+      return false;
+    }
+    // if the rectangle fully encloses us, we cross.
+    if (minLat <= this.minLat && maxLat >= this.maxLat && minLon <= this.minLon && maxLon >= this.maxLon) {
+      return true;
+    }
+    // if we cross any hole, we cross
+    for (Polygon hole : holes) {
+      if (hole.crosses(minLat, maxLat, minLon, maxLon)) {
+        return true;
+      }
+    }
+
+    /*
+     * Accurately compute (within restrictions of cartesian decimal degrees) whether a rectangle crosses a polygon
+     */
+    final double[][] bbox = new double[][] { {minLon, minLat}, {maxLon, minLat}, {maxLon, maxLat}, {minLon, maxLat}, {minLon, minLat} };
+    final int polyLength = polyLons.length-1;
+    double d, s, t, a1, b1, c1, a2, b2, c2;
+    double x00, y00, x01, y01, x10, y10, x11, y11;
+
+    // computes the intersection point between each bbox edge and the polygon edge
+    for (short b=0; b<4; ++b) {
+      a1 = bbox[b+1][1]-bbox[b][1];
+      b1 = bbox[b][0]-bbox[b+1][0];
+      c1 = a1*bbox[b+1][0] + b1*bbox[b+1][1];
+      for (int p=0; p<polyLength; ++p) {
+        a2 = polyLats[p+1]-polyLats[p];
+        b2 = polyLons[p]-polyLons[p+1];
+        // compute determinant
+        d = a1*b2 - a2*b1;
+        if (d != 0) {
+          // lines are not parallel, check intersecting points
+          c2 = a2*polyLons[p+1] + b2*polyLats[p+1];
+          s = (1/d)*(b2*c1 - b1*c2);
+          t = (1/d)*(a1*c2 - a2*c1);
+          x00 = Math.min(bbox[b][0], bbox[b+1][0]) - GeoEncodingUtils.TOLERANCE;
+          x01 = Math.max(bbox[b][0], bbox[b+1][0]) + GeoEncodingUtils.TOLERANCE;
+          y00 = Math.min(bbox[b][1], bbox[b+1][1]) - GeoEncodingUtils.TOLERANCE;
+          y01 = Math.max(bbox[b][1], bbox[b+1][1]) + GeoEncodingUtils.TOLERANCE;
+          x10 = Math.min(polyLons[p], polyLons[p+1]) - GeoEncodingUtils.TOLERANCE;
+          x11 = Math.max(polyLons[p], polyLons[p+1]) + GeoEncodingUtils.TOLERANCE;
+          y10 = Math.min(polyLats[p], polyLats[p+1]) - GeoEncodingUtils.TOLERANCE;
+          y11 = Math.max(polyLats[p], polyLats[p+1]) + GeoEncodingUtils.TOLERANCE;
+          // check whether the intersection point is touching one of the line segments
+          boolean touching = ((x00 == s && y00 == t) || (x01 == s && y01 == t))
+              || ((x10 == s && y10 == t) || (x11 == s && y11 == t));
+          // if line segments are not touching and the intersection point is within the range of either segment
+          if (!(touching || x00 > s || x01 < s || y00 > t || y01 < t || x10 > s || x11 < s || y10 > t || y11 < t)) {
+            return true;
+          }
+        }
+      } // for each poly edge
+    } // for each bbox edge
+    return false;
+  }
+  
+  /** Returns a copy of the internal latitude array */
+  public double[] getPolyLats() {
+    return polyLats.clone();
+  }
+  
+  /** Returns a copy of the internal longitude array */
+  public double[] getPolyLons() {
+    return polyLons.clone();
+  }
+  
+  /** Returns a copy of the internal holes array */
+  public Polygon[] getHoles() {
+    return holes.clone();
+  }
+  
+  /** Returns the bounding box over an array of polygons */
+  public static GeoRect getBoundingBox(Polygon[] polygons) {
+    // compute bounding box
+    double minLat = Double.POSITIVE_INFINITY;
+    double maxLat = Double.NEGATIVE_INFINITY;
+    double minLon = Double.POSITIVE_INFINITY;
+    double maxLon = Double.NEGATIVE_INFINITY;
+
+    for (int i = 0;i < polygons.length; i++) {
+      minLat = Math.min(polygons[i].minLat, minLat);
+      maxLat = Math.max(polygons[i].maxLat, maxLat);
+      minLon = Math.min(polygons[i].minLon, minLon);
+      maxLon = Math.max(polygons[i].maxLon, maxLon);
+    }
+    
+    return new GeoRect(minLat, maxLat, minLon, maxLon);
+  }
+  
+  /** Helper for multipolygon logic: returns true if any of the supplied polygons contain the point */
+  public static boolean contains(Polygon[] polygons, double latitude, double longitude) {
+    for (Polygon polygon : polygons) {
+      if (polygon.contains(latitude, longitude)) {
+        return true;
+      }
+    }
+    return false;
+  }
+  
+  /** Helper for multipolygon logic: returns true if any of the supplied polygons contain the rectangle */
+  public static boolean contains(Polygon[] polygons, double minLat, double maxLat, double minLon, double maxLon) {
+    for (Polygon polygon : polygons) {
+      if (polygon.contains(minLat, maxLat, minLon, maxLon)) {
+        return true;
+      }
+    }
+    return false;
+  }
+  
+  /** Helper for multipolygon logic: returns true if any of the supplied polygons crosses the rectangle */
+  public static boolean crosses(Polygon[] polygons, double minLat, double maxLat, double minLon, double maxLon) {
+    for (Polygon polygon : polygons) {
+      if (polygon.crosses(minLat, maxLat, minLon, maxLon)) {
+        return true;
+      }
+    }
+    return false;
+  }
+  
+  @Override
+  public int hashCode() {
+    final int prime = 31;
+    int result = 1;
+    result = prime * result + Arrays.hashCode(holes);
+    result = prime * result + Arrays.hashCode(polyLats);
+    result = prime * result + Arrays.hashCode(polyLons);
+    return result;
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (this == obj) return true;
+    if (obj == null) return false;
+    if (getClass() != obj.getClass()) return false;
+    Polygon other = (Polygon) obj;
+    if (!Arrays.equals(holes, other.holes)) return false;
+    if (!Arrays.equals(polyLats, other.polyLats)) return false;
+    if (!Arrays.equals(polyLons, other.polyLons)) return false;
+    return true;
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder sb = new StringBuilder();
+    for (int i = 0; i < polyLats.length; i++) {
+      sb.append("[")
+      .append(polyLats[i])
+      .append(", ")
+      .append(polyLons[i])
+      .append("] ");
+    }
+    if (holes.length > 0) {
+      sb.append(", holes=");
+      sb.append(Arrays.toString(holes));
+    }
+    return sb.toString();
+  }
+}

http://git-wip-us.apache.org/repos/asf/lucene-solr/blob/2c0a8ed4/lucene/spatial/src/test/org/apache/lucene/spatial/geopoint/search/TestGeoPointQuery.java
----------------------------------------------------------------------
diff --git a/lucene/spatial/src/test/org/apache/lucene/spatial/geopoint/search/TestGeoPointQuery.java b/lucene/spatial/src/test/org/apache/lucene/spatial/geopoint/search/TestGeoPointQuery.java
index 0f0eaeb..91afe3f 100644
--- a/lucene/spatial/src/test/org/apache/lucene/spatial/geopoint/search/TestGeoPointQuery.java
+++ b/lucene/spatial/src/test/org/apache/lucene/spatial/geopoint/search/TestGeoPointQuery.java
@@ -19,6 +19,7 @@ package org.apache.lucene.spatial.geopoint.search;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.search.Query;
 import org.apache.lucene.spatial.util.GeoEncodingUtils;
+import org.apache.lucene.spatial.util.Polygon;
 import org.apache.lucene.spatial.geopoint.document.GeoPointField;
 import org.apache.lucene.spatial.geopoint.document.GeoPointField.TermEncoding;
 import org.apache.lucene.spatial.util.BaseGeoPointTestCase;
@@ -56,8 +57,8 @@ public class TestGeoPointQuery extends BaseGeoPointTestCase {
   }
 
   @Override
-  protected Query newPolygonQuery(String field, double[] lats, double[] lons) {
-    return new GeoPointInPolygonQuery(field, TermEncoding.PREFIX, lats, lons);
+  protected Query newPolygonQuery(String field, Polygon... polygons) {
+    return new GeoPointInPolygonQuery(field, TermEncoding.PREFIX, polygons);
   }
 
 }

http://git-wip-us.apache.org/repos/asf/lucene-solr/blob/2c0a8ed4/lucene/spatial/src/test/org/apache/lucene/spatial/geopoint/search/TestLegacyGeoPointQuery.java
----------------------------------------------------------------------
diff --git a/lucene/spatial/src/test/org/apache/lucene/spatial/geopoint/search/TestLegacyGeoPointQuery.java b/lucene/spatial/src/test/org/apache/lucene/spatial/geopoint/search/TestLegacyGeoPointQuery.java
index 00cc279..cd15f66 100644
--- a/lucene/spatial/src/test/org/apache/lucene/spatial/geopoint/search/TestLegacyGeoPointQuery.java
+++ b/lucene/spatial/src/test/org/apache/lucene/spatial/geopoint/search/TestLegacyGeoPointQuery.java
@@ -19,6 +19,7 @@ package org.apache.lucene.spatial.geopoint.search;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.search.Query;
 import org.apache.lucene.spatial.util.GeoEncodingUtils;
+import org.apache.lucene.spatial.util.Polygon;
 import org.apache.lucene.spatial.geopoint.document.GeoPointField;
 import org.apache.lucene.spatial.geopoint.document.GeoPointField.TermEncoding;
 import org.apache.lucene.spatial.util.BaseGeoPointTestCase;
@@ -56,8 +57,8 @@ public class TestLegacyGeoPointQuery extends BaseGeoPointTestCase {
   }
 
   @Override
-  protected Query newPolygonQuery(String field, double[] lats, double[] lons) {
-    return new GeoPointInPolygonQuery(field, TermEncoding.NUMERIC, lats, lons);
+  protected Query newPolygonQuery(String field, Polygon... polygons) {
+    return new GeoPointInPolygonQuery(field, TermEncoding.NUMERIC, polygons);
   }
 
   // legacy encoding is too slow somehow for this random test, its not up to the task.
@@ -75,4 +76,6 @@ public class TestLegacyGeoPointQuery extends BaseGeoPointTestCase {
   public void testSamePointManyTimes() throws Exception {
     assumeTrue("legacy encoding goes OOM on this test", false);
   }
+  
+  
 }

http://git-wip-us.apache.org/repos/asf/lucene-solr/blob/2c0a8ed4/lucene/spatial/src/test/org/apache/lucene/spatial/util/BaseGeoPointTestCase.java
----------------------------------------------------------------------
diff --git a/lucene/spatial/src/test/org/apache/lucene/spatial/util/BaseGeoPointTestCase.java b/lucene/spatial/src/test/org/apache/lucene/spatial/util/BaseGeoPointTestCase.java
index dd0e09b..1b18a18 100644
--- a/lucene/spatial/src/test/org/apache/lucene/spatial/util/BaseGeoPointTestCase.java
+++ b/lucene/spatial/src/test/org/apache/lucene/spatial/util/BaseGeoPointTestCase.java
@@ -285,73 +285,95 @@ public abstract class BaseGeoPointTestCase extends LuceneTestCase {
     // search and verify we found our doc
     IndexReader reader = writer.getReader();
     IndexSearcher searcher = newSearcher(reader);
-    assertEquals(1, searcher.count(newPolygonQuery("field",
+    assertEquals(1, searcher.count(newPolygonQuery("field", new Polygon(
                                                    new double[] { 18, 18, 19, 19, 18 },
-                                                   new double[] { -66, -65, -65, -66, -66 })));
+                                                   new double[] { -66, -65, -65, -66, -66 }))));
 
     reader.close();
     writer.close();
     dir.close();
   }
   
-  /** null field name not allowed */
-  public void testPolygonNullField() {
-    IllegalArgumentException expected = expectThrows(IllegalArgumentException.class, () -> {
-      newPolygonQuery(null, 
-          new double[] { 18, 18, 19, 19, 18 },
-          new double[] { -66, -65, -65, -66, -66 });
-    });
-    assertTrue(expected.getMessage().contains("field must not be null"));
-  }
-  
-  /** null polyLats not allowed */
-  public void testPolygonNullPolyLats() {
-    IllegalArgumentException expected = expectThrows(IllegalArgumentException.class, () -> {
-      newPolygonQuery("test", 
-          null,
-          new double[] { -66, -65, -65, -66, -66 });
-    });
-    assertTrue(expected.getMessage().contains("polyLats must not be null"));
-  }
-  
-  /** null polyLons not allowed */
-  public void testPolygonNullPolyLons() {
-    IllegalArgumentException expected = expectThrows(IllegalArgumentException.class, () -> {
-      newPolygonQuery("test", 
-          new double[] { 18, 18, 19, 19, 18 },
-          null);
-    });
-    assertTrue(expected.getMessage().contains("polyLons must not be null"));
+  /** test we can search for a polygon with a hole (but still includes the doc) */
+  public void testPolygonHole() throws Exception {
+    Directory dir = newDirectory();
+    RandomIndexWriter writer = new RandomIndexWriter(random(), dir);
+
+    // add a doc with a point
+    Document document = new Document();
+    addPointToDoc("field", document, 18.313694, -65.227444);
+    writer.addDocument(document);
+    
+    // search and verify we found our doc
+    IndexReader reader = writer.getReader();
+    IndexSearcher searcher = newSearcher(reader);
+    Polygon inner = new Polygon(new double[] { 18.5, 18.5, 18.7, 18.7, 18.5 },
+                                new double[] { -65.7, -65.4, -65.4, -65.7, -65.7 });
+    Polygon outer = new Polygon(new double[] { 18, 18, 19, 19, 18 },
+                                new double[] { -66, -65, -65, -66, -66 }, inner);
+    assertEquals(1, searcher.count(newPolygonQuery("field", outer)));
+
+    reader.close();
+    writer.close();
+    dir.close();
   }
   
-  /** polygon needs at least 3 vertices */
-  public void testPolygonLine() {
-    IllegalArgumentException expected = expectThrows(IllegalArgumentException.class, () -> {
-      newPolygonQuery("test", 
-          new double[] { 18, 18, 18  },
-          new double[] { -66, -65, -66 });
-    });
-    assertTrue(expected.getMessage().contains("at least 4 polygon points required"));
+  /** test we can search for a polygon with a hole (that excludes the doc) */
+  public void testPolygonHoleExcludes() throws Exception {
+    Directory dir = newDirectory();
+    RandomIndexWriter writer = new RandomIndexWriter(random(), dir);
+
+    // add a doc with a point
+    Document document = new Document();
+    addPointToDoc("field", document, 18.313694, -65.227444);
+    writer.addDocument(document);
+    
+    // search and verify we found our doc
+    IndexReader reader = writer.getReader();
+    IndexSearcher searcher = newSearcher(reader);
+    Polygon inner = new Polygon(new double[] { 18.2, 18.2, 18.4, 18.4, 18.2 },
+                                new double[] { -65.3, -65.2, -65.2, -65.3, -65.3 });
+    Polygon outer = new Polygon(new double[] { 18, 18, 19, 19, 18 },
+                                new double[] { -66, -65, -65, -66, -66 }, inner);
+    assertEquals(0, searcher.count(newPolygonQuery("field", outer)));
+
+    reader.close();
+    writer.close();
+    dir.close();
   }
   
-  /** polygon needs same number of latitudes as longitudes */
-  public void testPolygonBogus() {
-    IllegalArgumentException expected = expectThrows(IllegalArgumentException.class, () -> {
-      newPolygonQuery("test", 
-          new double[] { 18, 18, 19, 19 },
-          new double[] { -66, -65, -65, -66, -66 });
-    });
-    assertTrue(expected.getMessage().contains("must be equal length"));
+  /** test we can search for a multi-polygon */
+  public void testMultiPolygonBasics() throws Exception {
+    Directory dir = newDirectory();
+    RandomIndexWriter writer = new RandomIndexWriter(random(), dir);
+
+    // add a doc with a point
+    Document document = new Document();
+    addPointToDoc("field", document, 18.313694, -65.227444);
+    writer.addDocument(document);
+    
+    // search and verify we found our doc
+    IndexReader reader = writer.getReader();
+    IndexSearcher searcher = newSearcher(reader);
+    Polygon a = new Polygon(new double[] { 28, 28, 29, 29, 28 },
+                           new double[] { -56, -55, -55, -56, -56 });
+    Polygon b = new Polygon(new double[] { 18, 18, 19, 19, 18 },
+                            new double[] { -66, -65, -65, -66, -66 });
+    assertEquals(1, searcher.count(newPolygonQuery("field", a, b)));
+
+    reader.close();
+    writer.close();
+    dir.close();
   }
   
-  /** polygon must be closed */
-  public void testPolygonNotClosed() {
+  /** null field name not allowed */
+  public void testPolygonNullField() {
     IllegalArgumentException expected = expectThrows(IllegalArgumentException.class, () -> {
-      newPolygonQuery("test", 
-          new double[] { 18, 18, 19, 19, 19 },
-          new double[] { -66, -65, -65, -66, -67 });
+      newPolygonQuery(null, new Polygon(
+          new double[] { 18, 18, 19, 19, 18 },
+          new double[] { -66, -65, -65, -66, -66 }));
     });
-    assertTrue(expected.getMessage(), expected.getMessage().contains("it must close itself"));
+    assertTrue(expected.getMessage().contains("field must not be null"));
   }
 
   // A particularly tricky adversary for BKD tree:
@@ -710,7 +732,7 @@ public abstract class BaseGeoPointTestCase extends LuceneTestCase {
 
   protected abstract Query newDistanceQuery(String field, double centerLat, double centerLon, double radiusMeters);
 
-  protected abstract Query newPolygonQuery(String field, double[] lats, double[] lons);
+  protected abstract Query newPolygonQuery(String field, Polygon... polygon);
 
   static final boolean rectContainsPoint(GeoRect rect, double pointLat, double pointLon) {
     assert Double.isNaN(pointLat) == false;
@@ -1072,16 +1094,14 @@ public abstract class BaseGeoPointTestCase extends LuceneTestCase {
       }
 
       // Polygon
-      final double[][] polygon;
+      final Polygon polygon;
       if (small) {
         polygon = GeoTestUtil.nextPolygonNear(originLat, originLon);
       } else {
         polygon = GeoTestUtil.nextPolygon();
       }
       
-      final double[] polyLats = polygon[0];
-      final double[] polyLons = polygon[1];
-      Query query = newPolygonQuery(FIELD_NAME, polyLats, polyLons);
+      Query query = newPolygonQuery(FIELD_NAME, polygon);
 
       if (VERBOSE) {
         System.out.println("  query=" + query);
@@ -1118,7 +1138,7 @@ public abstract class BaseGeoPointTestCase extends LuceneTestCase {
         } else if (Double.isNaN(lats[id])) {
           expected = false;
         } else {
-          expected = GeoRelationUtils.pointInPolygon(polyLats, polyLons, lats[id], lons[id]);
+          expected = polygon.contains(lats[id], lons[id]);
         }
 
         if (hits.get(docID) != expected) {
@@ -1132,8 +1152,7 @@ public abstract class BaseGeoPointTestCase extends LuceneTestCase {
           b.append("  query=" + query + " docID=" + docID + "\n");
           b.append("  lat=" + lats[id] + " lon=" + lons[id] + "\n");
           b.append("  deleted?=" + (liveDocs != null && liveDocs.get(docID) == false));
-          b.append("  polyLats=" + Arrays.toString(polyLats));
-          b.append("  polyLons=" + Arrays.toString(polyLons));
+          b.append("  polygon=" + polygon);
           if (true) {
             fail("wrong hit (first of possibly more):\n\n" + b);
           } else {
@@ -1317,10 +1336,10 @@ public abstract class BaseGeoPointTestCase extends LuceneTestCase {
     lons[3] = rect.maxLon;
     lats[4] = rect.minLat;
     lons[4] = rect.minLon;
-    q1 = newPolygonQuery("field", lats, lons);
-    q2 = newPolygonQuery("field", lats, lons);
+    q1 = newPolygonQuery("field", new Polygon(lats, lons));
+    q2 = newPolygonQuery("field", new Polygon(lats, lons));
     assertEquals(q1, q2);
-    assertFalse(q1.equals(newPolygonQuery("field2", lats, lons)));
+    assertFalse(q1.equals(newPolygonQuery("field2", new Polygon(lats, lons))));
   }
   
   /** return topdocs over a small set of points in field "point" */
@@ -1406,18 +1425,20 @@ public abstract class BaseGeoPointTestCase extends LuceneTestCase {
   
   public void testSmallSetPoly() throws Exception {
     TopDocs td = searchSmallSet(newPolygonQuery("point",
+        new Polygon(
         new double[]{33.073130, 32.9942669, 32.938386, 33.0374494,
             33.1369762, 33.1162747, 33.073130, 33.073130},
         new double[]{-96.7682647, -96.8280029, -96.6288757, -96.4929199,
-                     -96.6041564, -96.7449188, -96.76826477, -96.7682647}),
+                     -96.6041564, -96.7449188, -96.76826477, -96.7682647})),
         5);
     assertEquals(2, td.totalHits);
   }
 
   public void testSmallSetPolyWholeMap() throws Exception {
     TopDocs td = searchSmallSet(newPolygonQuery("point",
+                      new Polygon(
                       new double[] {GeoUtils.MIN_LAT_INCL, GeoUtils.MAX_LAT_INCL, GeoUtils.MAX_LAT_INCL, GeoUtils.MIN_LAT_INCL, GeoUtils.MIN_LAT_INCL},
-                      new double[] {GeoUtils.MIN_LON_INCL, GeoUtils.MIN_LON_INCL, GeoUtils.MAX_LON_INCL, GeoUtils.MAX_LON_INCL, GeoUtils.MIN_LON_INCL}),
+                      new double[] {GeoUtils.MIN_LON_INCL, GeoUtils.MIN_LON_INCL, GeoUtils.MAX_LON_INCL, GeoUtils.MAX_LON_INCL, GeoUtils.MIN_LON_INCL})),
                       20);    
     assertEquals("testWholeMap failed", 24, td.totalHits);
   }

http://git-wip-us.apache.org/repos/asf/lucene-solr/blob/2c0a8ed4/lucene/spatial/src/test/org/apache/lucene/spatial/util/GeoTestUtil.java
----------------------------------------------------------------------
diff --git a/lucene/spatial/src/test/org/apache/lucene/spatial/util/GeoTestUtil.java b/lucene/spatial/src/test/org/apache/lucene/spatial/util/GeoTestUtil.java
index e52c8a5..1785c12 100644
--- a/lucene/spatial/src/test/org/apache/lucene/spatial/util/GeoTestUtil.java
+++ b/lucene/spatial/src/test/org/apache/lucene/spatial/util/GeoTestUtil.java
@@ -85,7 +85,7 @@ public class GeoTestUtil {
   }
   
   /** returns next pseudorandom polygon */
-  public static double[][] nextPolygon() {
+  public static Polygon nextPolygon() {
     if (random().nextBoolean()) {
       return surpriseMePolygon(null, null);
     }
@@ -101,7 +101,7 @@ public class GeoTestUtil {
   }
   
   /** returns next pseudorandom polygon, kinda close to {@code otherLatitude} and {@code otherLongitude} */
-  public static double[][] nextPolygonNear(double otherLatitude, double otherLongitude) {
+  public static Polygon nextPolygonNear(double otherLatitude, double otherLongitude) {
     if (random().nextBoolean()) {
       return surpriseMePolygon(otherLatitude, otherLongitude);
     }
@@ -133,7 +133,7 @@ public class GeoTestUtil {
     return new GeoRect(lat0, lat1, lon0, lon1);
   }
   
-  private static double[][] boxPolygon(GeoRect box) {
+  private static Polygon boxPolygon(GeoRect box) {
     assert box.crossesDateline() == false;
     final double[] polyLats = new double[5];
     final double[] polyLons = new double[5];
@@ -147,10 +147,10 @@ public class GeoTestUtil {
     polyLons[3] = box.maxLon;
     polyLats[4] = box.minLat;
     polyLons[4] = box.minLon;
-    return new double[][] { polyLats, polyLons };
+    return new Polygon(polyLats, polyLons);
   }
   
-  private static double[][] trianglePolygon(GeoRect box) {
+  private static Polygon trianglePolygon(GeoRect box) {
     assert box.crossesDateline() == false;
     final double[] polyLats = new double[4];
     final double[] polyLons = new double[4];
@@ -162,11 +162,10 @@ public class GeoTestUtil {
     polyLons[2] = box.maxLon;
     polyLats[3] = box.minLat;
     polyLons[3] = box.minLon;
-    return new double[][] { polyLats, polyLons };
+    return new Polygon(polyLats, polyLons);
   }
   
-  /** Returns {polyLats, polyLons} double[] array */
-  private static double[][] surpriseMePolygon(Double otherLatitude, Double otherLongitude) {
+  private static Polygon surpriseMePolygon(Double otherLatitude, Double otherLongitude) {
     // repeat until we get a poly that doesn't cross dateline:
     newPoly:
     while (true) {
@@ -232,7 +231,7 @@ public class GeoTestUtil {
         latsArray[i] = lats.get(i);
         lonsArray[i] = lons.get(i);
       }
-      return new double[][] {latsArray, lonsArray};
+      return new Polygon(latsArray, lonsArray);
     }
   }
   

http://git-wip-us.apache.org/repos/asf/lucene-solr/blob/2c0a8ed4/lucene/spatial/src/test/org/apache/lucene/spatial/util/TestGeoUtils.java
----------------------------------------------------------------------
diff --git a/lucene/spatial/src/test/org/apache/lucene/spatial/util/TestGeoUtils.java b/lucene/spatial/src/test/org/apache/lucene/spatial/util/TestGeoUtils.java
index 9d6549a..f3b9ad1 100644
--- a/lucene/spatial/src/test/org/apache/lucene/spatial/util/TestGeoUtils.java
+++ b/lucene/spatial/src/test/org/apache/lucene/spatial/util/TestGeoUtils.java
@@ -264,65 +264,6 @@ public class TestGeoUtils extends LuceneTestCase {
     }
   }
   
-  public void testPacManPolyQuery() throws Exception {
-    // pacman
-    double[] px = {0, 10, 10, 0, -8, -10, -8, 0, 10, 10, 0};
-    double[] py = {0, 5, 9, 10, 9, 0, -9, -10, -9, -5, 0};
-
-    // shape bbox
-    double xMinA = -10;
-    double xMaxA = 10;
-    double yMinA = -10;
-    double yMaxA = 10;
-
-    // candidate crosses cell
-    double xMin = 2;//-5;
-    double xMax = 11;//0.000001;
-    double yMin = -1;//0;
-    double yMax = 1;//5;
-
-    // test cell crossing poly
-    assertTrue(GeoRelationUtils.rectCrossesPolyApprox(yMin, yMax, xMin, yMax, py, px, yMinA, yMaxA, xMinA, xMaxA));
-    assertFalse(GeoRelationUtils.rectCrossesPolyApprox(0, 5, -5, 0.000001, py, px, yMin, yMax, xMin, xMax));
-    assertTrue(GeoRelationUtils.rectWithinPolyApprox(0, 5, -5, -2, py, px, yMin, yMax, xMin, xMax));
-  }
-  
-  public void testPolyToBBox() throws Exception {
-    for (int i = 0; i < 1000; i++) {
-      double[][] polygon = GeoTestUtil.nextPolygon();
-      GeoRect box = GeoUtils.polyToBBox(polygon[0], polygon[1]);
-      assertFalse(box.crossesDateline());
-      
-      for (int j = 0; j < 1000; j++) {
-        double latitude = GeoTestUtil.nextLatitude();
-        double longitude = GeoTestUtil.nextLongitude();
-        // if the point is within poly, then it should be in our bounding box
-        if (GeoRelationUtils.pointInPolygon(polygon[0], polygon[1], latitude, longitude)) {
-          assertTrue(latitude >= box.minLat && latitude <= box.maxLat);
-          assertTrue(longitude >= box.minLon && longitude <= box.maxLon);
-        }
-      }
-    }
-  }
-  
-  public void testPolyToBBoxEdgeCases() throws Exception {
-    for (int i = 0; i < 1000; i++) {
-      double[][] polygon = GeoTestUtil.nextPolygon();
-      GeoRect box = GeoUtils.polyToBBox(polygon[0], polygon[1]);
-      assertFalse(box.crossesDateline());
-      
-      for (int j = 0; j < 1000; j++) {
-        double latitude = GeoTestUtil.nextLatitudeAround(box.minLat, box.maxLat);
-        double longitude = GeoTestUtil.nextLongitudeAround(box.minLon, box.maxLon);
-        // if the point is within poly, then it should be in our bounding box
-        if (GeoRelationUtils.pointInPolygon(polygon[0], polygon[1], latitude, longitude)) {
-          assertTrue(latitude >= box.minLat && latitude <= box.maxLat);
-          assertTrue(longitude >= box.minLon && longitude <= box.maxLon);
-        }
-      }
-    }
-  }
-
   public void testAxisLat() {
     double earthCircumference = 2D * Math.PI * GeoUtils.SEMIMAJOR_AXIS;
     assertEquals(90, GeoUtils.axisLat(0, earthCircumference / 4), 0.0D);

http://git-wip-us.apache.org/repos/asf/lucene-solr/blob/2c0a8ed4/lucene/spatial/src/test/org/apache/lucene/spatial/util/TestPolygon.java
----------------------------------------------------------------------
diff --git a/lucene/spatial/src/test/org/apache/lucene/spatial/util/TestPolygon.java b/lucene/spatial/src/test/org/apache/lucene/spatial/util/TestPolygon.java
new file mode 100644
index 0000000..4ccd8d4
--- /dev/null
+++ b/lucene/spatial/src/test/org/apache/lucene/spatial/util/TestPolygon.java
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.lucene.spatial.util;
+
+import org.apache.lucene.util.LuceneTestCase;
+
+public class TestPolygon extends LuceneTestCase {
+  
+  /** null polyLats not allowed */
+  public void testPolygonNullPolyLats() {
+    IllegalArgumentException expected = expectThrows(IllegalArgumentException.class, () -> {
+      new Polygon(null, new double[] { -66, -65, -65, -66, -66 });
+    });
+    assertTrue(expected.getMessage().contains("polyLats must not be null"));
+  }
+  
+  /** null polyLons not allowed */
+  public void testPolygonNullPolyLons() {
+    IllegalArgumentException expected = expectThrows(IllegalArgumentException.class, () -> {
+      new Polygon(new double[] { 18, 18, 19, 19, 18 }, null);
+    });
+    assertTrue(expected.getMessage().contains("polyLons must not be null"));
+  }
+  
+  /** polygon needs at least 3 vertices */
+  public void testPolygonLine() {
+    IllegalArgumentException expected = expectThrows(IllegalArgumentException.class, () -> {
+      new Polygon(new double[] { 18, 18, 18 }, new double[] { -66, -65, -66 });
+    });
+    assertTrue(expected.getMessage().contains("at least 4 polygon points required"));
+  }
+  
+  /** polygon needs same number of latitudes as longitudes */
+  public void testPolygonBogus() {
+    IllegalArgumentException expected = expectThrows(IllegalArgumentException.class, () -> {
+      new Polygon(new double[] { 18, 18, 19, 19 }, new double[] { -66, -65, -65, -66, -66 });
+    });
+    assertTrue(expected.getMessage().contains("must be equal length"));
+  }
+  
+  /** polygon must be closed */
+  public void testPolygonNotClosed() {
+    IllegalArgumentException expected = expectThrows(IllegalArgumentException.class, () -> {
+      new Polygon(new double[] { 18, 18, 19, 19, 19 }, new double[] { -66, -65, -65, -66, -67 });
+    });
+    assertTrue(expected.getMessage(), expected.getMessage().contains("it must close itself"));
+  }
+  
+  /** Three boxes, an island inside a hole inside a shape */
+  public void testMultiPolygon() {
+    Polygon hole = new Polygon(new double[] { -10, -10, 10, 10, -10 }, new double[] { -10, 10, 10, -10, -10 });
+    Polygon outer = new Polygon(new double[] { -50, -50, 50, 50, -50 }, new double[] { -50, 50, 50, -50, -50 }, hole);
+    Polygon island = new Polygon(new double[] { -5, -5, 5, 5, -5 }, new double[] { -5, 5, 5, -5, -5 } );
+    Polygon polygons[] = new Polygon[] { outer, island };
+    
+    // contains(point)
+    assertTrue(Polygon.contains(polygons, -2, 2)); // on the island
+    assertFalse(Polygon.contains(polygons, -6, 6)); // in the hole
+    assertTrue(Polygon.contains(polygons, -25, 25)); // on the mainland
+    assertFalse(Polygon.contains(polygons, -51, 51)); // in the ocean
+    
+    // contains(box): this can conservatively return false
+    assertTrue(Polygon.contains(polygons, -2, 2, -2, 2)); // on the island
+    assertFalse(Polygon.contains(polygons, 6, 7, 6, 7)); // in the hole
+    assertTrue(Polygon.contains(polygons, 24, 25, 24, 25)); // on the mainland
+    assertFalse(Polygon.contains(polygons, 51, 52, 51, 52)); // in the ocean
+    assertFalse(Polygon.contains(polygons, -60, 60, -60, 60)); // enclosing us completely
+    assertFalse(Polygon.contains(polygons, 49, 51, 49, 51)); // overlapping the mainland
+    assertFalse(Polygon.contains(polygons, 9, 11, 9, 11)); // overlapping the hole
+    assertFalse(Polygon.contains(polygons, 5, 6, 5, 6)); // overlapping the island
+
+    // crosses(box): this can conservatively return true
+    assertTrue(Polygon.crosses(polygons, -60, 60, -60, 60)); // enclosing us completely
+    assertTrue(Polygon.crosses(polygons, 49, 51, 49, 51)); // overlapping the mainland and ocean
+    assertTrue(Polygon.crosses(polygons, 9, 11, 9, 11)); // overlapping the hole and mainland
+    assertTrue(Polygon.crosses(polygons, 5, 6, 5, 6)); // overlapping the island
+  }
+  
+  public void testPacMan() throws Exception {
+    // pacman
+    double[] px = {0, 10, 10, 0, -8, -10, -8, 0, 10, 10, 0};
+    double[] py = {0, 5, 9, 10, 9, 0, -9, -10, -9, -5, 0};
+
+    // candidate crosses cell
+    double xMin = 2;//-5;
+    double xMax = 11;//0.000001;
+    double yMin = -1;//0;
+    double yMax = 1;//5;
+
+    // test cell crossing poly
+    Polygon polygon = new Polygon(py, px);
+    assertTrue(polygon.crosses(yMin, yMax, xMin, xMax));
+    assertFalse(polygon.contains(yMin, yMax, xMin, xMax));
+  }
+  
+  public void testBoundingBox() throws Exception {
+    for (int i = 0; i < 100; i++) {
+      Polygon polygon = GeoTestUtil.nextPolygon();
+      
+      for (int j = 0; j < 100; j++) {
+        double latitude = GeoTestUtil.nextLatitude();
+        double longitude = GeoTestUtil.nextLongitude();
+        // if the point is within poly, then it should be in our bounding box
+        if (polygon.contains(latitude, longitude)) {
+          assertTrue(latitude >= polygon.minLat && latitude <= polygon.maxLat);
+          assertTrue(longitude >= polygon.minLon && longitude <= polygon.maxLon);
+        }
+      }
+    }
+  }
+  
+  public void testBoundingBoxEdgeCases() throws Exception {
+    for (int i = 0; i < 100; i++) {
+      Polygon polygon = GeoTestUtil.nextPolygon();
+      
+      for (int j = 0; j < 100; j++) {
+        double latitude = GeoTestUtil.nextLatitudeAround(polygon.minLat, polygon.maxLat);
+        double longitude = GeoTestUtil.nextLongitudeAround(polygon.minLon, polygon.maxLon);
+        // if the point is within poly, then it should be in our bounding box
+        if (polygon.contains(latitude, longitude)) {
+          assertTrue(latitude >= polygon.minLat && latitude <= polygon.maxLat);
+          assertTrue(longitude >= polygon.minLon && longitude <= polygon.maxLon);
+        }
+      }
+    }
+  }
+}