LUCENE-6481: merge trunk

git-svn-id: https://svn.apache.org/repos/asf/lucene/dev/branches/LUCENE-6481@1683615 13f79535-47bb-0310-9956-ffa450edef68
diff --git a/lucene/core/src/java/org/apache/lucene/search/NumericRangeQuery.java b/lucene/core/src/java/org/apache/lucene/search/NumericRangeQuery.java
index 45ed87b..93dee17 100644
--- a/lucene/core/src/java/org/apache/lucene/search/NumericRangeQuery.java
+++ b/lucene/core/src/java/org/apache/lucene/search/NumericRangeQuery.java
@@ -167,8 +167,7 @@
 public final class NumericRangeQuery<T extends Number> extends MultiTermQuery {
 
   private NumericRangeQuery(final String field, final int precisionStep, final NumericType dataType,
-    T min, T max, final boolean minInclusive, final boolean maxInclusive
-  ) {
+    T min, T max, final boolean minInclusive, final boolean maxInclusive) {
     super(field);
     if (precisionStep < 1)
       throw new IllegalArgumentException("precisionStep must be >=1");
diff --git a/lucene/core/src/java/org/apache/lucene/util/BitUtil.java b/lucene/core/src/java/org/apache/lucene/util/BitUtil.java
index 5b0dc78..d412ac1 100644
--- a/lucene/core/src/java/org/apache/lucene/util/BitUtil.java
+++ b/lucene/core/src/java/org/apache/lucene/util/BitUtil.java
@@ -22,6 +22,15 @@
  */
 public final class BitUtil {
 
+  // magic numbers for bit interleaving
+  private static final long MAGIC[] = {
+      0x5555555555555555L, 0x3333333333333333L,
+      0x0F0F0F0F0F0F0F0FL, 0x00FF00FF00FF00FFL,
+      0x0000FFFF0000FFFFL, 0x00000000FFFFFFFFL
+  };
+  // shift values for bit interleaving
+  private static final short SHIFT[] = {1, 2, 4, 8, 16};
+
   private BitUtil() {} // no instance
 
   // The pop methods used to rely on bit-manipulation tricks for speed but it
@@ -102,6 +111,39 @@
     return v;
   }
 
+  /**
+   * Interleaves the first 32 bits of each long value
+   *
+   * Adapted from: http://graphics.stanford.edu/~seander/bithacks.html#InterleaveBMN
+   */
+  public static long interleave(long v1, long v2) {
+    v1 = (v1 | (v1 << SHIFT[4])) & MAGIC[4];
+    v1 = (v1 | (v1 << SHIFT[3])) & MAGIC[3];
+    v1 = (v1 | (v1 << SHIFT[2])) & MAGIC[2];
+    v1 = (v1 | (v1 << SHIFT[1])) & MAGIC[1];
+    v1 = (v1 | (v1 << SHIFT[0])) & MAGIC[0];
+    v2 = (v2 | (v2 << SHIFT[4])) & MAGIC[4];
+    v2 = (v2 | (v2 << SHIFT[3])) & MAGIC[3];
+    v2 = (v2 | (v2 << SHIFT[2])) & MAGIC[2];
+    v2 = (v2 | (v2 << SHIFT[1])) & MAGIC[1];
+    v2 = (v2 | (v2 << SHIFT[0])) & MAGIC[0];
+
+    return (v2<<1) | v1;
+  }
+
+  /**
+   * Deinterleaves long value back to two concatenated 32bit values
+   */
+  public static long deinterleave(long b) {
+    b &= MAGIC[0];
+    b = (b ^ (b >>> SHIFT[0])) & MAGIC[1];
+    b = (b ^ (b >>> SHIFT[1])) & MAGIC[2];
+    b = (b ^ (b >>> SHIFT[2])) & MAGIC[3];
+    b = (b ^ (b >>> SHIFT[3])) & MAGIC[4];
+    b = (b ^ (b >>> SHIFT[4])) & MAGIC[5];
+    return b;
+  }
+
    /** Same as {@link #zigZagEncode(long)} but on integers. */
    public static int zigZagEncode(int i) {
      return (i >> 31) ^ (i << 1);
diff --git a/lucene/sandbox/src/java/org/apache/lucene/document/GeoPointField.java b/lucene/sandbox/src/java/org/apache/lucene/document/GeoPointField.java
new file mode 100644
index 0000000..15775e9
--- /dev/null
+++ b/lucene/sandbox/src/java/org/apache/lucene/document/GeoPointField.java
@@ -0,0 +1,105 @@
+package org.apache.lucene.document;
+
+/*
+ * 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.
+ */
+
+import org.apache.lucene.index.IndexOptions;
+import org.apache.lucene.util.GeoUtils;
+
+/**
+ * <p>
+ * Field that indexes <code>latitude</code> <code>longitude</code> decimal-degree values
+ * for efficient encoding, sorting, and querying. This Geo capability is intended
+ * to provide a basic and efficient out of the box field type for indexing and
+ * querying 2 dimensional points in WGS-84 decimal degrees. An example usage is as follows:
+ *
+ * <pre class="prettyprint">
+ *  document.add(new GeoPointField(name, -96.33, 32.66, Field.Store.NO));
+ * </pre>
+ *
+ * <p>To perform simple geospatial queries against a <code>GeoPointField</code>,
+ * see {@link org.apache.lucene.search.GeoPointInBBoxQuery} or {@link org.apache.lucene.search.GeoPointInPolygonQuery}
+ *
+ * NOTE: This indexes only high precision encoded terms which may result in visiting a high number
+ * of terms for large queries. See LUCENE-6481 for a future improvement.
+ *
+ * @lucene.experimental
+ */
+public final class GeoPointField extends Field {
+  public static final int PRECISION_STEP = 6;
+
+  /**
+   * Type for an GeoPointField that is not stored:
+   * normalization factors, frequencies, and positions are omitted.
+   */
+  public static final FieldType TYPE_NOT_STORED = new FieldType();
+  static {
+    TYPE_NOT_STORED.setTokenized(false);
+    TYPE_NOT_STORED.setOmitNorms(true);
+    TYPE_NOT_STORED.setIndexOptions(IndexOptions.DOCS);
+    TYPE_NOT_STORED.setNumericType(FieldType.NumericType.LONG);
+    TYPE_NOT_STORED.setNumericPrecisionStep(PRECISION_STEP);
+    TYPE_NOT_STORED.freeze();
+  }
+
+  /**
+   * Type for a stored GeoPointField:
+   * normalization factors, frequencies, and positions are omitted.
+   */
+  public static final FieldType TYPE_STORED = new FieldType();
+  static {
+    TYPE_STORED.setTokenized(false);
+    TYPE_STORED.setOmitNorms(true);
+    TYPE_STORED.setIndexOptions(IndexOptions.DOCS);
+    TYPE_STORED.setNumericType(FieldType.NumericType.LONG);
+    TYPE_STORED.setNumericPrecisionStep(PRECISION_STEP);
+    TYPE_STORED.setStored(true);
+    TYPE_STORED.freeze();
+  }
+
+  /** Creates a stored or un-stored GeoPointField with the provided value
+   *  and default <code>precisionStep</code> set to 64 to avoid wasteful
+   *  indexing of lower precision terms.
+   *  @param name field name
+   *  @param lon longitude double value [-180.0 : 180.0]
+   *  @param lat latitude double value [-90.0 : 90.0]
+   *  @param stored Store.YES if the content should also be stored
+   *  @throws IllegalArgumentException if the field name is null.
+   */
+  public GeoPointField(String name, double lon, double lat, Store stored) {
+    super(name, stored == Store.YES ? TYPE_STORED : TYPE_NOT_STORED);
+    fieldsData = GeoUtils.mortonHash(lon, lat);
+  }
+
+  /** Expert: allows you to customize the {@link
+   *  FieldType}.
+   *  @param name field name
+   *  @param lon longitude double value [-180.0 : 180.0]
+   *  @param lat latitude double value [-90.0 : 90.0]
+   *  @param type customized field type: must have {@link FieldType#numericType()}
+   *         of {@link FieldType.NumericType#LONG}.
+   *  @throws IllegalArgumentException if the field name or type is null, or
+   *          if the field type does not have a LONG numericType()
+   */
+  public GeoPointField(String name, double lon, double lat, FieldType type) {
+    super(name, type);
+    if (type.numericType() != FieldType.NumericType.LONG) {
+      throw new IllegalArgumentException("type.numericType() must be LONG but got " + type.numericType());
+    }
+    fieldsData = GeoUtils.mortonHash(lon, lat);
+  }
+}
diff --git a/lucene/sandbox/src/java/org/apache/lucene/document/package.html b/lucene/sandbox/src/java/org/apache/lucene/document/package.html
new file mode 100644
index 0000000..6f26128
--- /dev/null
+++ b/lucene/sandbox/src/java/org/apache/lucene/document/package.html
@@ -0,0 +1,28 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<!--
+ 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.
+-->
+
+<!-- not a package-info.java, because we already defined this package in core/ -->
+
+<html>
+<head>
+   <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+</head>
+<body>
+This package contains a single Field implementation, GeoPointField, to index a lat/lon geospatial point.
+</body>
+</html>
diff --git a/lucene/sandbox/src/java/org/apache/lucene/search/GeoBoundingBox.java b/lucene/sandbox/src/java/org/apache/lucene/search/GeoBoundingBox.java
new file mode 100644
index 0000000..3e3650d
--- /dev/null
+++ b/lucene/sandbox/src/java/org/apache/lucene/search/GeoBoundingBox.java
@@ -0,0 +1,47 @@
+package org.apache.lucene.search;
+
+/*
+ * 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.
+ */
+
+import org.apache.lucene.util.GeoUtils;
+
+/** NOTE: package private; just used so {@link GeoPointInPolygonQuery} can communicate its bounding box to {@link GeoPointInBBoxQuery}. */
+class GeoBoundingBox {
+  public final double minLon;
+  public final double maxLon;
+  public final double minLat;
+  public final double maxLat;
+
+  public GeoBoundingBox(double minLon, double maxLon, double minLat, double maxLat) {
+    if (GeoUtils.isValidLon(minLon) == false) {
+      throw new IllegalArgumentException("invalid minLon " + minLon);
+    }
+    if (GeoUtils.isValidLon(maxLon) == false) {
+      throw new IllegalArgumentException("invalid maxLon " + minLon);
+    }
+    if (GeoUtils.isValidLat(minLat) == false) {
+      throw new IllegalArgumentException("invalid minLat " + minLat);
+    }
+    if (GeoUtils.isValidLat(maxLat) == false) {
+      throw new IllegalArgumentException("invalid maxLat " + minLat);
+    }
+    this.minLon = minLon;
+    this.maxLon = maxLon;
+    this.minLat = minLat;
+    this.maxLat = maxLat;
+  }
+}
diff --git a/lucene/sandbox/src/java/org/apache/lucene/search/GeoPointInBBoxQuery.java b/lucene/sandbox/src/java/org/apache/lucene/search/GeoPointInBBoxQuery.java
new file mode 100644
index 0000000..669a3cb
--- /dev/null
+++ b/lucene/sandbox/src/java/org/apache/lucene/search/GeoPointInBBoxQuery.java
@@ -0,0 +1,147 @@
+package org.apache.lucene.search;
+
+/*
+ * 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.
+ */
+
+import java.io.IOException;
+
+import org.apache.lucene.index.Terms;
+import org.apache.lucene.index.TermsEnum;
+import org.apache.lucene.util.AttributeSource;
+import org.apache.lucene.util.GeoUtils;
+import org.apache.lucene.util.ToStringUtils;
+
+/** Implements a simple bounding box query on a GeoPoint field. This is inspired by
+ * {@link org.apache.lucene.search.NumericRangeQuery} and is implemented using a
+ * two phase approach. First, candidate terms are queried using a numeric
+ * range based on the morton codes of the min and max lat/lon pairs. Terms
+ * passing this initial filter are passed to a final check that verifies whether
+ * the decoded lat/lon falls within (or on the boundary) of the query bounding box.
+ * The value comparisons are subject to a precision tolerance defined in
+ * {@value org.apache.lucene.util.GeoUtils#TOLERANCE}
+ *
+ * NOTES:
+ *    1.  All latitude/longitude values must be in decimal degrees.
+ *    2.  Complex computational geometry (e.g., dateline wrapping) is not supported
+ *    3.  For more advanced GeoSpatial indexing and query operations see spatial module
+ *    4.  This is well suited for small rectangles, large bounding boxes result
+ *        in too many terms
+ *
+ * @lucene.experimental
+ */
+public class GeoPointInBBoxQuery extends MultiTermQuery {
+  // simple bounding box optimization - no objects used to avoid dependencies
+  protected final double minLon;
+  protected final double minLat;
+  protected final double maxLon;
+  protected final double maxLat;
+
+  /**
+   * Constructs a new GeoBBoxQuery that will match encoded GeoPoint terms that fall within or on the boundary
+   * of the bounding box defined by the input parameters
+   * @param field the field name
+   * @param minLon lower longitude (x) value of the bounding box
+   * @param minLat lower latitude (y) value of the bounding box
+   * @param maxLon upper longitude (x) value of the bounding box
+   * @param maxLat upper latitude (y) value of the bounding box
+   */
+  public GeoPointInBBoxQuery(final String field, final double minLon, final double minLat, final double maxLon, final double maxLat) {
+    super(field);
+    if (GeoUtils.isValidLon(minLon) == false) {
+      throw new IllegalArgumentException("invalid minLon " + minLon);
+    }
+    if (GeoUtils.isValidLon(maxLon) == false) {
+      throw new IllegalArgumentException("invalid maxLon " + maxLon);
+    }
+    if (GeoUtils.isValidLat(minLat) == false) {
+      throw new IllegalArgumentException("invalid minLat " + minLat);
+    }
+    if (GeoUtils.isValidLat(maxLat) == false) {
+      throw new IllegalArgumentException("invalid maxLat " + maxLat);
+    }
+    this.minLon = minLon;
+    this.minLat = minLat;
+    this.maxLon = maxLon;
+    this.maxLat = maxLat;
+  }
+
+  @Override @SuppressWarnings("unchecked")
+  protected TermsEnum getTermsEnum(final Terms terms, AttributeSource atts) throws IOException {
+    final Long min = GeoUtils.mortonHash(minLon, minLat);
+    final Long max = Math.abs(GeoUtils.mortonHash(maxLon, maxLat));
+    if (min != null && max != null &&  min.compareTo(max) > 0) {
+      return TermsEnum.EMPTY;
+    }
+    return new GeoPointTermsEnum(terms.iterator(), atts, minLon, minLat, maxLon, maxLat);
+  }
+
+  @Override
+  @SuppressWarnings({"unchecked","rawtypes"})
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (o == null || getClass() != o.getClass()) return false;
+    if (!super.equals(o)) return false;
+
+    GeoPointInBBoxQuery that = (GeoPointInBBoxQuery) o;
+
+    if (Double.compare(that.maxLat, maxLat) != 0) return false;
+    if (Double.compare(that.maxLon, maxLon) != 0) return false;
+    if (Double.compare(that.minLat, minLat) != 0) return false;
+    if (Double.compare(that.minLon, minLon) != 0) return false;
+
+    return true;
+  }
+
+  @Override
+  public int hashCode() {
+    int result = super.hashCode();
+    long temp;
+    temp = Double.doubleToLongBits(minLon);
+    result = 31 * result + (int) (temp ^ (temp >>> 32));
+    temp = Double.doubleToLongBits(minLat);
+    result = 31 * result + (int) (temp ^ (temp >>> 32));
+    temp = Double.doubleToLongBits(maxLon);
+    result = 31 * result + (int) (temp ^ (temp >>> 32));
+    temp = Double.doubleToLongBits(maxLat);
+    result = 31 * result + (int) (temp ^ (temp >>> 32));
+    return result;
+  }
+
+  @Override
+  public String toString(String field) {
+    final StringBuilder sb = new StringBuilder();
+    sb.append(getClass().getSimpleName());
+    sb.append(':');
+    if (!getField().equals(field)) {
+      sb.append(" field=");
+      sb.append(getField());
+      sb.append(':');
+    }
+    return sb.append(" Lower Left: [")
+        .append(minLon)
+        .append(',')
+        .append(minLat)
+        .append(']')
+        .append(" Upper Right: [")
+        .append(maxLon)
+        .append(',')
+        .append(maxLat)
+        .append("]")
+        .append(ToStringUtils.boost(getBoost()))
+        .toString();
+  }
+}
diff --git a/lucene/sandbox/src/java/org/apache/lucene/search/GeoPointInPolygonQuery.java b/lucene/sandbox/src/java/org/apache/lucene/search/GeoPointInPolygonQuery.java
new file mode 100644
index 0000000..8d91772
--- /dev/null
+++ b/lucene/sandbox/src/java/org/apache/lucene/search/GeoPointInPolygonQuery.java
@@ -0,0 +1,242 @@
+package org.apache.lucene.search;
+
+/*
+ * 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.
+ */
+
+import org.apache.lucene.index.Terms;
+import org.apache.lucene.index.TermsEnum;
+import org.apache.lucene.util.AttributeSource;
+import org.apache.lucene.util.BytesRef;
+import org.apache.lucene.util.GeoUtils;
+import org.apache.lucene.util.NumericUtils;
+import org.apache.lucene.util.ToStringUtils;
+
+import java.io.IOException;
+import java.util.Arrays;
+
+/** Implements a simple point in polygon query on a GeoPoint field. This is based on
+ * {@link GeoPointInBBoxQuery} and is implemented using a
+ * three phase approach. First, like {@link GeoPointInBBoxQuery}
+ * candidate terms are queried using a numeric range based on the morton codes
+ * of the min and max lat/lon pairs. Terms passing this initial filter are passed
+ * to a secondary filter that verifies whether the decoded lat/lon point falls within
+ * (or on the boundary) of the bounding box query. Finally, the remaining candidate
+ * term is passed to the final point in polygon check. All value comparisons are subject
+ * to the same precision tolerance defined in {@value org.apache.lucene.util.GeoUtils#TOLERANCE}
+ *
+ * NOTES:
+ *    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
+ *    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
+  private final double[] x;
+  private final double[] y;
+
+  /**
+   * Constructs a new GeoPolygonQuery that will match encoded {@link org.apache.lucene.document.GeoPointField} terms
+   * that fall within or on the boundary of the polygon defined by the input parameters.
+   */
+  public GeoPointInPolygonQuery(final String field, final double[] polyLons, final double[] polyLats) {
+    this(field, computeBBox(polyLons, polyLats), polyLons, polyLats);
+  }
+
+  /**
+   * Expert: constructs a new GeoPolygonQuery that will match encoded {@link org.apache.lucene.document.GeoPointField} terms
+   * that fall within or on the boundary of the polygon defined by the input parameters.  This constructor requires a
+   * precomputed bounding box. As an alternative, {@link GeoPointInPolygonQuery(String,double[],double[])} can
+   * be used to compute the bounding box during construction
+   *
+   * @param field the field name
+   * @param minLon lower longitude (x) value of the bounding box optimizer
+   * @param minLat lower latitude (y) value of the bounding box optimizer
+   * @param maxLon upper longitude (x) value of the bounding box optimizer
+   * @param maxLat upper latitude (y) value of the bounding box optimizer
+   * @param polyLons array containing all longitude values for the polygon
+   * @param polyLats array containing all latitude values for the polygon
+   */
+  public GeoPointInPolygonQuery(final String field, final double minLon, final double minLat, final double maxLon,
+                                final double maxLat, final double[] polyLons, final double[] polyLats) {
+    // TODO: should we remove this?  It's dangerous .. app could accidentally provide too-small bbox?
+    // we should at least verify that bbox does in fact fully contain the poly?
+    this(field, new GeoBoundingBox(minLon, maxLon, minLat, maxLat), polyLons, polyLats);
+  }
+
+  /** Common constructor, used only internally. */
+  private GeoPointInPolygonQuery(final String field, GeoBoundingBox bbox, final double[] polyLons, final double[] polyLats) {
+    super(field, bbox.minLon, bbox.minLat, bbox.maxLon, bbox.maxLat);
+    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]);
+    }
+
+    this.x = polyLons;
+    this.y = polyLats;
+  }
+
+  @Override @SuppressWarnings("unchecked")
+  protected TermsEnum getTermsEnum(final Terms terms, AttributeSource atts) throws IOException {
+    final Long min = GeoUtils.mortonHash(minLon, minLat);
+    final Long max = Math.abs(GeoUtils.mortonHash(maxLon, maxLat));
+    if (min != null && max != null &&  min.compareTo(max) > 0) {
+      return TermsEnum.EMPTY;
+    }
+    return new GeoPolygonTermsEnum(terms.iterator(), atts, minLon, minLat, maxLon, maxLat);
+  }
+
+  @Override
+  public final 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(x, that.x)) return false;
+    if (!Arrays.equals(y, that.y)) return false;
+
+    return true;
+  }
+
+  @Override
+  public final int hashCode() {
+    int result = super.hashCode();
+    result = 31 * result + (x != null ? Arrays.hashCode(x) : 0);
+    result = 31 * result + (y != null ? Arrays.hashCode(y) : 0);
+    return result;
+  }
+
+  @Override
+  public String toString(String field) {
+    assert x.length == y.length;
+
+    final StringBuilder sb = new StringBuilder();
+    sb.append(getClass().getSimpleName());
+    sb.append(':');
+    if (!getField().equals(field)) {
+      sb.append(" field=");
+      sb.append(getField());
+      sb.append(':');
+    }
+    sb.append(" Points: ");
+    for (int i=0; i<x.length; ++i) {
+      sb.append("[")
+        .append(x[i])
+        .append(", ")
+        .append(y[i])
+        .append("] ");
+    }
+    sb.append(ToStringUtils.boost(getBoost()));
+
+    return sb.toString();
+  }
+
+  private final class GeoPolygonTermsEnum extends GeoPointTermsEnum {
+    GeoPolygonTermsEnum(final TermsEnum tenum, AttributeSource atts, final double minLon, final double minLat,
+                        final double maxLon, final double maxLat) {
+      super(tenum, atts, minLon, minLat, maxLon, maxLat);
+    }
+
+    @Override
+    protected boolean cellCrosses(final double minLon, final double minLat, final double maxLon, final double maxLat) {
+      return GeoUtils.rectCrossesPoly(minLon, minLat, maxLon, maxLat, x, y, GeoPointInPolygonQuery.this.minLon,
+          GeoPointInPolygonQuery.this.minLat, GeoPointInPolygonQuery.this.maxLon, GeoPointInPolygonQuery.this.maxLat);
+    }
+
+    @Override
+    protected boolean cellWithin(final double minLon, final double minLat, final double maxLon, final double maxLat) {
+      return GeoUtils.rectWithinPoly(minLon, minLat, maxLon, maxLat, x, y, GeoPointInPolygonQuery.this.minLon,
+          GeoPointInPolygonQuery.this.minLat, GeoPointInPolygonQuery.this.maxLon, GeoPointInPolygonQuery.this.maxLat);
+    }
+
+    @Override
+    protected boolean cellIntersects(final double minLon, final double minLat, final double maxLon, final double maxLat) {
+      return GeoUtils.rectIntersects(minLon, minLat, maxLon, maxLat, GeoPointInPolygonQuery.this.minLon,
+          GeoPointInPolygonQuery.this.minLat, GeoPointInPolygonQuery.this.maxLon, GeoPointInPolygonQuery.this.maxLat);
+    }
+
+    /**
+     * The two-phase query approach. The parent
+     * {@link org.apache.lucene.search.GeoPointTermsEnum#accept} method is called to match
+     * encoded terms that fall within the bounding box of the polygon. Those documents that pass the initial
+     * bounding box filter are then compared to the provided polygon using the
+     * {@link org.apache.lucene.util.GeoUtils#pointInPolygon} method.
+     *
+     * @param term term for candidate document
+     * @return match status
+     */
+    @Override
+    protected final AcceptStatus accept(BytesRef term) {
+      // first filter by bounding box
+      AcceptStatus status = super.accept(term);
+      assert status != AcceptStatus.YES_AND_SEEK;
+
+      if (status != AcceptStatus.YES) {
+        return status;
+      }
+
+      final long val = NumericUtils.prefixCodedToLong(term);
+      final double lon = GeoUtils.mortonUnhashLon(val);
+      final double lat = GeoUtils.mortonUnhashLat(val);
+      // post-filter by point in polygon
+      if (!GeoUtils.pointInPolygon(x, y, lat, lon)) {
+        return AcceptStatus.NO;
+      }
+      return AcceptStatus.YES;
+    }
+  }
+
+  private static GeoBoundingBox computeBBox(double[] polyLons, double[] polyLats) {
+    if (polyLons.length != polyLats.length) {
+      throw new IllegalArgumentException("polyLons and polyLats must be equal length");
+    }
+
+    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++) {
+      if (GeoUtils.isValidLon(polyLons[i]) == false) {
+        throw new IllegalArgumentException("invalid polyLons[" + i + "]=" + polyLons[i]);
+      }
+      if (GeoUtils.isValidLat(polyLats[i]) == false) {
+        throw new IllegalArgumentException("invalid polyLats[" + i + "]=" + polyLats[i]);
+      }
+      minLon = Math.min(polyLons[i], minLon);
+      maxLon = Math.max(polyLons[i], maxLon);
+      minLat = Math.min(polyLats[i], minLat);
+      maxLat = Math.max(polyLats[i], maxLat);
+    }
+
+    return new GeoBoundingBox(minLon, maxLon, minLat, maxLat);
+  }
+}
diff --git a/lucene/sandbox/src/java/org/apache/lucene/search/package.html b/lucene/sandbox/src/java/org/apache/lucene/search/package.html
index 0ae2dd6..e4a0c8e 100644
--- a/lucene/sandbox/src/java/org/apache/lucene/search/package.html
+++ b/lucene/sandbox/src/java/org/apache/lucene/search/package.html
@@ -23,6 +23,6 @@
    <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
 </head>
 <body>
-This package contains a single proximity query, TermAutomatonQuery.
+This package contains a flexible graph-based proximity query, TermAutomatonQuery, and geospatial queries.
 </body>
 </html>
diff --git a/lucene/sandbox/src/java/org/apache/lucene/util/GeoUtils.java b/lucene/sandbox/src/java/org/apache/lucene/util/GeoUtils.java
new file mode 100644
index 0000000..a2375a3
--- /dev/null
+++ b/lucene/sandbox/src/java/org/apache/lucene/util/GeoUtils.java
@@ -0,0 +1,400 @@
+package org.apache.lucene.util;
+
+/*
+ * 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.
+ */
+
+/**
+ * Basic reusable geo-spatial utility methods
+ *
+ * @lucene.experimental
+ */
+public final class GeoUtils {
+  // WGS84 earth-ellipsoid major (a) minor (b) radius, (f) flattening and eccentricity (e)
+  private static final double SEMIMAJOR_AXIS = 6_378_137; // [m]
+  private static final double FLATTENING = 1.0/298.257223563;
+  private static final double SEMIMINOR_AXIS = SEMIMAJOR_AXIS * (1.0 - FLATTENING); //6_356_752.31420; // [m]
+  private static final double ECCENTRICITY = StrictMath.sqrt((2.0 - FLATTENING) * FLATTENING);
+  private static final double PI_OVER_2 = StrictMath.PI / 2.0D;
+  private static final double SEMIMAJOR_AXIS2 = SEMIMAJOR_AXIS * SEMIMINOR_AXIS;
+  private static final double SEMIMINOR_AXIS2 = SEMIMINOR_AXIS * SEMIMINOR_AXIS;
+
+  private static final short MIN_LON = -180;
+  private static final short MIN_LAT = -90;
+  public static final short BITS = 31;
+  private static final double LON_SCALE = (0x1L<<BITS)/360.0D;
+  private static final double LAT_SCALE = (0x1L<<BITS)/180.0D;
+  private static final double TOLERANCE = 1E-7;
+
+  /** Minimum longitude value. */
+  public static final double MIN_LON_INCL = -180.0D;
+
+  /** Maximum longitude value. */
+  public static final double MAX_LON_INCL = 180.0D;
+
+  /** Minimum latitude value. */
+  public static final double MIN_LAT_INCL = -90.0D;
+
+  /** Maximum latitude value. */
+  public static final double MAX_LAT_INCL = 90.0D;
+
+  // No instance:
+  private GeoUtils() {
+  }
+
+  public static final Long mortonHash(final double lon, final double lat) {
+    return BitUtil.interleave(scaleLon(lon), scaleLat(lat));
+  }
+
+  public static final double mortonUnhashLon(final long hash) {
+    return unscaleLon(BitUtil.deinterleave(hash));
+  }
+
+  public static final double mortonUnhashLat(final long hash) {
+    return unscaleLat(BitUtil.deinterleave(hash >>> 1));
+  }
+
+  private static long scaleLon(final double val) {
+    return (long) ((val-MIN_LON) * LON_SCALE);
+  }
+
+  private static long scaleLat(final double val) {
+    return (long) ((val-MIN_LAT) * LAT_SCALE);
+  }
+
+  private static double unscaleLon(final long val) {
+    return (val / LON_SCALE) + MIN_LON;
+  }
+
+  private static double unscaleLat(final long val) {
+    return (val / LAT_SCALE) + MIN_LAT;
+  }
+
+  public static final double compare(final double v1, final double v2) {
+    final double compare = v1-v2;
+    return Math.abs(compare) <= TOLERANCE ? 0 : compare;
+  }
+
+  public static final boolean bboxContains(final double lon, final double lat, final double minLon,
+                                           final double minLat, final double maxLon, final double maxLat) {
+    return (compare(lon, minLon) >= 0 && compare(lon, maxLon) <= 0
+          && compare(lat, minLat) >= 0 && compare(lat, maxLat) <= 0);
+  }
+
+  /**
+   * Converts from geodesic lon lat alt to geocentric earth-centered earth-fixed
+   * @param lon geodesic longitude
+   * @param lat geodesic latitude
+   * @param alt geodesic altitude
+   * @param ecf reusable earth-centered earth-fixed result
+   * @return either a new ecef array or the reusable ecf parameter
+   */
+  public static final double[] llaToECF(double lon, double lat, double alt, double[] ecf) {
+    lon = StrictMath.toRadians(lon);
+    lat = StrictMath.toRadians(lat);
+
+    final double sl = StrictMath.sin(lat);
+    final double s2 = sl*sl;
+    final double cl = StrictMath.cos(lat);
+    final double ge2 = (SEMIMAJOR_AXIS2 - SEMIMINOR_AXIS2)/(SEMIMAJOR_AXIS2);
+
+    if (ecf == null)
+      ecf = new double[3];
+
+    if (lat < -PI_OVER_2 && lat > -1.001D * PI_OVER_2) {
+      lat = -PI_OVER_2;
+    } else if (lat > PI_OVER_2 && lat < 1.001D * PI_OVER_2) {
+      lat = PI_OVER_2;
+    }
+    assert ((lat >= -PI_OVER_2) || (lat <= PI_OVER_2));
+
+    if (lon > StrictMath.PI) {
+      lon -= (2*StrictMath.PI);
+    }
+
+    final double rn = SEMIMAJOR_AXIS / StrictMath.sqrt(1.0D - ge2 * s2);
+    ecf[0] = (rn+alt) * cl * StrictMath.cos(lon);
+    ecf[1] = (rn+alt) * cl * StrictMath.sin(lon);
+    ecf[2] = ((rn*(1.0-ge2))+alt)*sl;
+
+    return ecf;
+  }
+
+  /**
+   * Converts from geocentric earth-centered earth-fixed to geodesic lat/lon/alt
+   * @param x Cartesian x coordinate
+   * @param y Cartesian y coordinate
+   * @param z Cartesian z coordinate
+   * @param lla 0: longitude 1: latitude: 2: altitude
+   * @return double array as 0: longitude 1: latitude 2: altitude
+   */
+  public static final double[] ecfToLLA(final double x, final double y, final double z, double[] lla) {
+    boolean atPole = false;
+    final double ad_c = 1.0026000D;
+    final double e2 = (SEMIMAJOR_AXIS2 - SEMIMINOR_AXIS2)/(SEMIMAJOR_AXIS2);
+    final double ep2 = (SEMIMAJOR_AXIS2 - SEMIMINOR_AXIS2)/(SEMIMINOR_AXIS2);
+    final double cos67P5 = 0.38268343236508977D;
+
+    if (lla == null)
+      lla = new double[3];
+
+    if (x != 0.0) {
+      lla[0] = StrictMath.atan2(y,x);
+    } else {
+      if (y > 0) {
+        lla[0] = PI_OVER_2;
+      } else if (y < 0) {
+        lla[0] = -PI_OVER_2;
+      } else {
+        atPole = true;
+        lla[0] = 0.0D;
+        if (z > 0.0) {
+          lla[1] = PI_OVER_2;
+        } else if (z < 0.0) {
+          lla[1] = -PI_OVER_2;
+        } else {
+          lla[1] = PI_OVER_2;
+          lla[2] = -SEMIMINOR_AXIS;
+          return lla;
+        }
+      }
+    }
+
+    final double w2 = x*x + y*y;
+    final double w = StrictMath.sqrt(w2);
+    final double t0 = z * ad_c;
+    final double s0 = StrictMath.sqrt(t0 * t0 + w2);
+    final double sinB0 = t0 / s0;
+    final double cosB0 = w / s0;
+    final double sin3B0 = sinB0 * sinB0 * sinB0;
+    final double t1 = z + SEMIMINOR_AXIS * ep2 * sin3B0;
+    final double sum = w - SEMIMAJOR_AXIS * e2 * cosB0 * cosB0 * cosB0;
+    final double s1 = StrictMath.sqrt(t1 * t1 + sum * sum);
+    final double sinP1 = t1 / s1;
+    final double cosP1 = sum / s1;
+    final double rn = SEMIMAJOR_AXIS / StrictMath.sqrt(1.0D - e2 * sinP1 * sinP1);
+
+    if (cosP1 >= cos67P5) {
+      lla[2] = w / cosP1 - rn;
+    } else if (cosP1 <= -cos67P5) {
+      lla[2] = w / -cosP1 - rn;
+    } else {
+      lla[2] = z / sinP1 + rn * (e2 - 1.0);
+    }
+    if (!atPole) {
+      lla[1] = StrictMath.atan(sinP1/cosP1);
+    }
+    lla[0] = StrictMath.toDegrees(lla[0]);
+    lla[1] = StrictMath.toDegrees(lla[1]);
+
+    return lla;
+  }
+
+  /**
+   * 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
+   */
+  public static boolean pointInPolygon(double[] x, double[] y, double lat, double lon) {
+    assert x.length == y.length;
+    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 < x.length; i++) {
+      if (x[i] < lon && x[i-1] >= lon || x[i-1] < lon && x[i] >= lon) {
+        if (y[i] + (lon - x[i]) / (x[i-1] - x[i]) * (y[i-1] - y[i]) < lat) {
+          inPoly = !inPoly;
+        }
+      }
+    }
+    return inPoly;
+  }
+
+  public static String geoTermToString(long term) {
+    StringBuilder s = new StringBuilder(64);
+    final int numberOfLeadingZeros = Long.numberOfLeadingZeros(term);
+    for (int i = 0; i < numberOfLeadingZeros; i++) {
+      s.append('0');
+    }
+    if (term != 0)
+      s.append(Long.toBinaryString(term));
+    return s.toString();
+  }
+
+
+  public static boolean rectDisjoint(final double aMinX, final double aMinY, final double aMaxX, final double aMaxY,
+                                     final double bMinX, final double bMinY, final double bMaxX, final double bMaxY) {
+    return (aMaxX < bMinX || aMinX > bMaxX || aMaxY < bMinY || aMinY > bMaxY);
+  }
+
+  /**
+   * Computes whether a rectangle is wholly within another rectangle (shared boundaries allowed)
+   */
+  public static boolean rectWithin(final double aMinX, final double aMinY, final double aMaxX, final double aMaxY,
+                                   final double bMinX, final double bMinY, final double bMaxX, final double bMaxY) {
+    return !(aMinX < bMinX || aMinY < bMinY || aMaxX > bMaxX || aMaxY > bMaxY);
+  }
+
+  public static boolean rectCrosses(final double aMinX, final double aMinY, final double aMaxX, final double aMaxY,
+                                    final double bMinX, final double bMinY, final double bMaxX, final double bMaxY) {
+    return !(rectDisjoint(aMinX, aMinY, aMaxX, aMaxY, bMinX, bMinY, bMaxX, bMaxY) ||
+        rectWithin(aMinX, aMinY, aMaxX, aMaxY, bMinX, bMinY, bMaxX, bMaxY));
+  }
+
+  /**
+   * Computes whether rectangle a contains rectangle b (touching allowed)
+   */
+  public static boolean rectContains(final double aMinX, final double aMinY, final double aMaxX, final double aMaxY,
+                                     final double bMinX, final double bMinY, final double bMaxX, final double bMaxY) {
+      return !(bMinX < aMinX || bMinY < aMinY || bMaxX > aMaxX || bMaxY > aMaxY);
+  }
+
+  /**
+   * Computes whether a rectangle intersects another rectangle (crosses, within, touching, etc)
+   */
+  public static boolean rectIntersects(final double aMinX, final double aMinY, final double aMaxX, final double aMaxY,
+                                       final double bMinX, final double bMinY, final double bMaxX, final double bMaxY) {
+    return !((aMaxX < bMinX || aMinX > bMaxX || aMaxY < bMinY || aMinY > bMaxY) );
+  }
+
+  /**
+   * Computes whether a rectangle crosses a shape. (touching not allowed)
+   */
+  public static final boolean rectCrossesPoly(final double rMinX, final double rMinY, final double rMaxX,
+                                              final double rMaxY, final double[] shapeX, final double[] shapeY,
+                                              final double sMinX, final double sMinY, final double sMaxX,
+                                              final double sMaxY) {
+    // short-circuit: if the bounding boxes are disjoint then the shape does not cross
+    if (rectDisjoint(rMinX, rMinY, rMaxX, rMaxY, sMinX, sMinY, sMaxX, sMaxY))
+      return false;
+
+    final double[][] bbox = new double[][] { {rMinX, rMinY}, {rMaxX, rMinY}, {rMaxX, rMaxY}, {rMinX, rMaxY}, {rMinX, rMinY} };
+    final int polyLength = shapeX.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 = shapeY[p+1]-shapeY[p];
+        b2 = shapeX[p]-shapeX[p+1];
+        // compute determinant
+        d = a1*b2 - a2*b1;
+        if (d != 0) {
+          // lines are not parallel, check intersecting points
+          c2 = a2*shapeX[p+1] + b2*shapeY[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]) - TOLERANCE;
+          x01 = StrictMath.max(bbox[b][0], bbox[b+1][0]) + TOLERANCE;
+          y00 = StrictMath.min(bbox[b][1], bbox[b+1][1]) - TOLERANCE;
+          y01 = StrictMath.max(bbox[b][1], bbox[b+1][1]) + TOLERANCE;
+          x10 = StrictMath.min(shapeX[p], shapeX[p+1]) - TOLERANCE;
+          x11 = StrictMath.max(shapeX[p], shapeX[p+1]) + TOLERANCE;
+          y10 = StrictMath.min(shapeY[p], shapeY[p+1]) - TOLERANCE;
+          y11 = StrictMath.max(shapeY[p], shapeY[p+1]) + 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;
+  }
+
+  /**
+   * Computes whether a rectangle is within a given polygon (shared boundaries allowed)
+   */
+  public static boolean rectWithinPoly(final double rMinX, final double rMinY, final double rMaxX, final double rMaxY,
+                                       final double[] shapeX, final double[] shapeY, final double sMinX,
+                                       final double sMinY, final double sMaxX, final double sMaxY) {
+    // check if rectangle crosses poly (to handle concave/pacman polys), then check that all 4 corners
+    // are contained
+    return !(rectCrossesPoly(rMinX, rMinY, rMaxX, rMaxY, shapeX, shapeY, sMinX, sMinY, sMaxX, sMaxY) ||
+        !pointInPolygon(shapeX, shapeY, rMinY, rMinX) || !pointInPolygon(shapeX, shapeY, rMinY, rMaxX) ||
+        !pointInPolygon(shapeX, shapeY, rMaxY, rMaxX) || !pointInPolygon(shapeX, shapeY, rMaxY, rMinX));
+  }
+
+  public static boolean isValidLat(double lat) {
+    return Double.isNaN(lat) == false && lat >= MIN_LAT_INCL && lat <= MAX_LAT_INCL;
+  }
+
+  public static boolean isValidLon(double lon) {
+    return Double.isNaN(lon) == false && lon >= MIN_LON_INCL && lon <= MAX_LON_INCL;
+  }
+
+  // nocommit add these to unit test
+  public static void main(String[] args) {
+    // 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};
+
+    // bbox
+    double xMinA = -10;
+    double xMaxA = 10;
+    double yMinA = -10;
+    double yMaxA = 10;
+
+    // candidate cell
+    double xMin = 2;//-5;
+    double xMax = 11;//0.000001;
+    double yMin = -1;//0;
+    double yMax = 1;//5;
+
+    System.out.println("CELL AND POLY\n--------------");
+    // cell crosses poly (touching not allowed)
+    if (rectCrossesPoly(xMin, yMin, xMax, yMax, px, py, -10, -10, 10, 10)) {
+      System.out.println("CROSSES");
+    } else {
+      System.out.println("NO CROSS");
+    }
+
+    // cell within poly (touching allowed)
+    if (rectWithinPoly(xMin, yMin, xMax, yMax, px, py, -10, -10, 10, 10)) {
+      System.out.println("WITHIN");
+    } else {
+      System.out.println("NOT WITHIN");
+    }
+
+    System.out.println("\nCELL AND BBOX\n--------------");
+    // cell crosses poly (touching not allowed)
+    if (rectCrosses(xMin, yMin, xMax, yMax, xMinA, yMinA, xMaxA, yMaxA)) {
+      System.out.println("CROSSES");
+    } else {
+      System.out.println("NO CROSS");
+    }
+
+    // cell within poly (touching allowed)
+    if (rectWithin(xMin, yMin, xMax, yMax, xMinA, yMinA, xMaxA, yMaxA)) {
+      System.out.println("WITHIN");
+    } else {
+      System.out.println("NOT WITHIN");
+    }
+  }
+}
diff --git a/lucene/sandbox/src/java/org/apache/lucene/util/package.html b/lucene/sandbox/src/java/org/apache/lucene/util/package.html
new file mode 100644
index 0000000..9a8856c
--- /dev/null
+++ b/lucene/sandbox/src/java/org/apache/lucene/util/package.html
@@ -0,0 +1,28 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<!--
+ 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.
+-->
+
+<!-- not a package-info.java, because we already defined this package in core/ -->
+
+<html>
+<head>
+   <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+</head>
+<body>
+This package contains utility APIs, currently for geospatial searching.
+</body>
+</html>
diff --git a/lucene/sandbox/src/test/org/apache/lucene/search/TestGeoPointQuery.java b/lucene/sandbox/src/test/org/apache/lucene/search/TestGeoPointQuery.java
new file mode 100644
index 0000000..d45a44f
--- /dev/null
+++ b/lucene/sandbox/src/test/org/apache/lucene/search/TestGeoPointQuery.java
@@ -0,0 +1,419 @@
+package org.apache.lucene.search;
+
+/*
+ * 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.
+ */
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+
+import org.apache.lucene.analysis.MockAnalyzer;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.FieldType;
+import org.apache.lucene.document.GeoPointField;
+import org.apache.lucene.document.NumericDocValuesField;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.index.MultiDocValues;
+import org.apache.lucene.index.NumericDocValues;
+import org.apache.lucene.index.RandomIndexWriter;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.util.FixedBitSet;
+import org.apache.lucene.util.GeoUtils;
+import org.apache.lucene.util.IOUtils;
+import org.apache.lucene.util.LuceneTestCase;
+import org.apache.lucene.util.TestUtil;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/**
+ * Unit testing for basic GeoPoint query logic
+ *
+ * @lucene.experimental
+ */
+public class TestGeoPointQuery extends LuceneTestCase {
+  private static Directory directory = null;
+  private static IndexReader reader = null;
+  private static IndexSearcher searcher = null;
+
+  private static final String FIELD_NAME = "geoField";
+
+  // Global bounding box we will "cover" in the random test; we have to make this "smallish" else the queries take very long:
+  private static double originLat;
+  private static double originLon;
+  private static double range;
+
+  @BeforeClass
+  public static void beforeClass() throws Exception {
+    directory = newDirectory();
+    
+    // nocommit when we randomly test the full lat/lon space it can result in very very slow query times ... is this expected?
+
+    // Between 1.0 and 3.0:
+    range = 2*(random().nextDouble() + 0.5);
+    originLon = GeoUtils.MIN_LON_INCL + range + (GeoUtils.MAX_LON_INCL - GeoUtils.MIN_LON_INCL - 2*range) * random().nextDouble();
+    originLat = GeoUtils.MIN_LAT_INCL + range + (GeoUtils.MAX_LAT_INCL - GeoUtils.MIN_LAT_INCL - 2*range) * random().nextDouble();
+    if (VERBOSE) {
+      System.out.println("TEST: originLon=" + originLon + " originLat=" + originLat + " range=" + range);
+    }
+    RandomIndexWriter writer = new RandomIndexWriter(random(), directory,
+            newIndexWriterConfig(new MockAnalyzer(random()))
+                    .setMaxBufferedDocs(TestUtil.nextInt(random(), 100, 1000))
+                    .setMergePolicy(newLogMergePolicy()));
+
+    // create some simple geo points
+    final FieldType storedPoint = new FieldType(GeoPointField.TYPE_STORED);
+    // this is a simple systematic test
+    GeoPointField[] pts = new GeoPointField[] {
+         new GeoPointField(FIELD_NAME, -96.4538113027811, 32.94823588839368, storedPoint),
+         new GeoPointField(FIELD_NAME, -96.7759895324707, 32.7559529921407, storedPoint),
+         new GeoPointField(FIELD_NAME, -96.77701950073242, 32.77866942010977, storedPoint),
+         new GeoPointField(FIELD_NAME, -96.7706036567688, 32.7756745755423, storedPoint),
+         new GeoPointField(FIELD_NAME, -139.73458170890808, 27.703618681345585, storedPoint),
+         new GeoPointField(FIELD_NAME, -96.65084838867188, 33.06047141970814, storedPoint),
+         new GeoPointField(FIELD_NAME, -96.7772, 32.778650, storedPoint)};
+
+    for (GeoPointField p : pts) {
+        Document doc = new Document();
+        doc.add(p);
+        writer.addDocument(doc);
+    }
+    reader = writer.getReader();
+    searcher = newSearcher(reader);
+    writer.close();
+  }
+
+  @AfterClass
+  public static void afterClass() throws Exception {
+    searcher = null;
+    reader.close();
+    reader = null;
+    directory.close();
+    directory = null;
+  }
+
+  private TopDocs bboxQuery(double minLon, double minLat, double maxLon, double maxLat, int limit) throws Exception {
+    GeoPointInBBoxQuery q = new GeoPointInBBoxQuery(FIELD_NAME, minLon, minLat, maxLon, maxLat);
+    return searcher.search(q, limit);
+  }
+
+  private TopDocs polygonQuery(double[] lon, double[] lat, int limit) throws Exception {
+    GeoPointInPolygonQuery q = new GeoPointInPolygonQuery(FIELD_NAME, lon, lat);
+    return searcher.search(q, limit);
+  }
+
+  @Test
+  public void testBBoxQuery() throws Exception {
+    TopDocs td = bboxQuery(-96.7772, 32.778650, -96.77690000, 32.778950, 5);
+    assertEquals("GeoBoundingBoxQuery failed", 2, td.totalHits);
+  }
+
+  @Test
+  public void testPolyQuery() throws Exception {
+    TopDocs td = polygonQuery( new double[] { -96.7682647, -96.8280029, -96.6288757, -96.4929199,
+                                              -96.6041564, -96.7449188, -96.76826477, -96.7682647},
+      new double[] { 33.073130, 32.9942669, 32.938386, 33.0374494,
+                     33.1369762, 33.1162747, 33.073130, 33.073130}, 5);
+    assertEquals("GeoPolygonQuery failed", td.totalHits, 1);
+  }
+
+  public void testRandomTiny() throws Exception {
+    // Make sure single-leaf-node case is OK:
+    doTestRandom(10);
+  }
+
+  public void testRandom() throws Exception {
+    doTestRandom(10000);
+  }
+
+  @Nightly
+  public void testRandomBig() throws Exception {
+    doTestRandom(1000000);
+  }
+
+  private void doTestRandom(int count) throws Exception {
+
+    int numPoints = atLeast(count);
+
+    if (VERBOSE) {
+      System.out.println("TEST: numPoints=" + numPoints);
+    }
+
+    double[] lats = new double[numPoints];
+    double[] lons = new double[numPoints];
+
+    boolean haveRealDoc = false;
+
+    for (int docID=0;docID<numPoints;docID++) {
+      int x = random().nextInt(20);
+      if (x == 17) {
+        // Some docs don't have a point:
+        lats[docID] = Double.NaN;
+        if (VERBOSE) {
+          //System.out.println("  doc=" + docID + " is missing");
+        }
+        continue;
+      }
+
+      if (docID > 0 && x < 3 && haveRealDoc) {
+        int oldDocID;
+        while (true) {
+          oldDocID = random().nextInt(docID);
+          if (Double.isNaN(lats[oldDocID]) == false) {
+            break;
+          }
+        }
+
+        if (x == 0) {
+          // Identical lat to old point
+          lats[docID] = lats[oldDocID];
+          lons[docID] = randomLon();
+          if (VERBOSE) {
+            //System.out.println("  doc=" + docID + " lat=" + lats[docID] + " lon=" + lons[docID] + " (same lat as doc=" + oldDocID + ")");
+          }
+        } else if (x == 1) {
+          // Identical lon to old point
+          lats[docID] = randomLat();
+          lons[docID] = lons[oldDocID];
+          if (VERBOSE) {
+            //System.out.println("  doc=" + docID + " lat=" + lats[docID] + " lon=" + lons[docID] + " (same lon as doc=" + oldDocID + ")");
+          }
+        } else {
+          assert x == 2;
+          // Fully identical point:
+          lats[docID] = lats[oldDocID];
+          lons[docID] = lons[oldDocID];
+          if (VERBOSE) {
+            //System.out.println("  doc=" + docID + " lat=" + lats[docID] + " lon=" + lons[docID] + " (same lat/lon as doc=" + oldDocID + ")");
+          }
+        }
+      } else {
+        lats[docID] = randomLat();
+        lons[docID] = randomLon();
+        haveRealDoc = true;
+        if (VERBOSE) {
+          //System.out.println("  doc=" + docID + " lat=" + lats[docID] + " lon=" + lons[docID]);
+        }
+      }
+    }
+
+    verify(lats, lons);
+  }
+
+  private static void verify(double[] lats, double[] lons) throws Exception {
+    IndexWriterConfig iwc = newIndexWriterConfig();
+    Directory dir;
+    if (lats.length > 100000) {
+      dir = newFSDirectory(createTempDir("TestGeoPointQuery"));
+    } else {
+      dir = newDirectory();
+    }
+    Set<Integer> deleted = new HashSet<>();
+    // RandomIndexWriter is too slow here:
+    IndexWriter w = new IndexWriter(dir, iwc);
+    for(int id=0;id<lats.length;id++) {
+      Document doc = new Document();
+      doc.add(newStringField("id", ""+id, Field.Store.NO));
+      doc.add(new NumericDocValuesField("id", id));
+      if (Double.isNaN(lats[id]) == false) {
+        if (VERBOSE) {
+          System.out.println("  id=" + id + " lat=" + lats[id] + " lon=" + lons[id]);
+        }
+        doc.add(new GeoPointField(FIELD_NAME, lons[id], lats[id], Field.Store.NO));
+      } else if (VERBOSE) {
+        System.out.println("  id=" + id + " skipped");
+      }
+      w.addDocument(doc);
+      if (id > 0 && random().nextInt(100) == 42) {
+        int idToDelete = random().nextInt(id);
+        w.deleteDocuments(new Term("id", ""+idToDelete));
+        deleted.add(idToDelete);
+        if (VERBOSE) {
+          System.out.println("  delete id=" + idToDelete);
+        }
+      }
+    }
+    if (random().nextBoolean()) {
+      w.forceMerge(1);
+    }
+    IndexReader r = DirectoryReader.open(w, true);
+    w.close();
+
+    IndexSearcher s = newSearcher(r);
+
+    // Make sure queries are thread safe:
+    int numThreads = TestUtil.nextInt(random(), 2, 5);
+    // nocommit remove:
+    numThreads = 1;
+
+    List<Thread> threads = new ArrayList<>();
+    final int iters = atLeast(100);
+
+    final CountDownLatch startingGun = new CountDownLatch(1);
+
+    for(int i=0;i<numThreads;i++) {
+      Thread thread = new Thread() {
+          @Override
+          public void run() {
+            try {
+              _run();
+            } catch (Exception e) {
+              throw new RuntimeException(e);
+            }
+          }
+
+          private void _run() throws Exception {
+            startingGun.await();
+
+            NumericDocValues docIDToID = MultiDocValues.getNumericValues(r, "id");
+
+            for (int iter=0;iter<iters;iter++) {
+              double lat0 = randomLat();
+              double lat1 = randomLat();
+              double lon0 = randomLon();
+              double lon1 = randomLon();
+
+              if (lat1 < lat0) {
+                double x = lat0;
+                lat0 = lat1;
+                lat1 = x;
+              }
+
+              if (lon1 < lon0) {
+                double x = lon0;
+                lon0 = lon1;
+                lon1 = x;
+              }
+
+              if (VERBOSE) {
+                System.out.println("\nTEST: iter=" + iter + " lat=" + lat0 + " TO " + lat1 + " lon=" + lon0 + " TO " + lon1);
+              }
+
+              Query query;
+              boolean tooBigBBox = false;
+
+              double bboxLat0 = lat0;
+              double bboxLat1 = lat1;
+              double bboxLon0 = lon0;
+              double bboxLon1 = lon1;
+
+              // nocommit remove true || so we sometimes test polygon query too:
+              if (true || random().nextBoolean()) {
+                query = new GeoPointInBBoxQuery(FIELD_NAME, lon0, lat0, lon1, lat1);
+              } else {
+                // nocommit why does test fail if we enable this?  it should pass?
+                if (false && random().nextBoolean()) {
+                  // Intentionally pass a "too big" bounding box:
+                  double pct = random().nextDouble()*0.5;
+                  double width = lon1-lon0;
+                  bboxLon0 = Math.max(-180.0, lon0-width*pct);
+                  bboxLon1 = Math.min(180.0, lon1+width*pct);
+                  double height = lat1-lat0;
+                  bboxLat0 = Math.max(-90.0, lat0-height*pct);
+                  bboxLat1 = Math.min(90.0, lat1+height*pct);
+                  tooBigBBox = true;
+                }
+                double[] lats = new double[5];
+                double[] lons = new double[5];
+                lats[0] = bboxLat0;
+                lons[0] = bboxLon0;
+                lats[1] = bboxLat1;
+                lons[1] = bboxLon0;
+                lats[2] = bboxLat1;
+                lons[2] = bboxLon1;
+                lats[3] = bboxLat0;
+                lons[3] = bboxLon1;
+                lats[4] = bboxLat0;
+                lons[4] = bboxLon0;
+                query = new GeoPointInPolygonQuery(FIELD_NAME, bboxLon0, bboxLat0, bboxLon1, bboxLat1, lons, lats);
+              }
+
+              final FixedBitSet hits = new FixedBitSet(r.maxDoc());
+              s.search(query, new SimpleCollector() {
+
+                  private int docBase;
+
+                  @Override
+                  public boolean needsScores() {
+                    return false;
+                  }
+
+                  @Override
+                  protected void doSetNextReader(LeafReaderContext context) throws IOException {
+                    docBase = context.docBase;
+                  }
+
+                  @Override
+                  public void collect(int doc) {
+                    hits.set(docBase+doc);
+                  }
+                });
+
+              for(int docID=0;docID<r.maxDoc();docID++) {
+                int id = (int) docIDToID.get(docID);
+                boolean expected = deleted.contains(id) == false && rectContainsPointEnc(lat0, lat1, lon0, lon1, lats[id], lons[id]);
+                if (hits.get(docID) != expected) {
+                  System.out.println(Thread.currentThread().getName() + ": iter=" + iter + " id=" + id + " docID=" + docID + " lat=" + lats[id] + " lon=" + lons[id] + " (bbox: lat=" + lat0 + " TO " + lat1 + " lon=" + lon0 + " TO " + lon1 + ") expected " + expected + " but got: " + hits.get(docID) + " deleted?=" + deleted.contains(id) + " query=" + query);
+                  if (tooBigBBox) {
+                    System.out.println("  passed too-big bbox: lat=" + bboxLat0 + " TO " + bboxLat1 + " lon=" + bboxLon0 + " TO " + bboxLon1);
+                  }
+                  fail("wrong result");
+                }
+              }
+            }
+          }
+        };
+      thread.setName("T" + i);
+      thread.start();
+      threads.add(thread);
+    }
+
+    startingGun.countDown();
+    for(Thread thread : threads) {
+      thread.join();
+    }
+
+    IOUtils.close(r, dir);
+  }
+
+  private static boolean rectContainsPointEnc(double rectLatMin, double rectLatMax,
+                                              double rectLonMin, double rectLonMax,
+                                              double pointLat, double pointLon) {
+    if (Double.isNaN(pointLat)) {
+      return false;
+    }
+
+    return GeoUtils.bboxContains(pointLon, pointLat, rectLonMin, rectLatMin, rectLonMax, rectLatMax);
+  }
+
+  private static double randomLat() {
+    return originLat + range * (random().nextDouble()-0.5);
+  }
+
+  private static double randomLon() {
+    return originLon + range * (random().nextDouble()-0.5);
+  }
+}