You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@lucene.apache.org by iv...@apache.org on 2021/01/08 07:17:23 UTC

[lucene-solr] branch master updated: LUCENE-9641: Support for spatial relationships in LatLonPoint (#2155)

This is an automated email from the ASF dual-hosted git repository.

ivera pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/lucene-solr.git


The following commit(s) were added to refs/heads/master by this push:
     new 14009f4  LUCENE-9641: Support for spatial relationships in LatLonPoint (#2155)
14009f4 is described below

commit 14009f4424d847ce84fa23a71d8066dfe6c70da0
Author: Ignacio Vera <iv...@apache.org>
AuthorDate: Fri Jan 8 08:16:58 2021 +0100

    LUCENE-9641: Support for spatial relationships in LatLonPoint (#2155)
    
    Equivalent to LatLonShape, LatLonPoint can be queried now using spatial relationships.
---
 lucene/CHANGES.txt                                 |   4 +-
 .../lucene/document/LatLonDocValuesField.java      |  37 ++-
 .../LatLonDocValuesPointInGeometryQuery.java       | 166 -----------
 .../lucene/document/LatLonDocValuesQuery.java      | 272 ++++++++++++++++++
 .../org/apache/lucene/document/LatLonPoint.java    |  36 ++-
 .../document/LatLonPointInGeometryQuery.java       | 300 --------------------
 .../apache/lucene/document/LatLonPointQuery.java   | 182 ++++++++++++
 .../document/LatLonShapeBoundingBoxQuery.java      | 274 +++++++++---------
 .../apache/lucene/document/LatLonShapeQuery.java   | 270 +++++++++---------
 .../{ShapeQuery.java => SpatialQuery.java}         | 314 +++++++++++----------
 .../org/apache/lucene/document/XYShapeQuery.java   | 268 ++++++++++--------
 .../org/apache/lucene/geo/GeoEncodingUtils.java    |  40 +--
 .../src/java/org/apache/lucene/geo/Point2D.java    |  14 +-
 .../document/BaseLatLonDocValueTestCase.java       |  72 +++++
 .../lucene/document/BaseLatLonPointTestCase.java   | 139 +++++++++
 .../lucene/document/BaseLatLonShapeTestCase.java   | 283 +++----------------
 .../lucene/document/BaseLatLonSpatialTestCase.java | 220 +++++++++++++++
 ...ShapeTestCase.java => BaseSpatialTestCase.java} |   6 +-
 .../lucene/document/BaseXYShapeTestCase.java       |   2 +-
 .../TestLatLonDocValuesMultiPointPointQueries.java |  99 +++++++
 .../TestLatLonDocValuesPointPointQueries.java      |  71 +++++
 .../document/TestLatLonMultiPointPointQueries.java |  99 +++++++
 .../document/TestLatLonPointPointQueries.java      |  69 +++++
 .../apache/lucene/document/TestLatLonShape.java    |   9 +
 .../lucene/search/TestLatLonDocValuesQueries.java  |   4 +-
 .../lucene/search/TestLatLonPointQueries.java      |   3 +-
 26 files changed, 1973 insertions(+), 1280 deletions(-)

diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt
index bb5bdb3..0bda4fc 100644
--- a/lucene/CHANGES.txt
+++ b/lucene/CHANGES.txt
@@ -237,7 +237,9 @@ New Features
 * LUCENE-9572: TypeAsSynonymFilter has been enhanced support ignoring some types, and to allow
   the generated synonyms to copy some or all flags from the original token (Gus Heck).
 
-* LUCENE-9552: New LatLonPoint query that accepts an array of LatLonGeometries. (Ignacio Vera)  
+* LUCENE-9552: New LatLonPoint query that accepts an array of LatLonGeometries. (Ignacio Vera) 
+
+* LUCENE-9641: LatLonPoint query support for spatial relationships. (Ignacio Vera) 
 
 * LUCENE-9553: New XYPoint query that accepts an array of XYGeometries. (Ignacio Vera)  
 
diff --git a/lucene/core/src/java/org/apache/lucene/document/LatLonDocValuesField.java b/lucene/core/src/java/org/apache/lucene/document/LatLonDocValuesField.java
index 8c80363..1a5dfa8 100644
--- a/lucene/core/src/java/org/apache/lucene/document/LatLonDocValuesField.java
+++ b/lucene/core/src/java/org/apache/lucene/document/LatLonDocValuesField.java
@@ -23,6 +23,7 @@ import static org.apache.lucene.geo.GeoEncodingUtils.encodeLongitude;
 
 import org.apache.lucene.geo.Circle;
 import org.apache.lucene.geo.LatLonGeometry;
+import org.apache.lucene.geo.Point;
 import org.apache.lucene.geo.Polygon;
 import org.apache.lucene.geo.Rectangle;
 import org.apache.lucene.index.DocValuesType;
@@ -209,7 +210,7 @@ public class LatLonDocValuesField extends Field {
   public static Query newSlowDistanceQuery(
       String field, double latitude, double longitude, double radiusMeters) {
     Circle circle = new Circle(latitude, longitude, radiusMeters);
-    return newSlowGeometryQuery(field, circle);
+    return newSlowGeometryQuery(field, ShapeField.QueryRelation.INTERSECTS, circle);
   }
 
   /**
@@ -225,28 +226,42 @@ public class LatLonDocValuesField extends Field {
    *     null polygon.
    */
   public static Query newSlowPolygonQuery(String field, Polygon... polygons) {
-    return newSlowGeometryQuery(field, polygons);
+    return newSlowGeometryQuery(field, ShapeField.QueryRelation.INTERSECTS, polygons);
   }
 
   /**
-   * Create a query for matching points within the supplied geometries. Line geometries are not
-   * supported. This query is usually slow as it does not use an index structure and needs to verify
-   * documents one-by-one in order to know whether they match. It is best used wrapped in an {@link
+   * Create a query for matching one or more geometries against the provided {@link
+   * ShapeField.QueryRelation}. Line geometries are not supported for WITHIN relationship. This
+   * query is usually slow as it does not use an index structure and needs to verify documents
+   * one-by-one in order to know whether they match. It is best used wrapped in an {@link
    * IndexOrDocValuesQuery} alongside a {@link LatLonPoint#newGeometryQuery(String,
-   * LatLonGeometry...)}.
+   * ShapeField.QueryRelation, LatLonGeometry...)}.
    *
    * @param field field name. must not be null.
+   * @param queryRelation The relation the points needs to satisfy with the provided geometries,
+   *     must not be null.
    * @param latLonGeometries array of LatLonGeometries. must not be null or empty.
    * @return query matching points within the given polygons.
-   * @throws IllegalArgumentException if {@code field} is null, {@code latLonGeometries} is null,
-   *     empty or contain a null or line geometry.
+   * @throws IllegalArgumentException if {@code field} is null, {@code queryRelation} is null,
+   *     {@code latLonGeometries} is null, empty or contain a null or line geometry.
    */
-  public static Query newSlowGeometryQuery(String field, LatLonGeometry... latLonGeometries) {
-    if (latLonGeometries.length == 1 && latLonGeometries[0] instanceof Rectangle) {
+  public static Query newSlowGeometryQuery(
+      String field, ShapeField.QueryRelation queryRelation, LatLonGeometry... latLonGeometries) {
+    if (queryRelation == ShapeField.QueryRelation.INTERSECTS
+        && latLonGeometries.length == 1
+        && latLonGeometries[0] instanceof Rectangle) {
       LatLonGeometry geometry = latLonGeometries[0];
       Rectangle rect = (Rectangle) geometry;
       return newSlowBoxQuery(field, rect.minLat, rect.maxLat, rect.minLon, rect.maxLon);
     }
-    return new LatLonDocValuesPointInGeometryQuery(field, latLonGeometries);
+    if (queryRelation == ShapeField.QueryRelation.CONTAINS) {
+      for (LatLonGeometry geometry : latLonGeometries) {
+        if ((geometry instanceof Point) == false) {
+          return new MatchNoDocsQuery(
+              "Contains LatLonDocValuesField.newSlowGeometryQuery with non-point geometries");
+        }
+      }
+    }
+    return new LatLonDocValuesQuery(field, queryRelation, latLonGeometries);
   }
 }
diff --git a/lucene/core/src/java/org/apache/lucene/document/LatLonDocValuesPointInGeometryQuery.java b/lucene/core/src/java/org/apache/lucene/document/LatLonDocValuesPointInGeometryQuery.java
deleted file mode 100644
index 5bb1550..0000000
--- a/lucene/core/src/java/org/apache/lucene/document/LatLonDocValuesPointInGeometryQuery.java
+++ /dev/null
@@ -1,166 +0,0 @@
-/*
- * 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.document;
-
-import java.io.IOException;
-import java.util.Arrays;
-import org.apache.lucene.geo.Component2D;
-import org.apache.lucene.geo.GeoEncodingUtils;
-import org.apache.lucene.geo.LatLonGeometry;
-import org.apache.lucene.geo.Line;
-import org.apache.lucene.index.DocValues;
-import org.apache.lucene.index.LeafReaderContext;
-import org.apache.lucene.index.SortedNumericDocValues;
-import org.apache.lucene.search.ConstantScoreScorer;
-import org.apache.lucene.search.ConstantScoreWeight;
-import org.apache.lucene.search.IndexSearcher;
-import org.apache.lucene.search.Query;
-import org.apache.lucene.search.QueryVisitor;
-import org.apache.lucene.search.ScoreMode;
-import org.apache.lucene.search.Scorer;
-import org.apache.lucene.search.TwoPhaseIterator;
-import org.apache.lucene.search.Weight;
-
-/** Geometry query for {@link LatLonDocValuesField}. */
-public class LatLonDocValuesPointInGeometryQuery extends Query {
-
-  private final String field;
-  private final LatLonGeometry[] geometries;
-
-  LatLonDocValuesPointInGeometryQuery(String field, LatLonGeometry... geometries) {
-    if (field == null) {
-      throw new IllegalArgumentException("field must not be null");
-    }
-    if (geometries == null) {
-      throw new IllegalArgumentException("geometries must not be null");
-    }
-    if (geometries.length == 0) {
-      throw new IllegalArgumentException("geometries must not be empty");
-    }
-    for (int i = 0; i < geometries.length; i++) {
-      if (geometries[i] == null) {
-        throw new IllegalArgumentException("geometries[" + i + "] must not be null");
-      }
-      if (geometries[i] instanceof Line) {
-        throw new IllegalArgumentException(
-            "LatLonDocValuesPointInGeometryQuery does not support queries with line geometries");
-      }
-    }
-    this.field = field;
-    this.geometries = geometries;
-  }
-
-  @Override
-  public String toString(String field) {
-    StringBuilder sb = new StringBuilder();
-    if (!this.field.equals(field)) {
-      sb.append(this.field);
-      sb.append(':');
-    }
-    sb.append("geometries(").append(Arrays.toString(geometries));
-    return sb.append(")").toString();
-  }
-
-  @Override
-  public boolean equals(Object obj) {
-    if (sameClassAs(obj) == false) {
-      return false;
-    }
-    LatLonDocValuesPointInGeometryQuery other = (LatLonDocValuesPointInGeometryQuery) obj;
-    return field.equals(other.field) && Arrays.equals(geometries, other.geometries);
-  }
-
-  @Override
-  public int hashCode() {
-    int h = classHash();
-    h = 31 * h + field.hashCode();
-    h = 31 * h + Arrays.hashCode(geometries);
-    return h;
-  }
-
-  @Override
-  public void visit(QueryVisitor visitor) {
-    if (visitor.acceptField(field)) {
-      visitor.visitLeaf(this);
-    }
-  }
-
-  @Override
-  public Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, float boost)
-      throws IOException {
-    final Component2D tree = LatLonGeometry.create(geometries);
-
-    if (tree.getMinY() > tree.getMaxY()) {
-      // encodeLatitudeCeil may cause minY to be > maxY iff
-      // the delta between the longitude < the encoding resolution
-      return new ConstantScoreWeight(this, boost) {
-        @Override
-        public Scorer scorer(LeafReaderContext context) {
-          return null;
-        }
-
-        @Override
-        public boolean isCacheable(LeafReaderContext ctx) {
-          return false;
-        }
-      };
-    }
-
-    final GeoEncodingUtils.Component2DPredicate component2DPredicate =
-        GeoEncodingUtils.createComponentPredicate(tree);
-
-    return new ConstantScoreWeight(this, boost) {
-
-      @Override
-      public Scorer scorer(LeafReaderContext context) throws IOException {
-        final SortedNumericDocValues values = context.reader().getSortedNumericDocValues(field);
-        if (values == null) {
-          return null;
-        }
-
-        final TwoPhaseIterator iterator =
-            new TwoPhaseIterator(values) {
-
-              @Override
-              public boolean matches() throws IOException {
-                for (int i = 0, count = values.docValueCount(); i < count; ++i) {
-                  final long value = values.nextValue();
-                  final int lat = (int) (value >>> 32);
-                  final int lon = (int) (value & 0xFFFFFFFF);
-                  if (component2DPredicate.test(lat, lon)) {
-                    return true;
-                  }
-                }
-                return false;
-              }
-
-              @Override
-              public float matchCost() {
-                return 1000f; // TODO: what should it be?
-              }
-            };
-        return new ConstantScoreScorer(this, boost, scoreMode, iterator);
-      }
-
-      @Override
-      public boolean isCacheable(LeafReaderContext ctx) {
-        return DocValues.isCacheable(ctx, field);
-      }
-    };
-  }
-}
diff --git a/lucene/core/src/java/org/apache/lucene/document/LatLonDocValuesQuery.java b/lucene/core/src/java/org/apache/lucene/document/LatLonDocValuesQuery.java
new file mode 100644
index 0000000..8e8120f
--- /dev/null
+++ b/lucene/core/src/java/org/apache/lucene/document/LatLonDocValuesQuery.java
@@ -0,0 +1,272 @@
+/*
+ * 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.document;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.apache.lucene.geo.Component2D;
+import org.apache.lucene.geo.GeoEncodingUtils;
+import org.apache.lucene.geo.LatLonGeometry;
+import org.apache.lucene.geo.Line;
+import org.apache.lucene.geo.Point;
+import org.apache.lucene.index.DocValues;
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.index.SortedNumericDocValues;
+import org.apache.lucene.search.ConstantScoreScorer;
+import org.apache.lucene.search.ConstantScoreWeight;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.QueryVisitor;
+import org.apache.lucene.search.ScoreMode;
+import org.apache.lucene.search.Scorer;
+import org.apache.lucene.search.TwoPhaseIterator;
+import org.apache.lucene.search.Weight;
+
+/**
+ * Finds all previously indexed geo points that comply the given {@link ShapeField.QueryRelation}
+ * with the specified array of {@link LatLonGeometry}.
+ *
+ * <p>The field must be indexed using {@link LatLonDocValuesField} added per document.
+ */
+class LatLonDocValuesQuery extends Query {
+
+  private final String field;
+  private final LatLonGeometry[] geometries;
+  private final ShapeField.QueryRelation queryRelation;
+  private final Component2D component2D;
+
+  LatLonDocValuesQuery(
+      String field, ShapeField.QueryRelation queryRelation, LatLonGeometry... geometries) {
+    if (field == null) {
+      throw new IllegalArgumentException("field must not be null");
+    }
+    if (queryRelation == null) {
+      throw new IllegalArgumentException("queryRelation must not be null");
+    }
+    if (queryRelation == ShapeField.QueryRelation.WITHIN) {
+      for (LatLonGeometry geometry : geometries) {
+        if (geometry instanceof Line) {
+          // TODO: line queries do not support within relations
+          throw new IllegalArgumentException(
+              "LatLonDocValuesPointQuery does not support "
+                  + ShapeField.QueryRelation.WITHIN
+                  + " queries with line geometries");
+        }
+      }
+    }
+    if (queryRelation == ShapeField.QueryRelation.CONTAINS) {
+      for (LatLonGeometry geometry : geometries) {
+        if ((geometry instanceof Point) == false) {
+          throw new IllegalArgumentException(
+              "LatLonDocValuesPointQuery does not support "
+                  + ShapeField.QueryRelation.CONTAINS
+                  + " queries with non-points geometries");
+        }
+      }
+    }
+    this.field = field;
+    this.geometries = geometries;
+    this.queryRelation = queryRelation;
+    this.component2D = LatLonGeometry.create(geometries);
+  }
+
+  @Override
+  public String toString(String field) {
+    StringBuilder sb = new StringBuilder();
+    if (!this.field.equals(field)) {
+      sb.append(this.field);
+      sb.append(':');
+    }
+    sb.append(queryRelation).append(':');
+    sb.append("geometries(").append(Arrays.toString(geometries));
+    return sb.append(")").toString();
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (sameClassAs(obj) == false) {
+      return false;
+    }
+    LatLonDocValuesQuery other = (LatLonDocValuesQuery) obj;
+    return field.equals(other.field)
+        && queryRelation == other.queryRelation
+        && Arrays.equals(geometries, other.geometries);
+  }
+
+  @Override
+  public int hashCode() {
+    int h = classHash();
+    h = 31 * h + field.hashCode();
+    h = 31 * h + queryRelation.hashCode();
+    h = 31 * h + Arrays.hashCode(geometries);
+    return h;
+  }
+
+  @Override
+  public void visit(QueryVisitor visitor) {
+    if (visitor.acceptField(field)) {
+      visitor.visitLeaf(this);
+    }
+  }
+
+  @Override
+  public Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, float boost)
+      throws IOException {
+    final GeoEncodingUtils.Component2DPredicate component2DPredicate =
+        queryRelation == ShapeField.QueryRelation.CONTAINS
+            ? null
+            : GeoEncodingUtils.createComponentPredicate(component2D);
+    return new ConstantScoreWeight(this, boost) {
+
+      @Override
+      public Scorer scorer(LeafReaderContext context) throws IOException {
+        final SortedNumericDocValues values = context.reader().getSortedNumericDocValues(field);
+        if (values == null) {
+          return null;
+        }
+        final TwoPhaseIterator iterator;
+        switch (queryRelation) {
+          case INTERSECTS:
+            iterator = intersects(values, component2DPredicate);
+            break;
+          case WITHIN:
+            iterator = within(values, component2DPredicate);
+            break;
+          case DISJOINT:
+            iterator = disjoint(values, component2DPredicate);
+            break;
+          case CONTAINS:
+            iterator = contains(values, geometries);
+            break;
+          default:
+            throw new IllegalArgumentException(
+                "Invalid query relationship:[" + queryRelation + "]");
+        }
+        return new ConstantScoreScorer(this, boost, scoreMode, iterator);
+      }
+
+      @Override
+      public boolean isCacheable(LeafReaderContext ctx) {
+        return DocValues.isCacheable(ctx, field);
+      }
+    };
+  }
+
+  private TwoPhaseIterator intersects(
+      SortedNumericDocValues values, GeoEncodingUtils.Component2DPredicate component2DPredicate) {
+    return new TwoPhaseIterator(values) {
+      @Override
+      public boolean matches() throws IOException {
+        for (int i = 0, count = values.docValueCount(); i < count; ++i) {
+          final long value = values.nextValue();
+          final int lat = (int) (value >>> 32);
+          final int lon = (int) (value & 0xFFFFFFFF);
+          if (component2DPredicate.test(lat, lon)) {
+            return true;
+          }
+        }
+        return false;
+      }
+
+      @Override
+      public float matchCost() {
+        return 1000f; // TODO: what should it be?
+      }
+    };
+  }
+
+  private TwoPhaseIterator within(
+      SortedNumericDocValues values, GeoEncodingUtils.Component2DPredicate component2DPredicate) {
+    return new TwoPhaseIterator(values) {
+      @Override
+      public boolean matches() throws IOException {
+        for (int i = 0, count = values.docValueCount(); i < count; ++i) {
+          final long value = values.nextValue();
+          final int lat = (int) (value >>> 32);
+          final int lon = (int) (value & 0xFFFFFFFF);
+          if (component2DPredicate.test(lat, lon) == false) {
+            return false;
+          }
+        }
+        return true;
+      }
+
+      @Override
+      public float matchCost() {
+        return 1000f; // TODO: what should it be?
+      }
+    };
+  }
+
+  private TwoPhaseIterator disjoint(
+      SortedNumericDocValues values, GeoEncodingUtils.Component2DPredicate component2DPredicate) {
+    return new TwoPhaseIterator(values) {
+      @Override
+      public boolean matches() throws IOException {
+        for (int i = 0, count = values.docValueCount(); i < count; ++i) {
+          final long value = values.nextValue();
+          final int lat = (int) (value >>> 32);
+          final int lon = (int) (value & 0xFFFFFFFF);
+          if (component2DPredicate.test(lat, lon)) {
+            return false;
+          }
+        }
+        return true;
+      }
+
+      @Override
+      public float matchCost() {
+        return 1000f; // TODO: what should it be?
+      }
+    };
+  }
+
+  private TwoPhaseIterator contains(SortedNumericDocValues values, LatLonGeometry[] geometries) {
+    final List<Component2D> component2Ds = new ArrayList<>(geometries.length);
+    for (int i = 0; i < geometries.length; i++) {
+      component2Ds.add(LatLonGeometry.create(geometries[i]));
+    }
+    return new TwoPhaseIterator(values) {
+      @Override
+      public boolean matches() throws IOException {
+        Component2D.WithinRelation answer = Component2D.WithinRelation.DISJOINT;
+        for (int i = 0, count = values.docValueCount(); i < count; ++i) {
+          final long value = values.nextValue();
+          final double lat = GeoEncodingUtils.decodeLatitude((int) (value >>> 32));
+          final double lon = GeoEncodingUtils.decodeLongitude((int) (value & 0xFFFFFFFF));
+          for (Component2D component2D : component2Ds) {
+            Component2D.WithinRelation relation = component2D.withinPoint(lon, lat);
+            if (relation == Component2D.WithinRelation.NOTWITHIN) {
+              return false;
+            } else if (relation != Component2D.WithinRelation.DISJOINT) {
+              answer = relation;
+            }
+          }
+        }
+        return answer == Component2D.WithinRelation.CANDIDATE;
+      }
+
+      @Override
+      public float matchCost() {
+        return 1000f; // TODO: what should it be?
+      }
+    };
+  }
+}
diff --git a/lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java b/lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java
index 06de3cf..f2d5940 100644
--- a/lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java
+++ b/lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java
@@ -25,6 +25,7 @@ import static org.apache.lucene.geo.GeoEncodingUtils.encodeLongitudeCeil;
 
 import org.apache.lucene.geo.Circle;
 import org.apache.lucene.geo.LatLonGeometry;
+import org.apache.lucene.geo.Point;
 import org.apache.lucene.geo.Polygon;
 import org.apache.lucene.geo.Rectangle;
 import org.apache.lucene.index.FieldInfo;
@@ -292,21 +293,25 @@ public class LatLonPoint extends Field {
    * @see Polygon
    */
   public static Query newPolygonQuery(String field, Polygon... polygons) {
-    return newGeometryQuery(field, polygons);
+    return newGeometryQuery(field, ShapeField.QueryRelation.INTERSECTS, polygons);
   }
 
   /**
-   * Create a query for matching one or more geometries. Line geometries are not supported.
+   * Create a query for matching one or more geometries against the provided {@link
+   * ShapeField.QueryRelation}. Line geometries are not supported for WITHIN relationship.
    *
    * @param field field name. must not be null.
+   * @param queryRelation The relation the points needs to satisfy with the provided geometries,
+   *     must not be null.
    * @param latLonGeometries array of LatLonGeometries. must not be null or empty.
    * @return query matching points within at least one geometry.
-   * @throws IllegalArgumentException if {@code field} is null, {@code latLonGeometries} is null,
-   *     empty or contain a null or line geometry.
+   * @throws IllegalArgumentException if {@code field} is null, {@code queryRelation} is null,
+   *     {@code latLonGeometries} is null, empty or contain a null.
    * @see LatLonGeometry
    */
-  public static Query newGeometryQuery(String field, LatLonGeometry... latLonGeometries) {
-    if (latLonGeometries.length == 1) {
+  public static Query newGeometryQuery(
+      String field, ShapeField.QueryRelation queryRelation, LatLonGeometry... latLonGeometries) {
+    if (queryRelation == ShapeField.QueryRelation.INTERSECTS && latLonGeometries.length == 1) {
       if (latLonGeometries[0] instanceof Rectangle) {
         final Rectangle rect = (Rectangle) latLonGeometries[0];
         return newBoxQuery(field, rect.minLat, rect.maxLat, rect.minLon, rect.maxLon);
@@ -316,7 +321,24 @@ public class LatLonPoint extends Field {
         return newDistanceQuery(field, circle.getLat(), circle.getLon(), circle.getRadius());
       }
     }
-    return new LatLonPointInGeometryQuery(field, latLonGeometries);
+    if (queryRelation == ShapeField.QueryRelation.CONTAINS) {
+      return makeContainsGeometryQuery(field, latLonGeometries);
+    }
+    return new LatLonPointQuery(field, queryRelation, latLonGeometries);
+  }
+
+  private static Query makeContainsGeometryQuery(String field, LatLonGeometry... latLonGeometries) {
+    BooleanQuery.Builder builder = new BooleanQuery.Builder();
+    for (LatLonGeometry geometry : latLonGeometries) {
+      if ((geometry instanceof Point) == false) {
+        return new MatchNoDocsQuery(
+            "Contains LatLonPoint.newGeometryQuery with non-point geometries");
+      }
+      builder.add(
+          new LatLonPointQuery(field, ShapeField.QueryRelation.CONTAINS, geometry),
+          BooleanClause.Occur.MUST);
+    }
+    return new ConstantScoreQuery(builder.build());
   }
 
   /**
diff --git a/lucene/core/src/java/org/apache/lucene/document/LatLonPointInGeometryQuery.java b/lucene/core/src/java/org/apache/lucene/document/LatLonPointInGeometryQuery.java
deleted file mode 100644
index 66ad777..0000000
--- a/lucene/core/src/java/org/apache/lucene/document/LatLonPointInGeometryQuery.java
+++ /dev/null
@@ -1,300 +0,0 @@
-/*
- * 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.document;
-
-import static org.apache.lucene.geo.GeoEncodingUtils.decodeLatitude;
-import static org.apache.lucene.geo.GeoEncodingUtils.decodeLongitude;
-import static org.apache.lucene.geo.GeoEncodingUtils.encodeLatitude;
-import static org.apache.lucene.geo.GeoEncodingUtils.encodeLongitude;
-
-import java.io.IOException;
-import java.util.Arrays;
-import org.apache.lucene.geo.Component2D;
-import org.apache.lucene.geo.GeoEncodingUtils;
-import org.apache.lucene.geo.LatLonGeometry;
-import org.apache.lucene.geo.Line;
-import org.apache.lucene.index.FieldInfo;
-import org.apache.lucene.index.LeafReader;
-import org.apache.lucene.index.LeafReaderContext;
-import org.apache.lucene.index.PointValues;
-import org.apache.lucene.index.PointValues.IntersectVisitor;
-import org.apache.lucene.index.PointValues.Relation;
-import org.apache.lucene.search.ConstantScoreScorer;
-import org.apache.lucene.search.ConstantScoreWeight;
-import org.apache.lucene.search.DocIdSetIterator;
-import org.apache.lucene.search.IndexSearcher;
-import org.apache.lucene.search.Query;
-import org.apache.lucene.search.QueryVisitor;
-import org.apache.lucene.search.ScoreMode;
-import org.apache.lucene.search.Scorer;
-import org.apache.lucene.search.ScorerSupplier;
-import org.apache.lucene.search.Weight;
-import org.apache.lucene.util.DocIdSetBuilder;
-import org.apache.lucene.util.NumericUtils;
-
-/**
- * Finds all previously indexed points that fall within the specified geometries.
- *
- * <p>The field must be indexed with using {@link LatLonPoint} added per document.
- *
- * @lucene.experimental
- */
-final class LatLonPointInGeometryQuery extends Query {
-  final String field;
-  final LatLonGeometry[] geometries;
-
-  LatLonPointInGeometryQuery(String field, LatLonGeometry[] geometries) {
-    if (field == null) {
-      throw new IllegalArgumentException("field must not be null");
-    }
-    if (geometries == null) {
-      throw new IllegalArgumentException("geometries must not be null");
-    }
-    if (geometries.length == 0) {
-      throw new IllegalArgumentException("geometries must not be empty");
-    }
-    for (int i = 0; i < geometries.length; i++) {
-      if (geometries[i] == null) {
-        throw new IllegalArgumentException("geometries[" + i + "] must not be null");
-      }
-      if (geometries[i] instanceof Line) {
-        throw new IllegalArgumentException(
-            "LatLonPointInGeometryQuery does not support queries with line geometries");
-      }
-    }
-    this.field = field;
-    this.geometries = geometries.clone();
-  }
-
-  @Override
-  public void visit(QueryVisitor visitor) {
-    if (visitor.acceptField(field)) {
-      visitor.visitLeaf(this);
-    }
-  }
-
-  private IntersectVisitor getIntersectVisitor(
-      DocIdSetBuilder result,
-      Component2D tree,
-      GeoEncodingUtils.Component2DPredicate component2DPredicate,
-      byte[] minLat,
-      byte[] maxLat,
-      byte[] minLon,
-      byte[] maxLon) {
-    return new IntersectVisitor() {
-      DocIdSetBuilder.BulkAdder adder;
-
-      @Override
-      public void grow(int count) {
-        adder = result.grow(count);
-      }
-
-      @Override
-      public void visit(int docID) {
-        adder.add(docID);
-      }
-
-      @Override
-      public void visit(int docID, byte[] packedValue) {
-        if (component2DPredicate.test(
-            NumericUtils.sortableBytesToInt(packedValue, 0),
-            NumericUtils.sortableBytesToInt(packedValue, Integer.BYTES))) {
-          visit(docID);
-        }
-      }
-
-      @Override
-      public void visit(DocIdSetIterator iterator, byte[] packedValue) throws IOException {
-        if (component2DPredicate.test(
-            NumericUtils.sortableBytesToInt(packedValue, 0),
-            NumericUtils.sortableBytesToInt(packedValue, Integer.BYTES))) {
-          int docID;
-          while ((docID = iterator.nextDoc()) != DocIdSetIterator.NO_MORE_DOCS) {
-            visit(docID);
-          }
-        }
-      }
-
-      @Override
-      public Relation compare(byte[] minPackedValue, byte[] maxPackedValue) {
-        if (Arrays.compareUnsigned(minPackedValue, 0, Integer.BYTES, maxLat, 0, Integer.BYTES) > 0
-            || Arrays.compareUnsigned(maxPackedValue, 0, Integer.BYTES, minLat, 0, Integer.BYTES)
-                < 0
-            || Arrays.compareUnsigned(
-                    minPackedValue,
-                    Integer.BYTES,
-                    Integer.BYTES + Integer.BYTES,
-                    maxLon,
-                    0,
-                    Integer.BYTES)
-                > 0
-            || Arrays.compareUnsigned(
-                    maxPackedValue,
-                    Integer.BYTES,
-                    Integer.BYTES + Integer.BYTES,
-                    minLon,
-                    0,
-                    Integer.BYTES)
-                < 0) {
-          // outside of global bounding box range
-          return Relation.CELL_OUTSIDE_QUERY;
-        }
-
-        double cellMinLat = decodeLatitude(minPackedValue, 0);
-        double cellMinLon = decodeLongitude(minPackedValue, Integer.BYTES);
-        double cellMaxLat = decodeLatitude(maxPackedValue, 0);
-        double cellMaxLon = decodeLongitude(maxPackedValue, Integer.BYTES);
-
-        return tree.relate(cellMinLon, cellMaxLon, cellMinLat, cellMaxLat);
-      }
-    };
-  }
-
-  @Override
-  public Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, float boost)
-      throws IOException {
-    final Component2D tree = LatLonGeometry.create(geometries);
-    if (tree.getMinY() > tree.getMaxY()) {
-      // encodeLatitudeCeil may cause minY to be > maxY iff
-      // the delta between the longitude < the encoding resolution
-      return new ConstantScoreWeight(this, boost) {
-        @Override
-        public Scorer scorer(LeafReaderContext context) {
-          return null;
-        }
-
-        @Override
-        public boolean isCacheable(LeafReaderContext ctx) {
-          return false;
-        }
-      };
-    }
-    final GeoEncodingUtils.Component2DPredicate component2DPredicate =
-        GeoEncodingUtils.createComponentPredicate(tree);
-    // bounding box over all geometries, this can speed up tree intersection/cheaply improve
-    // approximation for complex multi-geometries
-    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(encodeLatitude(tree.getMinY()), minLat, 0);
-    NumericUtils.intToSortableBytes(encodeLatitude(tree.getMaxY()), maxLat, 0);
-    NumericUtils.intToSortableBytes(encodeLongitude(tree.getMinX()), minLon, 0);
-    NumericUtils.intToSortableBytes(encodeLongitude(tree.getMaxX()), maxLon, 0);
-
-    return new ConstantScoreWeight(this, boost) {
-
-      @Override
-      public ScorerSupplier scorerSupplier(LeafReaderContext context) throws IOException {
-        LeafReader reader = context.reader();
-        PointValues values = reader.getPointValues(field);
-        if (values == null) {
-          // No docs in this segment had any points fields
-          return null;
-        }
-        FieldInfo fieldInfo = reader.getFieldInfos().fieldInfo(field);
-        if (fieldInfo == null) {
-          // No docs in this segment indexed this field at all
-          return null;
-        }
-        LatLonPoint.checkCompatible(fieldInfo);
-        final Weight weight = this;
-
-        return new ScorerSupplier() {
-
-          long cost = -1;
-          DocIdSetBuilder result = new DocIdSetBuilder(reader.maxDoc(), values, field);
-          final IntersectVisitor visitor =
-              getIntersectVisitor(
-                  result, tree, component2DPredicate, minLat, maxLat, minLon, maxLon);
-
-          @Override
-          public Scorer get(long leadCost) throws IOException {
-            values.intersect(visitor);
-            return new ConstantScoreScorer(weight, score(), scoreMode, result.build().iterator());
-          }
-
-          @Override
-          public long cost() {
-            if (cost == -1) {
-              // Computing the cost may be expensive, so only do it if necessary
-              cost = values.estimateDocCount(visitor);
-              assert cost >= 0;
-            }
-            return cost;
-          }
-        };
-      }
-
-      @Override
-      public Scorer scorer(LeafReaderContext context) throws IOException {
-        ScorerSupplier scorerSupplier = scorerSupplier(context);
-        if (scorerSupplier == null) {
-          return null;
-        }
-        return scorerSupplier.get(Long.MAX_VALUE);
-      }
-
-      @Override
-      public boolean isCacheable(LeafReaderContext ctx) {
-        return true;
-      }
-    };
-  }
-
-  /** Returns the query field */
-  public String getField() {
-    return field;
-  }
-
-  /** Returns a copy of the internal geometry array */
-  public LatLonGeometry[] getGeometries() {
-    return geometries.clone();
-  }
-
-  @Override
-  public int hashCode() {
-    final int prime = 31;
-    int result = classHash();
-    result = prime * result + field.hashCode();
-    result = prime * result + Arrays.hashCode(geometries);
-    return result;
-  }
-
-  @Override
-  public boolean equals(Object other) {
-    return sameClassAs(other) && equalsTo(getClass().cast(other));
-  }
-
-  private boolean equalsTo(LatLonPointInGeometryQuery other) {
-    return field.equals(other.field) && Arrays.equals(geometries, other.geometries);
-  }
-
-  @Override
-  public String toString(String field) {
-    final StringBuilder sb = new StringBuilder();
-    sb.append(getClass().getSimpleName());
-    sb.append(':');
-    if (this.field.equals(field) == false) {
-      sb.append(" field=");
-      sb.append(this.field);
-      sb.append(':');
-    }
-    sb.append(Arrays.toString(geometries));
-    return sb.toString();
-  }
-}
diff --git a/lucene/core/src/java/org/apache/lucene/document/LatLonPointQuery.java b/lucene/core/src/java/org/apache/lucene/document/LatLonPointQuery.java
new file mode 100644
index 0000000..83b3bf9
--- /dev/null
+++ b/lucene/core/src/java/org/apache/lucene/document/LatLonPointQuery.java
@@ -0,0 +1,182 @@
+/*
+ * 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.document;
+
+import static org.apache.lucene.geo.GeoEncodingUtils.decodeLatitude;
+import static org.apache.lucene.geo.GeoEncodingUtils.decodeLongitude;
+import static org.apache.lucene.geo.GeoEncodingUtils.encodeLatitude;
+import static org.apache.lucene.geo.GeoEncodingUtils.encodeLongitude;
+
+import java.util.Arrays;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import org.apache.lucene.document.ShapeField.QueryRelation;
+import org.apache.lucene.geo.Component2D;
+import org.apache.lucene.geo.GeoEncodingUtils;
+import org.apache.lucene.geo.LatLonGeometry;
+import org.apache.lucene.geo.Line;
+import org.apache.lucene.geo.Point;
+import org.apache.lucene.index.PointValues.Relation;
+import org.apache.lucene.util.NumericUtils;
+
+/**
+ * Finds all previously indexed geo points that comply the given {@link QueryRelation} with the
+ * specified array of {@link LatLonGeometry}.
+ *
+ * <p>The field must be indexed using one or more {@link LatLonPoint} added per document.
+ */
+final class LatLonPointQuery extends SpatialQuery {
+  private final LatLonGeometry[] geometries;
+  private final Component2D component2D;
+
+  /**
+   * Creates a query that matches all indexed shapes to the provided array of {@link LatLonGeometry}
+   */
+  LatLonPointQuery(String field, QueryRelation queryRelation, LatLonGeometry... geometries) {
+    super(field, queryRelation);
+    if (queryRelation == QueryRelation.WITHIN) {
+      for (LatLonGeometry geometry : geometries) {
+        if (geometry instanceof Line) {
+          // TODO: line queries do not support within relations
+          throw new IllegalArgumentException(
+              "LatLonPointQuery does not support "
+                  + QueryRelation.WITHIN
+                  + " queries with line geometries");
+        }
+      }
+    }
+    if (queryRelation == ShapeField.QueryRelation.CONTAINS) {
+      for (LatLonGeometry geometry : geometries) {
+        if ((geometry instanceof Point) == false) {
+          throw new IllegalArgumentException(
+              "LatLonPointQuery does not support "
+                  + ShapeField.QueryRelation.CONTAINS
+                  + " queries with non-points geometries");
+        }
+      }
+    }
+    this.component2D = LatLonGeometry.create(geometries);
+    this.geometries = geometries.clone();
+  }
+
+  @Override
+  protected SpatialVisitor getSpatialVisitor() {
+    final GeoEncodingUtils.Component2DPredicate component2DPredicate =
+        GeoEncodingUtils.createComponentPredicate(component2D);
+    // bounding box over all geometries, this can speed up tree intersection/cheaply improve
+    // approximation for complex multi-geometries
+    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(encodeLatitude(component2D.getMinY()), minLat, 0);
+    NumericUtils.intToSortableBytes(encodeLatitude(component2D.getMaxY()), maxLat, 0);
+    NumericUtils.intToSortableBytes(encodeLongitude(component2D.getMinX()), minLon, 0);
+    NumericUtils.intToSortableBytes(encodeLongitude(component2D.getMaxX()), maxLon, 0);
+
+    return new SpatialVisitor() {
+      @Override
+      protected Relation relate(byte[] minPackedValue, byte[] maxPackedValue) {
+        if (Arrays.compareUnsigned(minPackedValue, 0, Integer.BYTES, maxLat, 0, Integer.BYTES) > 0
+            || Arrays.compareUnsigned(maxPackedValue, 0, Integer.BYTES, minLat, 0, Integer.BYTES)
+                < 0
+            || Arrays.compareUnsigned(
+                    minPackedValue,
+                    Integer.BYTES,
+                    Integer.BYTES + Integer.BYTES,
+                    maxLon,
+                    0,
+                    Integer.BYTES)
+                > 0
+            || Arrays.compareUnsigned(
+                    maxPackedValue,
+                    Integer.BYTES,
+                    Integer.BYTES + Integer.BYTES,
+                    minLon,
+                    0,
+                    Integer.BYTES)
+                < 0) {
+          // outside of global bounding box range
+          return Relation.CELL_OUTSIDE_QUERY;
+        }
+
+        double cellMinLat = decodeLatitude(minPackedValue, 0);
+        double cellMinLon = decodeLongitude(minPackedValue, Integer.BYTES);
+        double cellMaxLat = decodeLatitude(maxPackedValue, 0);
+        double cellMaxLon = decodeLongitude(maxPackedValue, Integer.BYTES);
+
+        return component2D.relate(cellMinLon, cellMaxLon, cellMinLat, cellMaxLat);
+      }
+
+      @Override
+      protected Predicate<byte[]> intersects() {
+        return packedValue ->
+            component2DPredicate.test(
+                NumericUtils.sortableBytesToInt(packedValue, 0),
+                NumericUtils.sortableBytesToInt(packedValue, Integer.BYTES));
+      }
+
+      @Override
+      protected Predicate<byte[]> within() {
+        return packedValue ->
+            component2DPredicate.test(
+                NumericUtils.sortableBytesToInt(packedValue, 0),
+                NumericUtils.sortableBytesToInt(packedValue, Integer.BYTES));
+      }
+
+      @Override
+      protected Function<byte[], Component2D.WithinRelation> contains() {
+        return packedValue ->
+            component2D.withinPoint(
+                GeoEncodingUtils.decodeLongitude(
+                    NumericUtils.sortableBytesToInt(packedValue, Integer.BYTES)),
+                GeoEncodingUtils.decodeLatitude(NumericUtils.sortableBytesToInt(packedValue, 0)));
+      }
+    };
+  }
+
+  @Override
+  public String toString(String field) {
+    final StringBuilder sb = new StringBuilder();
+    sb.append(getClass().getSimpleName());
+    sb.append(':');
+    if (this.field.equals(field) == false) {
+      sb.append(" field=");
+      sb.append(this.field);
+      sb.append(':');
+    }
+    sb.append("[");
+    for (int i = 0; i < geometries.length; i++) {
+      sb.append(geometries[i].toString());
+      sb.append(',');
+    }
+    sb.append(']');
+    return sb.toString();
+  }
+
+  @Override
+  protected boolean equalsTo(Object o) {
+    return super.equalsTo(o) && Arrays.equals(geometries, ((LatLonPointQuery) o).geometries);
+  }
+
+  @Override
+  public int hashCode() {
+    int hash = super.hashCode();
+    hash = 31 * hash + Arrays.hashCode(geometries);
+    return hash;
+  }
+}
diff --git a/lucene/core/src/java/org/apache/lucene/document/LatLonShapeBoundingBoxQuery.java b/lucene/core/src/java/org/apache/lucene/document/LatLonShapeBoundingBoxQuery.java
index 4dcdc47..0d6bae4 100644
--- a/lucene/core/src/java/org/apache/lucene/document/LatLonShapeBoundingBoxQuery.java
+++ b/lucene/core/src/java/org/apache/lucene/document/LatLonShapeBoundingBoxQuery.java
@@ -25,6 +25,8 @@ import static org.apache.lucene.geo.GeoEncodingUtils.encodeLongitude;
 import static org.apache.lucene.geo.GeoEncodingUtils.encodeLongitudeCeil;
 
 import java.util.Arrays;
+import java.util.function.Function;
+import java.util.function.Predicate;
 import org.apache.lucene.document.ShapeField.QueryRelation;
 import org.apache.lucene.geo.Component2D;
 import org.apache.lucene.geo.GeoUtils;
@@ -38,143 +40,161 @@ import org.apache.lucene.util.NumericUtils;
  * <p>The field must be indexed using {@link
  * org.apache.lucene.document.LatLonShape#createIndexableFields} added per document.
  */
-final class LatLonShapeBoundingBoxQuery extends ShapeQuery {
+final class LatLonShapeBoundingBoxQuery extends SpatialQuery {
   private final Rectangle rectangle;
-  private final EncodedRectangle encodedRectangle;
 
   LatLonShapeBoundingBoxQuery(String field, QueryRelation queryRelation, Rectangle rectangle) {
     super(field, queryRelation);
     this.rectangle = rectangle;
-    this.encodedRectangle =
-        new EncodedRectangle(
-            rectangle.minLat, rectangle.maxLat, rectangle.minLon, rectangle.maxLon);
-  }
-
-  @Override
-  protected Relation relateRangeBBoxToQuery(
-      int minXOffset,
-      int minYOffset,
-      byte[] minTriangle,
-      int maxXOffset,
-      int maxYOffset,
-      byte[] maxTriangle) {
-    if (queryRelation == QueryRelation.INTERSECTS || queryRelation == QueryRelation.DISJOINT) {
-      return encodedRectangle.intersectRangeBBox(
-          minXOffset, minYOffset, minTriangle, maxXOffset, maxYOffset, maxTriangle);
-    }
-    return encodedRectangle.relateRangeBBox(
-        minXOffset, minYOffset, minTriangle, maxXOffset, maxYOffset, maxTriangle);
-  }
-
-  @Override
-  protected boolean queryIntersects(byte[] t, ShapeField.DecodedTriangle scratchTriangle) {
-    ShapeField.decodeTriangle(t, scratchTriangle);
-
-    switch (scratchTriangle.type) {
-      case POINT:
-        {
-          return encodedRectangle.contains(scratchTriangle.aX, scratchTriangle.aY);
-        }
-      case LINE:
-        {
-          int aY = scratchTriangle.aY;
-          int aX = scratchTriangle.aX;
-          int bY = scratchTriangle.bY;
-          int bX = scratchTriangle.bX;
-          return encodedRectangle.intersectsLine(aX, aY, bX, bY);
-        }
-      case TRIANGLE:
-        {
-          int aY = scratchTriangle.aY;
-          int aX = scratchTriangle.aX;
-          int bY = scratchTriangle.bY;
-          int bX = scratchTriangle.bX;
-          int cY = scratchTriangle.cY;
-          int cX = scratchTriangle.cX;
-          return encodedRectangle.intersectsTriangle(aX, aY, bX, bY, cX, cY);
-        }
-      default:
-        throw new IllegalArgumentException(
-            "Unsupported triangle type :[" + scratchTriangle.type + "]");
-    }
   }
 
   @Override
-  protected boolean queryContains(byte[] t, ShapeField.DecodedTriangle scratchTriangle) {
-    ShapeField.decodeTriangle(t, scratchTriangle);
-
-    switch (scratchTriangle.type) {
-      case POINT:
-        {
-          return encodedRectangle.contains(scratchTriangle.aX, scratchTriangle.aY);
-        }
-      case LINE:
-        {
-          int aY = scratchTriangle.aY;
-          int aX = scratchTriangle.aX;
-          int bY = scratchTriangle.bY;
-          int bX = scratchTriangle.bX;
-          return encodedRectangle.containsLine(aX, aY, bX, bY);
-        }
-      case TRIANGLE:
-        {
-          int aY = scratchTriangle.aY;
-          int aX = scratchTriangle.aX;
-          int bY = scratchTriangle.bY;
-          int bX = scratchTriangle.bX;
-          int cY = scratchTriangle.cY;
-          int cX = scratchTriangle.cX;
-          return encodedRectangle.containsTriangle(aX, aY, bX, bY, cX, cY);
-        }
-      default:
-        throw new IllegalArgumentException(
-            "Unsupported triangle type :[" + scratchTriangle.type + "]");
-    }
-  }
-
-  @Override
-  protected Component2D.WithinRelation queryWithin(
-      byte[] t, ShapeField.DecodedTriangle scratchTriangle) {
-    if (encodedRectangle.crossesDateline()) {
-      throw new IllegalArgumentException(
-          "withinTriangle is not supported for rectangles crossing the date line");
-    }
-    // decode indexed triangle
-    ShapeField.decodeTriangle(t, scratchTriangle);
-
-    switch (scratchTriangle.type) {
-      case POINT:
-        {
-          return encodedRectangle.contains(scratchTriangle.aX, scratchTriangle.aY)
-              ? Component2D.WithinRelation.NOTWITHIN
-              : Component2D.WithinRelation.DISJOINT;
-        }
-      case LINE:
-        {
-          return encodedRectangle.withinLine(
-              scratchTriangle.aX,
-              scratchTriangle.aY,
-              scratchTriangle.ab,
-              scratchTriangle.bX,
-              scratchTriangle.bY);
+  protected SpatialVisitor getSpatialVisitor() {
+    final EncodedRectangle encodedRectangle =
+        new EncodedRectangle(
+            rectangle.minLat, rectangle.maxLat, rectangle.minLon, rectangle.maxLon);
+    return new SpatialVisitor() {
+
+      @Override
+      protected Relation relate(byte[] minTriangle, byte[] maxTriangle) {
+        if (queryRelation == QueryRelation.INTERSECTS || queryRelation == QueryRelation.DISJOINT) {
+          return encodedRectangle.intersectRangeBBox(
+              ShapeField.BYTES,
+              0,
+              minTriangle,
+              3 * ShapeField.BYTES,
+              2 * ShapeField.BYTES,
+              maxTriangle);
         }
-      case TRIANGLE:
-        {
-          return encodedRectangle.withinTriangle(
-              scratchTriangle.aX,
-              scratchTriangle.aY,
-              scratchTriangle.ab,
-              scratchTriangle.bX,
-              scratchTriangle.bY,
-              scratchTriangle.bc,
-              scratchTriangle.cX,
-              scratchTriangle.cY,
-              scratchTriangle.ca);
+        return encodedRectangle.relateRangeBBox(
+            ShapeField.BYTES,
+            0,
+            minTriangle,
+            3 * ShapeField.BYTES,
+            2 * ShapeField.BYTES,
+            maxTriangle);
+      }
+
+      @Override
+      protected Predicate<byte[]> intersects() {
+        final ShapeField.DecodedTriangle scratchTriangle = new ShapeField.DecodedTriangle();
+        return triangle -> {
+          ShapeField.decodeTriangle(triangle, scratchTriangle);
+
+          switch (scratchTriangle.type) {
+            case POINT:
+              {
+                return encodedRectangle.contains(scratchTriangle.aX, scratchTriangle.aY);
+              }
+            case LINE:
+              {
+                int aY = scratchTriangle.aY;
+                int aX = scratchTriangle.aX;
+                int bY = scratchTriangle.bY;
+                int bX = scratchTriangle.bX;
+                return encodedRectangle.intersectsLine(aX, aY, bX, bY);
+              }
+            case TRIANGLE:
+              {
+                int aY = scratchTriangle.aY;
+                int aX = scratchTriangle.aX;
+                int bY = scratchTriangle.bY;
+                int bX = scratchTriangle.bX;
+                int cY = scratchTriangle.cY;
+                int cX = scratchTriangle.cX;
+                return encodedRectangle.intersectsTriangle(aX, aY, bX, bY, cX, cY);
+              }
+            default:
+              throw new IllegalArgumentException(
+                  "Unsupported triangle type :[" + scratchTriangle.type + "]");
+          }
+        };
+      }
+
+      @Override
+      protected Predicate<byte[]> within() {
+        final ShapeField.DecodedTriangle scratchTriangle = new ShapeField.DecodedTriangle();
+        return triangle -> {
+          ShapeField.decodeTriangle(triangle, scratchTriangle);
+
+          switch (scratchTriangle.type) {
+            case POINT:
+              {
+                return encodedRectangle.contains(scratchTriangle.aX, scratchTriangle.aY);
+              }
+            case LINE:
+              {
+                int aY = scratchTriangle.aY;
+                int aX = scratchTriangle.aX;
+                int bY = scratchTriangle.bY;
+                int bX = scratchTriangle.bX;
+                return encodedRectangle.containsLine(aX, aY, bX, bY);
+              }
+            case TRIANGLE:
+              {
+                int aY = scratchTriangle.aY;
+                int aX = scratchTriangle.aX;
+                int bY = scratchTriangle.bY;
+                int bX = scratchTriangle.bX;
+                int cY = scratchTriangle.cY;
+                int cX = scratchTriangle.cX;
+                return encodedRectangle.containsTriangle(aX, aY, bX, bY, cX, cY);
+              }
+            default:
+              throw new IllegalArgumentException(
+                  "Unsupported triangle type :[" + scratchTriangle.type + "]");
+          }
+        };
+      }
+
+      @Override
+      protected Function<byte[], Component2D.WithinRelation> contains() {
+        if (encodedRectangle.crossesDateline()) {
+          throw new IllegalArgumentException(
+              "withinTriangle is not supported for rectangles crossing the date line");
         }
-      default:
-        throw new IllegalArgumentException(
-            "Unsupported triangle type :[" + scratchTriangle.type + "]");
-    }
+        final ShapeField.DecodedTriangle scratchTriangle = new ShapeField.DecodedTriangle();
+        return triangle -> {
+
+          // decode indexed triangle
+          ShapeField.decodeTriangle(triangle, scratchTriangle);
+
+          switch (scratchTriangle.type) {
+            case POINT:
+              {
+                return encodedRectangle.contains(scratchTriangle.aX, scratchTriangle.aY)
+                    ? Component2D.WithinRelation.NOTWITHIN
+                    : Component2D.WithinRelation.DISJOINT;
+              }
+            case LINE:
+              {
+                return encodedRectangle.withinLine(
+                    scratchTriangle.aX,
+                    scratchTriangle.aY,
+                    scratchTriangle.ab,
+                    scratchTriangle.bX,
+                    scratchTriangle.bY);
+              }
+            case TRIANGLE:
+              {
+                return encodedRectangle.withinTriangle(
+                    scratchTriangle.aX,
+                    scratchTriangle.aY,
+                    scratchTriangle.ab,
+                    scratchTriangle.bX,
+                    scratchTriangle.bY,
+                    scratchTriangle.bc,
+                    scratchTriangle.cX,
+                    scratchTriangle.cY,
+                    scratchTriangle.ca);
+              }
+            default:
+              throw new IllegalArgumentException(
+                  "Unsupported triangle type :[" + scratchTriangle.type + "]");
+          }
+        };
+      }
+    };
   }
 
   @Override
diff --git a/lucene/core/src/java/org/apache/lucene/document/LatLonShapeQuery.java b/lucene/core/src/java/org/apache/lucene/document/LatLonShapeQuery.java
index 5b7ed54..af7c87b 100644
--- a/lucene/core/src/java/org/apache/lucene/document/LatLonShapeQuery.java
+++ b/lucene/core/src/java/org/apache/lucene/document/LatLonShapeQuery.java
@@ -17,6 +17,8 @@
 package org.apache.lucene.document;
 
 import java.util.Arrays;
+import java.util.function.Function;
+import java.util.function.Predicate;
 import org.apache.lucene.document.ShapeField.QueryRelation;
 import org.apache.lucene.geo.Component2D;
 import org.apache.lucene.geo.GeoEncodingUtils;
@@ -26,12 +28,12 @@ import org.apache.lucene.index.PointValues.Relation;
 import org.apache.lucene.util.NumericUtils;
 
 /**
- * Finds all previously indexed cartesian shapes that comply the given {@link QueryRelation} with
- * the specified array of {@link LatLonGeometry}.
+ * Finds all previously indexed geo shapes that comply the given {@link QueryRelation} with the
+ * specified array of {@link LatLonGeometry}.
  *
  * <p>The field must be indexed using {@link LatLonShape#createIndexableFields} added per document.
  */
-final class LatLonShapeQuery extends ShapeQuery {
+final class LatLonShapeQuery extends SpatialQuery {
   private final LatLonGeometry[] geometries;
   private final Component2D component2D;
 
@@ -56,140 +58,150 @@ final class LatLonShapeQuery extends ShapeQuery {
   }
 
   @Override
-  protected Relation relateRangeBBoxToQuery(
-      int minXOffset,
-      int minYOffset,
-      byte[] minTriangle,
-      int maxXOffset,
-      int maxYOffset,
-      byte[] maxTriangle) {
+  protected SpatialVisitor getSpatialVisitor() {
 
-    double minLat =
-        GeoEncodingUtils.decodeLatitude(NumericUtils.sortableBytesToInt(minTriangle, minYOffset));
-    double minLon =
-        GeoEncodingUtils.decodeLongitude(NumericUtils.sortableBytesToInt(minTriangle, minXOffset));
-    double maxLat =
-        GeoEncodingUtils.decodeLatitude(NumericUtils.sortableBytesToInt(maxTriangle, maxYOffset));
-    double maxLon =
-        GeoEncodingUtils.decodeLongitude(NumericUtils.sortableBytesToInt(maxTriangle, maxXOffset));
+    return new SpatialVisitor() {
+      @Override
+      protected Relation relate(byte[] minTriangle, byte[] maxTriangle) {
+        double minLat =
+            GeoEncodingUtils.decodeLatitude(NumericUtils.sortableBytesToInt(minTriangle, 0));
+        double minLon =
+            GeoEncodingUtils.decodeLongitude(
+                NumericUtils.sortableBytesToInt(minTriangle, ShapeField.BYTES));
+        double maxLat =
+            GeoEncodingUtils.decodeLatitude(
+                NumericUtils.sortableBytesToInt(maxTriangle, 2 * ShapeField.BYTES));
+        double maxLon =
+            GeoEncodingUtils.decodeLongitude(
+                NumericUtils.sortableBytesToInt(maxTriangle, 3 * ShapeField.BYTES));
 
-    // check internal node against query
-    return component2D.relate(minLon, maxLon, minLat, maxLat);
-  }
+        // check internal node against query
+        return component2D.relate(minLon, maxLon, minLat, maxLat);
+      }
 
-  @Override
-  protected boolean queryIntersects(byte[] t, ShapeField.DecodedTriangle scratchTriangle) {
-    ShapeField.decodeTriangle(t, scratchTriangle);
+      @Override
+      protected Predicate<byte[]> intersects() {
+        final ShapeField.DecodedTriangle scratchTriangle = new ShapeField.DecodedTriangle();
+        return triangle -> {
+          ShapeField.decodeTriangle(triangle, scratchTriangle);
 
-    switch (scratchTriangle.type) {
-      case POINT:
-        {
-          double alat = GeoEncodingUtils.decodeLatitude(scratchTriangle.aY);
-          double alon = GeoEncodingUtils.decodeLongitude(scratchTriangle.aX);
-          return component2D.contains(alon, alat);
-        }
-      case LINE:
-        {
-          double alat = GeoEncodingUtils.decodeLatitude(scratchTriangle.aY);
-          double alon = GeoEncodingUtils.decodeLongitude(scratchTriangle.aX);
-          double blat = GeoEncodingUtils.decodeLatitude(scratchTriangle.bY);
-          double blon = GeoEncodingUtils.decodeLongitude(scratchTriangle.bX);
-          return component2D.intersectsLine(alon, alat, blon, blat);
-        }
-      case TRIANGLE:
-        {
-          double alat = GeoEncodingUtils.decodeLatitude(scratchTriangle.aY);
-          double alon = GeoEncodingUtils.decodeLongitude(scratchTriangle.aX);
-          double blat = GeoEncodingUtils.decodeLatitude(scratchTriangle.bY);
-          double blon = GeoEncodingUtils.decodeLongitude(scratchTriangle.bX);
-          double clat = GeoEncodingUtils.decodeLatitude(scratchTriangle.cY);
-          double clon = GeoEncodingUtils.decodeLongitude(scratchTriangle.cX);
-          return component2D.intersectsTriangle(alon, alat, blon, blat, clon, clat);
-        }
-      default:
-        throw new IllegalArgumentException(
-            "Unsupported triangle type :[" + scratchTriangle.type + "]");
-    }
-  }
+          switch (scratchTriangle.type) {
+            case POINT:
+              {
+                double alat = GeoEncodingUtils.decodeLatitude(scratchTriangle.aY);
+                double alon = GeoEncodingUtils.decodeLongitude(scratchTriangle.aX);
+                return component2D.contains(alon, alat);
+              }
+            case LINE:
+              {
+                double alat = GeoEncodingUtils.decodeLatitude(scratchTriangle.aY);
+                double alon = GeoEncodingUtils.decodeLongitude(scratchTriangle.aX);
+                double blat = GeoEncodingUtils.decodeLatitude(scratchTriangle.bY);
+                double blon = GeoEncodingUtils.decodeLongitude(scratchTriangle.bX);
+                return component2D.intersectsLine(alon, alat, blon, blat);
+              }
+            case TRIANGLE:
+              {
+                double alat = GeoEncodingUtils.decodeLatitude(scratchTriangle.aY);
+                double alon = GeoEncodingUtils.decodeLongitude(scratchTriangle.aX);
+                double blat = GeoEncodingUtils.decodeLatitude(scratchTriangle.bY);
+                double blon = GeoEncodingUtils.decodeLongitude(scratchTriangle.bX);
+                double clat = GeoEncodingUtils.decodeLatitude(scratchTriangle.cY);
+                double clon = GeoEncodingUtils.decodeLongitude(scratchTriangle.cX);
+                return component2D.intersectsTriangle(alon, alat, blon, blat, clon, clat);
+              }
+            default:
+              throw new IllegalArgumentException(
+                  "Unsupported triangle type :[" + scratchTriangle.type + "]");
+          }
+        };
+      }
 
-  @Override
-  protected boolean queryContains(byte[] t, ShapeField.DecodedTriangle scratchTriangle) {
-    ShapeField.decodeTriangle(t, scratchTriangle);
+      @Override
+      protected Predicate<byte[]> within() {
+        final ShapeField.DecodedTriangle scratchTriangle = new ShapeField.DecodedTriangle();
+        return triangle -> {
+          ShapeField.decodeTriangle(triangle, scratchTriangle);
 
-    switch (scratchTriangle.type) {
-      case POINT:
-        {
-          double alat = GeoEncodingUtils.decodeLatitude(scratchTriangle.aY);
-          double alon = GeoEncodingUtils.decodeLongitude(scratchTriangle.aX);
-          return component2D.contains(alon, alat);
-        }
-      case LINE:
-        {
-          double alat = GeoEncodingUtils.decodeLatitude(scratchTriangle.aY);
-          double alon = GeoEncodingUtils.decodeLongitude(scratchTriangle.aX);
-          double blat = GeoEncodingUtils.decodeLatitude(scratchTriangle.bY);
-          double blon = GeoEncodingUtils.decodeLongitude(scratchTriangle.bX);
-          return component2D.containsLine(alon, alat, blon, blat);
-        }
-      case TRIANGLE:
-        {
-          double alat = GeoEncodingUtils.decodeLatitude(scratchTriangle.aY);
-          double alon = GeoEncodingUtils.decodeLongitude(scratchTriangle.aX);
-          double blat = GeoEncodingUtils.decodeLatitude(scratchTriangle.bY);
-          double blon = GeoEncodingUtils.decodeLongitude(scratchTriangle.bX);
-          double clat = GeoEncodingUtils.decodeLatitude(scratchTriangle.cY);
-          double clon = GeoEncodingUtils.decodeLongitude(scratchTriangle.cX);
-          return component2D.containsTriangle(alon, alat, blon, blat, clon, clat);
-        }
-      default:
-        throw new IllegalArgumentException(
-            "Unsupported triangle type :[" + scratchTriangle.type + "]");
-    }
-  }
+          switch (scratchTriangle.type) {
+            case POINT:
+              {
+                double alat = GeoEncodingUtils.decodeLatitude(scratchTriangle.aY);
+                double alon = GeoEncodingUtils.decodeLongitude(scratchTriangle.aX);
+                return component2D.contains(alon, alat);
+              }
+            case LINE:
+              {
+                double alat = GeoEncodingUtils.decodeLatitude(scratchTriangle.aY);
+                double alon = GeoEncodingUtils.decodeLongitude(scratchTriangle.aX);
+                double blat = GeoEncodingUtils.decodeLatitude(scratchTriangle.bY);
+                double blon = GeoEncodingUtils.decodeLongitude(scratchTriangle.bX);
+                return component2D.containsLine(alon, alat, blon, blat);
+              }
+            case TRIANGLE:
+              {
+                double alat = GeoEncodingUtils.decodeLatitude(scratchTriangle.aY);
+                double alon = GeoEncodingUtils.decodeLongitude(scratchTriangle.aX);
+                double blat = GeoEncodingUtils.decodeLatitude(scratchTriangle.bY);
+                double blon = GeoEncodingUtils.decodeLongitude(scratchTriangle.bX);
+                double clat = GeoEncodingUtils.decodeLatitude(scratchTriangle.cY);
+                double clon = GeoEncodingUtils.decodeLongitude(scratchTriangle.cX);
+                return component2D.containsTriangle(alon, alat, blon, blat, clon, clat);
+              }
+            default:
+              throw new IllegalArgumentException(
+                  "Unsupported triangle type :[" + scratchTriangle.type + "]");
+          }
+        };
+      }
 
-  @Override
-  protected Component2D.WithinRelation queryWithin(
-      byte[] t, ShapeField.DecodedTriangle scratchTriangle) {
-    ShapeField.decodeTriangle(t, scratchTriangle);
+      @Override
+      protected Function<byte[], Component2D.WithinRelation> contains() {
+        final ShapeField.DecodedTriangle scratchTriangle = new ShapeField.DecodedTriangle();
+        return triangle -> {
+          ShapeField.decodeTriangle(triangle, scratchTriangle);
 
-    switch (scratchTriangle.type) {
-      case POINT:
-        {
-          double alat = GeoEncodingUtils.decodeLatitude(scratchTriangle.aY);
-          double alon = GeoEncodingUtils.decodeLongitude(scratchTriangle.aX);
-          return component2D.withinPoint(alon, alat);
-        }
-      case LINE:
-        {
-          double alat = GeoEncodingUtils.decodeLatitude(scratchTriangle.aY);
-          double alon = GeoEncodingUtils.decodeLongitude(scratchTriangle.aX);
-          double blat = GeoEncodingUtils.decodeLatitude(scratchTriangle.bY);
-          double blon = GeoEncodingUtils.decodeLongitude(scratchTriangle.bX);
-          return component2D.withinLine(alon, alat, scratchTriangle.ab, blon, blat);
-        }
-      case TRIANGLE:
-        {
-          double alat = GeoEncodingUtils.decodeLatitude(scratchTriangle.aY);
-          double alon = GeoEncodingUtils.decodeLongitude(scratchTriangle.aX);
-          double blat = GeoEncodingUtils.decodeLatitude(scratchTriangle.bY);
-          double blon = GeoEncodingUtils.decodeLongitude(scratchTriangle.bX);
-          double clat = GeoEncodingUtils.decodeLatitude(scratchTriangle.cY);
-          double clon = GeoEncodingUtils.decodeLongitude(scratchTriangle.cX);
-          return component2D.withinTriangle(
-              alon,
-              alat,
-              scratchTriangle.ab,
-              blon,
-              blat,
-              scratchTriangle.bc,
-              clon,
-              clat,
-              scratchTriangle.ca);
-        }
-      default:
-        throw new IllegalArgumentException(
-            "Unsupported triangle type :[" + scratchTriangle.type + "]");
-    }
+          switch (scratchTriangle.type) {
+            case POINT:
+              {
+                double alat = GeoEncodingUtils.decodeLatitude(scratchTriangle.aY);
+                double alon = GeoEncodingUtils.decodeLongitude(scratchTriangle.aX);
+                return component2D.withinPoint(alon, alat);
+              }
+            case LINE:
+              {
+                double alat = GeoEncodingUtils.decodeLatitude(scratchTriangle.aY);
+                double alon = GeoEncodingUtils.decodeLongitude(scratchTriangle.aX);
+                double blat = GeoEncodingUtils.decodeLatitude(scratchTriangle.bY);
+                double blon = GeoEncodingUtils.decodeLongitude(scratchTriangle.bX);
+                return component2D.withinLine(alon, alat, scratchTriangle.ab, blon, blat);
+              }
+            case TRIANGLE:
+              {
+                double alat = GeoEncodingUtils.decodeLatitude(scratchTriangle.aY);
+                double alon = GeoEncodingUtils.decodeLongitude(scratchTriangle.aX);
+                double blat = GeoEncodingUtils.decodeLatitude(scratchTriangle.bY);
+                double blon = GeoEncodingUtils.decodeLongitude(scratchTriangle.bX);
+                double clat = GeoEncodingUtils.decodeLatitude(scratchTriangle.cY);
+                double clon = GeoEncodingUtils.decodeLongitude(scratchTriangle.cX);
+                return component2D.withinTriangle(
+                    alon,
+                    alat,
+                    scratchTriangle.ab,
+                    blon,
+                    blat,
+                    scratchTriangle.bc,
+                    clon,
+                    clat,
+                    scratchTriangle.ca);
+              }
+            default:
+              throw new IllegalArgumentException(
+                  "Unsupported triangle type :[" + scratchTriangle.type + "]");
+          }
+        };
+      }
+    };
   }
 
   @Override
diff --git a/lucene/core/src/java/org/apache/lucene/document/ShapeQuery.java b/lucene/core/src/java/org/apache/lucene/document/SpatialQuery.java
similarity index 67%
rename from lucene/core/src/java/org/apache/lucene/document/ShapeQuery.java
rename to lucene/core/src/java/org/apache/lucene/document/SpatialQuery.java
index b9052e9..17208d5 100644
--- a/lucene/core/src/java/org/apache/lucene/document/ShapeQuery.java
+++ b/lucene/core/src/java/org/apache/lucene/document/SpatialQuery.java
@@ -18,6 +18,9 @@ package org.apache.lucene.document;
 
 import java.io.IOException;
 import java.util.Objects;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.function.Predicate;
 import org.apache.lucene.document.ShapeField.QueryRelation;
 import org.apache.lucene.geo.Component2D;
 import org.apache.lucene.index.FieldInfo;
@@ -42,29 +45,10 @@ import org.apache.lucene.util.DocIdSetBuilder;
 import org.apache.lucene.util.FixedBitSet;
 
 /**
- * Base query class for all spatial geometries: {@link LatLonShape} and {@link XYShape}.
- *
- * <p>The field must be indexed using either {@link LatLonShape#createIndexableFields} or {@link
- * XYShape#createIndexableFields} and the corresponding factory method must be used:
- *
- * <ul>
- *   <li>{@link LatLonShape#newBoxQuery newBoxQuery()} for matching geo shapes that have some {@link
- *       QueryRelation} with a bounding box.
- *   <li>{@link LatLonShape#newLineQuery newLineQuery()} for matching geo shapes that have some
- *       {@link QueryRelation} with a linestring.
- *   <li>{@link LatLonShape#newPolygonQuery newPolygonQuery()} for matching geo shapes that have
- *       some {@link QueryRelation} with a polygon.
- *   <li>{@link XYShape#newBoxQuery newBoxQuery()} for matching cartesian shapes that have some
- *       {@link QueryRelation} with a bounding box.
- *   <li>{@link XYShape#newLineQuery newLineQuery()} for matching cartesian shapes that have some
- *       {@link QueryRelation} with a linestring.
- *   <li>{@link XYShape#newPolygonQuery newPolygonQuery()} for matching cartesian shapes that have
- *       some {@link QueryRelation} with a polygon.
- * </ul>
- *
- * <p>
+ * Base query class for all spatial geometries: {@link LatLonShape}, {@link LatLonPoint} and {@link
+ * XYShape}. In order to create a query, use the factory methods on those classes.
  */
-abstract class ShapeQuery extends Query {
+abstract class SpatialQuery extends Query {
   /** field name */
   final String field;
   /**
@@ -74,72 +58,65 @@ abstract class ShapeQuery extends Query {
    */
   final QueryRelation queryRelation;
 
-  protected ShapeQuery(String field, final QueryRelation queryType) {
+  protected SpatialQuery(String field, final QueryRelation queryRelation) {
     if (field == null) {
       throw new IllegalArgumentException("field must not be null");
     }
+    if (queryRelation == null) {
+      throw new IllegalArgumentException("queryRelation must not be null");
+    }
     this.field = field;
-    this.queryRelation = queryType;
+    this.queryRelation = queryRelation;
   }
 
   /**
-   * relates an internal node (bounding box of a range of triangles) to the target query Note: logic
-   * is specific to query type see {@link LatLonShapeBoundingBoxQuery#relateRangeToQuery} and {@link
-   * LatLonShapeQuery#relateRangeToQuery}
+   * returns the spatial visitor to be used for this query. Called before generating the query
+   * {@link Weight}
    */
-  protected abstract Relation relateRangeBBoxToQuery(
-      int minXOffset,
-      int minYOffset,
-      byte[] minTriangle,
-      int maxXOffset,
-      int maxYOffset,
-      byte[] maxTriangle);
-
-  /** returns true if the provided triangle matches the query */
-  protected boolean queryMatches(
-      byte[] triangle,
-      ShapeField.DecodedTriangle scratchTriangle,
-      ShapeField.QueryRelation queryRelation) {
-    switch (queryRelation) {
-      case INTERSECTS:
-        return queryIntersects(triangle, scratchTriangle);
-      case WITHIN:
-        return queryContains(triangle, scratchTriangle);
-      case DISJOINT:
-        return queryIntersects(triangle, scratchTriangle) == false;
-      default:
-        throw new IllegalArgumentException("Unsupported query type :[" + queryRelation + "]");
+  protected abstract SpatialVisitor getSpatialVisitor();
+
+  /** Visitor used for walking the BKD tree. */
+  protected abstract static class SpatialVisitor {
+    /** relates a range of points (internal node) to the query */
+    protected abstract Relation relate(byte[] minPackedValue, byte[] maxPackedValue);
+
+    /** Gets a intersects predicate. Called when constructing a {@link Scorer} */
+    protected abstract Predicate<byte[]> intersects();
+
+    /** Gets a within predicate. Called when constructing a {@link Scorer} */
+    protected abstract Predicate<byte[]> within();
+
+    /** Gets a contains function. Called when constructing a {@link Scorer} */
+    protected abstract Function<byte[], Component2D.WithinRelation> contains();
+
+    private Predicate<byte[]> containsPredicate() {
+      final Function<byte[], Component2D.WithinRelation> contains = contains();
+      return bytes -> contains.apply(bytes) == Component2D.WithinRelation.CANDIDATE;
+    }
+
+    private BiFunction<byte[], byte[], Relation> getInnerFunction(
+        ShapeField.QueryRelation queryRelation) {
+      if (queryRelation == QueryRelation.DISJOINT) {
+        return (minPackedValue, maxPackedValue) ->
+            transposeRelation(relate(minPackedValue, maxPackedValue));
+      }
+      return (minPackedValue, maxPackedValue) -> relate(minPackedValue, maxPackedValue);
     }
-  }
 
-  /** returns true if the provided triangle intersects the query */
-  protected abstract boolean queryIntersects(
-      byte[] triangle, ShapeField.DecodedTriangle scratchTriangle);
-
-  /** returns true if the provided triangle is within the query */
-  protected abstract boolean queryContains(
-      byte[] triangle, ShapeField.DecodedTriangle scratchTriangle);
-
-  /** Return the within relationship between the query and the indexed shape. */
-  protected abstract Component2D.WithinRelation queryWithin(
-      byte[] triangle, ShapeField.DecodedTriangle scratchTriangle);
-
-  /** relates a range of triangles (internal node) to the query */
-  protected Relation relateRangeToQuery(
-      byte[] minTriangle, byte[] maxTriangle, QueryRelation queryRelation) {
-    // compute bounding box of internal node
-    final Relation r =
-        relateRangeBBoxToQuery(
-            ShapeField.BYTES,
-            0,
-            minTriangle,
-            3 * ShapeField.BYTES,
-            2 * ShapeField.BYTES,
-            maxTriangle);
-    if (queryRelation == QueryRelation.DISJOINT) {
-      return transposeRelation(r);
+    private Predicate<byte[]> getLeafPredicate(ShapeField.QueryRelation queryRelation) {
+      switch (queryRelation) {
+        case INTERSECTS:
+          return intersects();
+        case WITHIN:
+          return within();
+        case DISJOINT:
+          return intersects().negate();
+        case CONTAINS:
+          return containsPredicate();
+        default:
+          throw new IllegalArgumentException("Unsupported query type :[" + queryRelation + "]");
+      }
     }
-    return r;
   }
 
   @Override
@@ -151,7 +128,8 @@ abstract class ShapeQuery extends Query {
 
   @Override
   public final Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, float boost) {
-    final ShapeQuery query = this;
+    final SpatialQuery query = this;
+    final SpatialVisitor spatialVisitor = getSpatialVisitor();
     return new ConstantScoreWeight(query, boost) {
 
       @Override
@@ -176,11 +154,11 @@ abstract class ShapeQuery extends Query {
           // No docs in this segment indexed this field at all
           return null;
         }
-
         final Weight weight = this;
         final Relation rel =
-            relateRangeToQuery(
-                values.getMinPackedValue(), values.getMaxPackedValue(), queryRelation);
+            spatialVisitor
+                .getInnerFunction(queryRelation)
+                .apply(values.getMinPackedValue(), values.getMaxPackedValue());
         if (rel == Relation.CELL_OUTSIDE_QUERY
             || (rel == Relation.CELL_INSIDE_QUERY && queryRelation == QueryRelation.CONTAINS)) {
           // no documents match the query
@@ -202,13 +180,14 @@ abstract class ShapeQuery extends Query {
         } else {
           if (queryRelation != QueryRelation.INTERSECTS
               && queryRelation != QueryRelation.CONTAINS
-              && hasAnyHits(query, values) == false) {
+              && values.getDocCount() != values.size()
+              && hasAnyHits(spatialVisitor, queryRelation, values) == false) {
             // First we check if we have any hits so we are fast in the adversarial case where
             // the shape does not match any documents and we are in the dense case
             return null;
           }
           // walk the tree to get matching documents
-          return new RelationScorerSupplier(values, ShapeQuery.this) {
+          return new RelationScorerSupplier(values, spatialVisitor, queryRelation, field) {
             @Override
             public Scorer get(long leadCost) throws IOException {
               return getScorer(reader, weight, score(), scoreMode);
@@ -249,15 +228,15 @@ abstract class ShapeQuery extends Query {
 
   /** class specific equals check */
   protected boolean equalsTo(Object o) {
-    return Objects.equals(field, ((ShapeQuery) o).field)
-        && this.queryRelation == ((ShapeQuery) o).queryRelation;
+    return Objects.equals(field, ((SpatialQuery) o).field)
+        && this.queryRelation == ((SpatialQuery) o).queryRelation;
   }
 
   /**
    * transpose the relation; INSIDE becomes OUTSIDE, OUTSIDE becomes INSIDE, CROSSES remains
    * unchanged
    */
-  private static Relation transposeRelation(Relation r) {
+  protected static Relation transposeRelation(Relation r) {
     if (r == Relation.CELL_INSIDE_QUERY) {
       return Relation.CELL_OUTSIDE_QUERY;
     } else if (r == Relation.CELL_OUTSIDE_QUERY) {
@@ -271,36 +250,46 @@ abstract class ShapeQuery extends Query {
    */
   private abstract static class RelationScorerSupplier extends ScorerSupplier {
     private final PointValues values;
-    private final ShapeQuery query;
+    private final SpatialVisitor spatialVisitor;
+    private final QueryRelation queryRelation;
+    private final String field;
     private long cost = -1;
 
-    RelationScorerSupplier(final PointValues values, final ShapeQuery query) {
+    RelationScorerSupplier(
+        final PointValues values,
+        SpatialVisitor spatialVisitor,
+        final QueryRelation queryRelation,
+        final String field) {
       this.values = values;
-      this.query = query;
+      this.spatialVisitor = spatialVisitor;
+      this.queryRelation = queryRelation;
+      this.field = field;
     }
 
     protected Scorer getScorer(
         final LeafReader reader, final Weight weight, final float boost, final ScoreMode scoreMode)
         throws IOException {
-      switch (query.getQueryRelation()) {
+      switch (queryRelation) {
         case INTERSECTS:
           return getSparseScorer(reader, weight, boost, scoreMode);
-        case WITHIN:
-        case DISJOINT:
-          return getDenseScorer(reader, weight, boost, scoreMode);
         case CONTAINS:
           return getContainsDenseScorer(reader, weight, boost, scoreMode);
+        case WITHIN:
+        case DISJOINT:
+          return values.getDocCount() == values.size()
+              ? getSparseScorer(reader, weight, boost, scoreMode)
+              : getDenseScorer(reader, weight, boost, scoreMode);
         default:
-          throw new IllegalArgumentException(
-              "Unsupported query type :[" + query.getQueryRelation() + "]");
+          throw new IllegalArgumentException("Unsupported query type :[" + queryRelation + "]");
       }
     }
 
-    /** Scorer used for INTERSECTS * */
+    /** Scorer used for INTERSECTS and single value points */
     private Scorer getSparseScorer(
         final LeafReader reader, final Weight weight, final float boost, final ScoreMode scoreMode)
         throws IOException {
-      if (values.getDocCount() == reader.maxDoc()
+      if (queryRelation == QueryRelation.DISJOINT
+          && values.getDocCount() == reader.maxDoc()
           && values.getDocCount() == values.size()
           && cost() > reader.maxDoc() / 2) {
         // If all docs have exactly one value and the cost is greater
@@ -309,29 +298,27 @@ abstract class ShapeQuery extends Query {
         final FixedBitSet result = new FixedBitSet(reader.maxDoc());
         result.set(0, reader.maxDoc());
         final long[] cost = new long[] {reader.maxDoc()};
-        values.intersect(getInverseDenseVisitor(query, result, cost));
+        values.intersect(getInverseDenseVisitor(spatialVisitor, queryRelation, result, cost));
         final DocIdSetIterator iterator = new BitSetIterator(result, cost[0]);
         return new ConstantScoreScorer(weight, boost, scoreMode, iterator);
-      }
-      if (values.getDocCount() < (values.size() >>> 2)) {
+      } else if (values.getDocCount() < (values.size() >>> 2)) {
         // we use a dense structure so we can skip already visited documents
         final FixedBitSet result = new FixedBitSet(reader.maxDoc());
         final long[] cost = new long[] {0};
-        values.intersect(getIntersectsDenseVisitor(query, result, cost));
+        values.intersect(getIntersectsDenseVisitor(spatialVisitor, queryRelation, result, cost));
         assert cost[0] > 0 || result.cardinality() == 0;
         final DocIdSetIterator iterator =
             cost[0] == 0 ? DocIdSetIterator.empty() : new BitSetIterator(result, cost[0]);
         return new ConstantScoreScorer(weight, boost, scoreMode, iterator);
       } else {
-        final DocIdSetBuilder docIdSetBuilder =
-            new DocIdSetBuilder(reader.maxDoc(), values, query.getField());
-        values.intersect(getSparseVisitor(query, docIdSetBuilder));
+        final DocIdSetBuilder docIdSetBuilder = new DocIdSetBuilder(reader.maxDoc(), values, field);
+        values.intersect(getSparseVisitor(spatialVisitor, queryRelation, docIdSetBuilder));
         final DocIdSetIterator iterator = docIdSetBuilder.build().iterator();
         return new ConstantScoreScorer(weight, boost, scoreMode, iterator);
       }
     }
 
-    /** Scorer used for WITHIN and DISJOINT * */
+    /** Scorer used for WITHIN and DISJOINT */
     private Scorer getDenseScorer(
         LeafReader reader, Weight weight, final float boost, ScoreMode scoreMode)
         throws IOException {
@@ -343,17 +330,17 @@ abstract class ShapeQuery extends Query {
         // are potential matches
         result.set(0, reader.maxDoc());
         // Remove false positives
-        values.intersect(getInverseDenseVisitor(query, result, cost));
+        values.intersect(getInverseDenseVisitor(spatialVisitor, queryRelation, result, cost));
       } else {
         cost = new long[] {0};
         // Get potential  documents.
         final FixedBitSet excluded = new FixedBitSet(reader.maxDoc());
-        values.intersect(getDenseVisitor(query, result, excluded, cost));
+        values.intersect(getDenseVisitor(spatialVisitor, queryRelation, result, excluded, cost));
         result.andNot(excluded);
         // Remove false positives, we only care about the inner nodes as intersecting
         // leaf nodes have been already taken into account. Unfortunately this
         // process still reads the leaf nodes.
-        values.intersect(getShallowInverseDenseVisitor(query, result));
+        values.intersect(getShallowInverseDenseVisitor(spatialVisitor, queryRelation, result));
       }
       assert cost[0] > 0 || result.cardinality() == 0;
       final DocIdSetIterator iterator =
@@ -368,7 +355,8 @@ abstract class ShapeQuery extends Query {
       final long[] cost = new long[] {0};
       // Get potential  documents.
       final FixedBitSet excluded = new FixedBitSet(reader.maxDoc());
-      values.intersect(getContainsDenseVisitor(query, result, excluded, cost));
+      values.intersect(
+          getContainsDenseVisitor(spatialVisitor, queryRelation, result, excluded, cost));
       result.andNot(excluded);
       assert cost[0] > 0 || result.cardinality() == 0;
       final DocIdSetIterator iterator =
@@ -380,7 +368,7 @@ abstract class ShapeQuery extends Query {
     public long cost() {
       if (cost == -1) {
         // Computing the cost may be expensive, so only do it if necessary
-        cost = values.estimateDocCount(getEstimateVisitor(query));
+        cost = values.estimateDocCount(getEstimateVisitor(spatialVisitor, queryRelation));
         assert cost >= 0;
       }
       return cost;
@@ -388,7 +376,10 @@ abstract class ShapeQuery extends Query {
   }
 
   /** create a visitor for calculating point count estimates for the provided relation */
-  private static IntersectVisitor getEstimateVisitor(final ShapeQuery query) {
+  private static IntersectVisitor getEstimateVisitor(
+      final SpatialVisitor spatialVisitor, QueryRelation queryRelation) {
+    final BiFunction<byte[], byte[], Relation> innerFunction =
+        spatialVisitor.getInnerFunction(queryRelation);
     return new IntersectVisitor() {
       @Override
       public void visit(int docID) {
@@ -402,7 +393,7 @@ abstract class ShapeQuery extends Query {
 
       @Override
       public Relation compare(byte[] minTriangle, byte[] maxTriangle) {
-        return query.relateRangeToQuery(minTriangle, maxTriangle, query.getQueryRelation());
+        return innerFunction.apply(minTriangle, maxTriangle);
       }
     };
   }
@@ -412,9 +403,13 @@ abstract class ShapeQuery extends Query {
    * INTERSECT when the number of docs <= 4 * number of points )
    */
   private static IntersectVisitor getSparseVisitor(
-      final ShapeQuery query, final DocIdSetBuilder result) {
+      final SpatialVisitor spatialVisitor,
+      QueryRelation queryRelation,
+      final DocIdSetBuilder result) {
+    final BiFunction<byte[], byte[], Relation> innerFunction =
+        spatialVisitor.getInnerFunction(queryRelation);
+    final Predicate<byte[]> leafPredicate = spatialVisitor.getLeafPredicate(queryRelation);
     return new IntersectVisitor() {
-      final ShapeField.DecodedTriangle scratchTriangle = new ShapeField.DecodedTriangle();
       DocIdSetBuilder.BulkAdder adder;
 
       @Override
@@ -429,14 +424,14 @@ abstract class ShapeQuery extends Query {
 
       @Override
       public void visit(int docID, byte[] t) {
-        if (query.queryMatches(t, scratchTriangle, query.getQueryRelation())) {
+        if (leafPredicate.test(t)) {
           visit(docID);
         }
       }
 
       @Override
       public void visit(DocIdSetIterator iterator, byte[] t) throws IOException {
-        if (query.queryMatches(t, scratchTriangle, query.getQueryRelation())) {
+        if (leafPredicate.test(t)) {
           int docID;
           while ((docID = iterator.nextDoc()) != DocIdSetIterator.NO_MORE_DOCS) {
             visit(docID);
@@ -446,16 +441,21 @@ abstract class ShapeQuery extends Query {
 
       @Override
       public Relation compare(byte[] minTriangle, byte[] maxTriangle) {
-        return query.relateRangeToQuery(minTriangle, maxTriangle, query.getQueryRelation());
+        return innerFunction.apply(minTriangle, maxTriangle);
       }
     };
   }
 
-  /** Scorer used for INTERSECTS when the number of points > 4 * number of docs * */
+  /** Scorer used for INTERSECTS when the number of points > 4 * number of docs */
   private static IntersectVisitor getIntersectsDenseVisitor(
-      final ShapeQuery query, final FixedBitSet result, final long[] cost) {
+      final SpatialVisitor spatialVisitor,
+      QueryRelation queryRelation,
+      final FixedBitSet result,
+      final long[] cost) {
+    final BiFunction<byte[], byte[], Relation> innerFunction =
+        spatialVisitor.getInnerFunction(queryRelation);
+    final Predicate<byte[]> leafPredicate = spatialVisitor.getLeafPredicate(queryRelation);
     return new IntersectVisitor() {
-      final ShapeField.DecodedTriangle scratchTriangle = new ShapeField.DecodedTriangle();
 
       @Override
       public void visit(int docID) {
@@ -466,7 +466,7 @@ abstract class ShapeQuery extends Query {
       @Override
       public void visit(int docID, byte[] t) {
         if (result.get(docID) == false) {
-          if (query.queryMatches(t, scratchTriangle, query.getQueryRelation())) {
+          if (leafPredicate.test(t)) {
             visit(docID);
           }
         }
@@ -474,7 +474,7 @@ abstract class ShapeQuery extends Query {
 
       @Override
       public void visit(DocIdSetIterator iterator, byte[] t) throws IOException {
-        if (query.queryMatches(t, scratchTriangle, query.getQueryRelation())) {
+        if (leafPredicate.test(t)) {
           int docID;
           while ((docID = iterator.nextDoc()) != DocIdSetIterator.NO_MORE_DOCS) {
             visit(docID);
@@ -484,7 +484,7 @@ abstract class ShapeQuery extends Query {
 
       @Override
       public Relation compare(byte[] minTriangle, byte[] maxTriangle) {
-        return query.relateRangeToQuery(minTriangle, maxTriangle, query.getQueryRelation());
+        return innerFunction.apply(minTriangle, maxTriangle);
       }
     };
   }
@@ -494,13 +494,15 @@ abstract class ShapeQuery extends Query {
    * WITHIN & DISJOINT
    */
   private static IntersectVisitor getDenseVisitor(
-      final ShapeQuery query,
+      final SpatialVisitor spatialVisitor,
+      final QueryRelation queryRelation,
       final FixedBitSet result,
       final FixedBitSet excluded,
       final long[] cost) {
+    final BiFunction<byte[], byte[], Relation> innerFunction =
+        spatialVisitor.getInnerFunction(queryRelation);
+    final Predicate<byte[]> leafPredicate = spatialVisitor.getLeafPredicate(queryRelation);
     return new IntersectVisitor() {
-      final ShapeField.DecodedTriangle scratchTriangle = new ShapeField.DecodedTriangle();
-
       @Override
       public void visit(int docID) {
         result.set(docID);
@@ -510,7 +512,7 @@ abstract class ShapeQuery extends Query {
       @Override
       public void visit(int docID, byte[] t) {
         if (excluded.get(docID) == false) {
-          if (query.queryMatches(t, scratchTriangle, query.getQueryRelation())) {
+          if (leafPredicate.test(t)) {
             visit(docID);
           } else {
             excluded.set(docID);
@@ -520,7 +522,7 @@ abstract class ShapeQuery extends Query {
 
       @Override
       public void visit(DocIdSetIterator iterator, byte[] t) throws IOException {
-        boolean matches = query.queryMatches(t, scratchTriangle, query.getQueryRelation());
+        boolean matches = leafPredicate.test(t);
         int docID;
         while ((docID = iterator.nextDoc()) != DocIdSetIterator.NO_MORE_DOCS) {
           if (matches) {
@@ -533,7 +535,7 @@ abstract class ShapeQuery extends Query {
 
       @Override
       public Relation compare(byte[] minTriangle, byte[] maxTriangle) {
-        return query.relateRangeToQuery(minTriangle, maxTriangle, query.getQueryRelation());
+        return innerFunction.apply(minTriangle, maxTriangle);
       }
     };
   }
@@ -543,13 +545,15 @@ abstract class ShapeQuery extends Query {
    * CONTAINS
    */
   private static IntersectVisitor getContainsDenseVisitor(
-      final ShapeQuery query,
+      final SpatialVisitor spatialVisitor,
+      final QueryRelation queryRelation,
       final FixedBitSet result,
       final FixedBitSet excluded,
       final long[] cost) {
+    final BiFunction<byte[], byte[], Relation> innerFunction =
+        spatialVisitor.getInnerFunction(queryRelation);
+    final Function<byte[], Component2D.WithinRelation> leafFunction = spatialVisitor.contains();
     return new IntersectVisitor() {
-      final ShapeField.DecodedTriangle scratchTriangle = new ShapeField.DecodedTriangle();
-
       @Override
       public void visit(int docID) {
         excluded.set(docID);
@@ -558,7 +562,7 @@ abstract class ShapeQuery extends Query {
       @Override
       public void visit(int docID, byte[] t) {
         if (excluded.get(docID) == false) {
-          Component2D.WithinRelation within = query.queryWithin(t, scratchTriangle);
+          Component2D.WithinRelation within = leafFunction.apply(t);
           if (within == Component2D.WithinRelation.CANDIDATE) {
             cost[0]++;
             result.set(docID);
@@ -570,7 +574,7 @@ abstract class ShapeQuery extends Query {
 
       @Override
       public void visit(DocIdSetIterator iterator, byte[] t) throws IOException {
-        Component2D.WithinRelation within = query.queryWithin(t, scratchTriangle);
+        Component2D.WithinRelation within = leafFunction.apply(t);
         int docID;
         while ((docID = iterator.nextDoc()) != DocIdSetIterator.NO_MORE_DOCS) {
           if (within == Component2D.WithinRelation.CANDIDATE) {
@@ -584,7 +588,7 @@ abstract class ShapeQuery extends Query {
 
       @Override
       public Relation compare(byte[] minTriangle, byte[] maxTriangle) {
-        return query.relateRangeToQuery(minTriangle, maxTriangle, query.getQueryRelation());
+        return innerFunction.apply(minTriangle, maxTriangle);
       }
     };
   }
@@ -594,9 +598,14 @@ abstract class ShapeQuery extends Query {
    * bitset; used with WITHIN & DISJOINT
    */
   private static IntersectVisitor getInverseDenseVisitor(
-      final ShapeQuery query, final FixedBitSet result, final long[] cost) {
+      final SpatialVisitor spatialVisitor,
+      final QueryRelation queryRelation,
+      final FixedBitSet result,
+      final long[] cost) {
+    final BiFunction<byte[], byte[], Relation> innerFunction =
+        spatialVisitor.getInnerFunction(queryRelation);
+    final Predicate<byte[]> leafPredicate = spatialVisitor.getLeafPredicate(queryRelation);
     return new IntersectVisitor() {
-      final ShapeField.DecodedTriangle scratchTriangle = new ShapeField.DecodedTriangle();
 
       @Override
       public void visit(int docID) {
@@ -607,8 +616,7 @@ abstract class ShapeQuery extends Query {
       @Override
       public void visit(int docID, byte[] packedTriangle) {
         if (result.get(docID)) {
-          if (query.queryMatches(packedTriangle, scratchTriangle, query.getQueryRelation())
-              == false) {
+          if (leafPredicate.test(packedTriangle) == false) {
             visit(docID);
           }
         }
@@ -616,7 +624,7 @@ abstract class ShapeQuery extends Query {
 
       @Override
       public void visit(DocIdSetIterator iterator, byte[] t) throws IOException {
-        if (query.queryMatches(t, scratchTriangle, query.getQueryRelation()) == false) {
+        if (leafPredicate.test(t) == false) {
           int docID;
           while ((docID = iterator.nextDoc()) != DocIdSetIterator.NO_MORE_DOCS) {
             visit(docID);
@@ -626,8 +634,7 @@ abstract class ShapeQuery extends Query {
 
       @Override
       public Relation compare(byte[] minPackedValue, byte[] maxPackedValue) {
-        return transposeRelation(
-            query.relateRangeToQuery(minPackedValue, maxPackedValue, query.getQueryRelation()));
+        return transposeRelation(innerFunction.apply(minPackedValue, maxPackedValue));
       }
     };
   }
@@ -637,7 +644,10 @@ abstract class ShapeQuery extends Query {
    * bitset; used with WITHIN & DISJOINT. This visitor only takes into account inner nodes
    */
   private static IntersectVisitor getShallowInverseDenseVisitor(
-      final ShapeQuery query, final FixedBitSet result) {
+      final SpatialVisitor spatialVisitor, QueryRelation queryRelation, final FixedBitSet result) {
+    final BiFunction<byte[], byte[], Relation> innerFunction =
+        spatialVisitor.getInnerFunction(queryRelation);
+    ;
     return new IntersectVisitor() {
 
       @Override
@@ -657,8 +667,7 @@ abstract class ShapeQuery extends Query {
 
       @Override
       public Relation compare(byte[] minPackedValue, byte[] maxPackedValue) {
-        return transposeRelation(
-            query.relateRangeToQuery(minPackedValue, maxPackedValue, query.getQueryRelation()));
+        return transposeRelation(innerFunction.apply(minPackedValue, maxPackedValue));
       }
     };
   }
@@ -667,12 +676,15 @@ abstract class ShapeQuery extends Query {
    * Return true if the query matches at least one document. It creates a visitor that terminates as
    * soon as one or more docs are matched.
    */
-  private static boolean hasAnyHits(final ShapeQuery query, final PointValues values)
+  private static boolean hasAnyHits(
+      final SpatialVisitor spatialVisitor, QueryRelation queryRelation, final PointValues values)
       throws IOException {
     try {
+      final BiFunction<byte[], byte[], Relation> innerFunction =
+          spatialVisitor.getInnerFunction(queryRelation);
+      final Predicate<byte[]> leafPredicate = spatialVisitor.getLeafPredicate(queryRelation);
       values.intersect(
           new IntersectVisitor() {
-            final ShapeField.DecodedTriangle scratchTriangle = new ShapeField.DecodedTriangle();
 
             @Override
             public void visit(int docID) {
@@ -681,23 +693,21 @@ abstract class ShapeQuery extends Query {
 
             @Override
             public void visit(int docID, byte[] t) {
-              if (query.queryMatches(t, scratchTriangle, query.getQueryRelation())) {
+              if (leafPredicate.test(t)) {
                 throw new CollectionTerminatedException();
               }
             }
 
             @Override
             public void visit(DocIdSetIterator iterator, byte[] t) {
-              if (query.queryMatches(t, scratchTriangle, query.getQueryRelation())) {
+              if (leafPredicate.test(t)) {
                 throw new CollectionTerminatedException();
               }
             }
 
             @Override
             public Relation compare(byte[] minPackedValue, byte[] maxPackedValue) {
-              Relation rel =
-                  query.relateRangeToQuery(
-                      minPackedValue, maxPackedValue, query.getQueryRelation());
+              Relation rel = innerFunction.apply(minPackedValue, maxPackedValue);
               if (rel == Relation.CELL_INSIDE_QUERY) {
                 throw new CollectionTerminatedException();
               }
diff --git a/lucene/core/src/java/org/apache/lucene/document/XYShapeQuery.java b/lucene/core/src/java/org/apache/lucene/document/XYShapeQuery.java
index e5cf429..9726d0b 100644
--- a/lucene/core/src/java/org/apache/lucene/document/XYShapeQuery.java
+++ b/lucene/core/src/java/org/apache/lucene/document/XYShapeQuery.java
@@ -19,6 +19,8 @@ package org.apache.lucene.document;
 import static org.apache.lucene.geo.XYEncodingUtils.decode;
 
 import java.util.Arrays;
+import java.util.function.Function;
+import java.util.function.Predicate;
 import org.apache.lucene.document.ShapeField.QueryRelation;
 import org.apache.lucene.geo.Component2D;
 import org.apache.lucene.geo.XYEncodingUtils;
@@ -32,7 +34,7 @@ import org.apache.lucene.util.NumericUtils;
  *
  * <p>The field must be indexed using {@link XYShape#createIndexableFields} added per document.
  */
-final class XYShapeQuery extends ShapeQuery {
+final class XYShapeQuery extends SpatialQuery {
   final XYGeometry[] geometries;
   private final Component2D component2D;
 
@@ -44,128 +46,148 @@ final class XYShapeQuery extends ShapeQuery {
   }
 
   @Override
-  protected Relation relateRangeBBoxToQuery(
-      int minXOffset,
-      int minYOffset,
-      byte[] minTriangle,
-      int maxXOffset,
-      int maxYOffset,
-      byte[] maxTriangle) {
-
-    double minY = XYEncodingUtils.decode(NumericUtils.sortableBytesToInt(minTriangle, minYOffset));
-    double minX = XYEncodingUtils.decode(NumericUtils.sortableBytesToInt(minTriangle, minXOffset));
-    double maxY = XYEncodingUtils.decode(NumericUtils.sortableBytesToInt(maxTriangle, maxYOffset));
-    double maxX = XYEncodingUtils.decode(NumericUtils.sortableBytesToInt(maxTriangle, maxXOffset));
-
-    // check internal node against query
-    return component2D.relate(minX, maxX, minY, maxY);
-  }
-
-  @Override
-  protected boolean queryIntersects(byte[] t, ShapeField.DecodedTriangle scratchTriangle) {
-    ShapeField.decodeTriangle(t, scratchTriangle);
-
-    switch (scratchTriangle.type) {
-      case POINT:
-        {
-          double y = decode(scratchTriangle.aY);
-          double x = decode(scratchTriangle.aX);
-          return component2D.contains(x, y);
-        }
-      case LINE:
-        {
-          double aY = decode(scratchTriangle.aY);
-          double aX = decode(scratchTriangle.aX);
-          double bY = decode(scratchTriangle.bY);
-          double bX = decode(scratchTriangle.bX);
-          return component2D.intersectsLine(aX, aY, bX, bY);
-        }
-      case TRIANGLE:
-        {
-          double aY = decode(scratchTriangle.aY);
-          double aX = decode(scratchTriangle.aX);
-          double bY = decode(scratchTriangle.bY);
-          double bX = decode(scratchTriangle.bX);
-          double cY = decode(scratchTriangle.cY);
-          double cX = decode(scratchTriangle.cX);
-          return component2D.intersectsTriangle(aX, aY, bX, bY, cX, cY);
-        }
-      default:
-        throw new IllegalArgumentException(
-            "Unsupported triangle type :[" + scratchTriangle.type + "]");
-    }
-  }
-
-  @Override
-  protected boolean queryContains(byte[] t, ShapeField.DecodedTriangle scratchTriangle) {
-    ShapeField.decodeTriangle(t, scratchTriangle);
-
-    switch (scratchTriangle.type) {
-      case POINT:
-        {
-          double y = decode(scratchTriangle.aY);
-          double x = decode(scratchTriangle.aX);
-          return component2D.contains(x, y);
-        }
-      case LINE:
-        {
-          double aY = decode(scratchTriangle.aY);
-          double aX = decode(scratchTriangle.aX);
-          double bY = decode(scratchTriangle.bY);
-          double bX = decode(scratchTriangle.bX);
-          return component2D.containsLine(aX, aY, bX, bY);
-        }
-      case TRIANGLE:
-        {
-          double aY = decode(scratchTriangle.aY);
-          double aX = decode(scratchTriangle.aX);
-          double bY = decode(scratchTriangle.bY);
-          double bX = decode(scratchTriangle.bX);
-          double cY = decode(scratchTriangle.cY);
-          double cX = decode(scratchTriangle.cX);
-          return component2D.containsTriangle(aX, aY, bX, bY, cX, cY);
-        }
-      default:
-        throw new IllegalArgumentException(
-            "Unsupported triangle type :[" + scratchTriangle.type + "]");
-    }
-  }
-
-  @Override
-  protected Component2D.WithinRelation queryWithin(
-      byte[] t, ShapeField.DecodedTriangle scratchTriangle) {
-    ShapeField.decodeTriangle(t, scratchTriangle);
-
-    switch (scratchTriangle.type) {
-      case POINT:
-        {
-          double y = decode(scratchTriangle.aY);
-          double x = decode(scratchTriangle.aX);
-          return component2D.withinPoint(x, y);
-        }
-      case LINE:
-        {
-          double aY = decode(scratchTriangle.aY);
-          double aX = decode(scratchTriangle.aX);
-          double bY = decode(scratchTriangle.bY);
-          double bX = decode(scratchTriangle.bX);
-          return component2D.withinLine(aX, aY, scratchTriangle.ab, bX, bY);
-        }
-      case TRIANGLE:
-        {
-          double aY = decode(scratchTriangle.aY);
-          double aX = decode(scratchTriangle.aX);
-          double bY = decode(scratchTriangle.bY);
-          double bX = decode(scratchTriangle.bX);
-          double cY = decode(scratchTriangle.cY);
-          double cX = decode(scratchTriangle.cX);
-          return component2D.withinTriangle(
-              aX, aY, scratchTriangle.ab, bX, bY, scratchTriangle.bc, cX, cY, scratchTriangle.ca);
-        }
-      default:
-        throw new IllegalArgumentException(
-            "Unsupported triangle type :[" + scratchTriangle.type + "]");
-    }
+  protected SpatialVisitor getSpatialVisitor() {
+    return new SpatialVisitor() {
+      @Override
+      protected Relation relate(byte[] minTriangle, byte[] maxTriangle) {
+
+        double minY = XYEncodingUtils.decode(NumericUtils.sortableBytesToInt(minTriangle, 0));
+        double minX =
+            XYEncodingUtils.decode(NumericUtils.sortableBytesToInt(minTriangle, ShapeField.BYTES));
+        double maxY =
+            XYEncodingUtils.decode(
+                NumericUtils.sortableBytesToInt(maxTriangle, 2 * ShapeField.BYTES));
+        double maxX =
+            XYEncodingUtils.decode(
+                NumericUtils.sortableBytesToInt(maxTriangle, 3 * ShapeField.BYTES));
+
+        // check internal node against query
+        return component2D.relate(minX, maxX, minY, maxY);
+      }
+
+      @Override
+      protected Predicate<byte[]> intersects() {
+        final ShapeField.DecodedTriangle scratchTriangle = new ShapeField.DecodedTriangle();
+        return triangle -> {
+          ShapeField.decodeTriangle(triangle, scratchTriangle);
+
+          switch (scratchTriangle.type) {
+            case POINT:
+              {
+                double y = decode(scratchTriangle.aY);
+                double x = decode(scratchTriangle.aX);
+                return component2D.contains(x, y);
+              }
+            case LINE:
+              {
+                double aY = decode(scratchTriangle.aY);
+                double aX = decode(scratchTriangle.aX);
+                double bY = decode(scratchTriangle.bY);
+                double bX = decode(scratchTriangle.bX);
+                return component2D.intersectsLine(aX, aY, bX, bY);
+              }
+            case TRIANGLE:
+              {
+                double aY = decode(scratchTriangle.aY);
+                double aX = decode(scratchTriangle.aX);
+                double bY = decode(scratchTriangle.bY);
+                double bX = decode(scratchTriangle.bX);
+                double cY = decode(scratchTriangle.cY);
+                double cX = decode(scratchTriangle.cX);
+                return component2D.intersectsTriangle(aX, aY, bX, bY, cX, cY);
+              }
+            default:
+              throw new IllegalArgumentException(
+                  "Unsupported triangle type :[" + scratchTriangle.type + "]");
+          }
+        };
+      }
+
+      @Override
+      protected Predicate<byte[]> within() {
+        final ShapeField.DecodedTriangle scratchTriangle = new ShapeField.DecodedTriangle();
+        return triangle -> {
+          ShapeField.decodeTriangle(triangle, scratchTriangle);
+
+          switch (scratchTriangle.type) {
+            case POINT:
+              {
+                double y = decode(scratchTriangle.aY);
+                double x = decode(scratchTriangle.aX);
+                return component2D.contains(x, y);
+              }
+            case LINE:
+              {
+                double aY = decode(scratchTriangle.aY);
+                double aX = decode(scratchTriangle.aX);
+                double bY = decode(scratchTriangle.bY);
+                double bX = decode(scratchTriangle.bX);
+                return component2D.containsLine(aX, aY, bX, bY);
+              }
+            case TRIANGLE:
+              {
+                double aY = decode(scratchTriangle.aY);
+                double aX = decode(scratchTriangle.aX);
+                double bY = decode(scratchTriangle.bY);
+                double bX = decode(scratchTriangle.bX);
+                double cY = decode(scratchTriangle.cY);
+                double cX = decode(scratchTriangle.cX);
+                return component2D.containsTriangle(aX, aY, bX, bY, cX, cY);
+              }
+            default:
+              throw new IllegalArgumentException(
+                  "Unsupported triangle type :[" + scratchTriangle.type + "]");
+          }
+        };
+      }
+
+      @Override
+      protected Function<byte[], Component2D.WithinRelation> contains() {
+        final ShapeField.DecodedTriangle scratchTriangle = new ShapeField.DecodedTriangle();
+        return triangle -> {
+          ShapeField.decodeTriangle(triangle, scratchTriangle);
+
+          switch (scratchTriangle.type) {
+            case POINT:
+              {
+                double y = decode(scratchTriangle.aY);
+                double x = decode(scratchTriangle.aX);
+                return component2D.withinPoint(x, y);
+              }
+            case LINE:
+              {
+                double aY = decode(scratchTriangle.aY);
+                double aX = decode(scratchTriangle.aX);
+                double bY = decode(scratchTriangle.bY);
+                double bX = decode(scratchTriangle.bX);
+                return component2D.withinLine(aX, aY, scratchTriangle.ab, bX, bY);
+              }
+            case TRIANGLE:
+              {
+                double aY = decode(scratchTriangle.aY);
+                double aX = decode(scratchTriangle.aX);
+                double bY = decode(scratchTriangle.bY);
+                double bX = decode(scratchTriangle.bX);
+                double cY = decode(scratchTriangle.cY);
+                double cX = decode(scratchTriangle.cX);
+                return component2D.withinTriangle(
+                    aX,
+                    aY,
+                    scratchTriangle.ab,
+                    bX,
+                    bY,
+                    scratchTriangle.bc,
+                    cX,
+                    cY,
+                    scratchTriangle.ca);
+              }
+            default:
+              throw new IllegalArgumentException(
+                  "Unsupported triangle type :[" + scratchTriangle.type + "]");
+          }
+        };
+      }
+    };
   }
 
   @Override
diff --git a/lucene/core/src/java/org/apache/lucene/geo/GeoEncodingUtils.java b/lucene/core/src/java/org/apache/lucene/geo/GeoEncodingUtils.java
index e673c9e..d7ff62b 100644
--- a/lucene/core/src/java/org/apache/lucene/geo/GeoEncodingUtils.java
+++ b/lucene/core/src/java/org/apache/lucene/geo/GeoEncodingUtils.java
@@ -178,7 +178,13 @@ public final class GeoEncodingUtils {
         box ->
             GeoUtils.relate(
                 box.minLat, box.maxLat, box.minLon, box.maxLon, lat, lon, distanceSortKey, axisLat);
-    final Grid subBoxes = createSubBoxes(boundingBox, boxToRelation);
+    final Grid subBoxes =
+        createSubBoxes(
+            boundingBox.minLat,
+            boundingBox.maxLat,
+            boundingBox.minLon,
+            boundingBox.maxLon,
+            boxToRelation);
 
     return new DistancePredicate(
         subBoxes.latShift,
@@ -200,11 +206,11 @@ public final class GeoEncodingUtils {
    * @lucene.internal
    */
   public static Component2DPredicate createComponentPredicate(Component2D tree) {
-    final Rectangle boundingBox =
-        new Rectangle(tree.getMinY(), tree.getMaxY(), tree.getMinX(), tree.getMaxX());
     final Function<Rectangle, Relation> boxToRelation =
         box -> tree.relate(box.minLon, box.maxLon, box.minLat, box.maxLat);
-    final Grid subBoxes = createSubBoxes(boundingBox, boxToRelation);
+    final Grid subBoxes =
+        createSubBoxes(
+            tree.getMinY(), tree.getMaxY(), tree.getMinX(), tree.getMaxX(), boxToRelation);
 
     return new Component2DPredicate(
         subBoxes.latShift,
@@ -218,13 +224,17 @@ public final class GeoEncodingUtils {
   }
 
   private static Grid createSubBoxes(
-      Rectangle boundingBox, Function<Rectangle, Relation> boxToRelation) {
-    final int minLat = encodeLatitudeCeil(boundingBox.minLat);
-    final int maxLat = encodeLatitude(boundingBox.maxLat);
-    final int minLon = encodeLongitudeCeil(boundingBox.minLon);
-    final int maxLon = encodeLongitude(boundingBox.maxLon);
-
-    if (maxLat < minLat || (boundingBox.crossesDateline() == false && maxLon < minLon)) {
+      double shapeMinLat,
+      double shapeMaxLat,
+      double shapeMinLon,
+      double shapeMaxLon,
+      Function<Rectangle, Relation> boxToRelation) {
+    final int minLat = encodeLatitudeCeil(shapeMinLat);
+    final int maxLat = encodeLatitude(shapeMaxLat);
+    final int minLon = encodeLongitudeCeil(shapeMinLon);
+    final int maxLon = encodeLongitude(shapeMaxLon);
+
+    if (maxLat < minLat || (shapeMaxLon >= shapeMinLon && maxLon < minLon)) {
       // the box cannot match any quantized point
       return new Grid(1, 1, 0, 0, 0, 0, new byte[0]);
     }
@@ -243,7 +253,7 @@ public final class GeoEncodingUtils {
     {
       long minLon2 = (long) minLon - Integer.MIN_VALUE;
       long maxLon2 = (long) maxLon - Integer.MIN_VALUE;
-      if (boundingBox.crossesDateline()) {
+      if (shapeMaxLon < shapeMinLon) { // crosses dateline
         maxLon2 += 1L << 32; // wrap
       }
       lonShift = computeShift(minLon2, maxLon2);
@@ -265,10 +275,8 @@ public final class GeoEncodingUtils {
                 boxToRelation
                     .apply(
                         new Rectangle(
-                            decodeLatitude(boxMinLat),
-                            decodeLatitude(boxMaxLat),
-                            decodeLongitude(boxMinLon),
-                            decodeLongitude(boxMaxLon)))
+                            decodeLatitude(boxMinLat), decodeLatitude(boxMaxLat),
+                            decodeLongitude(boxMinLon), decodeLongitude(boxMaxLon)))
                     .ordinal();
       }
     }
diff --git a/lucene/core/src/java/org/apache/lucene/geo/Point2D.java b/lucene/core/src/java/org/apache/lucene/geo/Point2D.java
index 377b5b4..32b9f42 100644
--- a/lucene/core/src/java/org/apache/lucene/geo/Point2D.java
+++ b/lucene/core/src/java/org/apache/lucene/geo/Point2D.java
@@ -167,9 +167,17 @@ final class Point2D implements Component2D {
 
   /** create a Point2D component tree from a LatLon point */
   static Component2D create(Point point) {
-    return new Point2D(
-        GeoEncodingUtils.decodeLongitude(GeoEncodingUtils.encodeLongitude(point.getLon())),
-        GeoEncodingUtils.decodeLatitude(GeoEncodingUtils.encodeLatitude(point.getLat())));
+    // Points behave as rectangles
+    double qLat =
+        point.getLat() == GeoUtils.MAX_LAT_INCL
+            ? point.getLat()
+            : GeoEncodingUtils.decodeLatitude(GeoEncodingUtils.encodeLatitudeCeil(point.getLat()));
+    double qLon =
+        point.getLon() == GeoUtils.MAX_LON_INCL
+            ? point.getLon()
+            : GeoEncodingUtils.decodeLongitude(
+                GeoEncodingUtils.encodeLongitudeCeil(point.getLon()));
+    return new Point2D(qLon, qLat);
   }
 
   /** create a Point2D component tree from a XY point */
diff --git a/lucene/core/src/test/org/apache/lucene/document/BaseLatLonDocValueTestCase.java b/lucene/core/src/test/org/apache/lucene/document/BaseLatLonDocValueTestCase.java
new file mode 100644
index 0000000..2e96c3a
--- /dev/null
+++ b/lucene/core/src/test/org/apache/lucene/document/BaseLatLonDocValueTestCase.java
@@ -0,0 +1,72 @@
+/*
+ * 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.document;
+
+import java.util.Arrays;
+import org.apache.lucene.document.ShapeField.QueryRelation;
+import org.apache.lucene.geo.Circle;
+import org.apache.lucene.geo.Line;
+import org.apache.lucene.geo.Point;
+import org.apache.lucene.geo.Polygon;
+import org.apache.lucene.geo.Rectangle;
+import org.apache.lucene.search.Query;
+
+/**
+ * Base test case for testing geospatial indexing and search functionality for {@link
+ * LatLonDocValuesField} *
+ */
+public abstract class BaseLatLonDocValueTestCase extends BaseLatLonSpatialTestCase {
+
+  @Override
+  protected Query newRectQuery(
+      String field,
+      QueryRelation queryRelation,
+      double minLon,
+      double maxLon,
+      double minLat,
+      double maxLat) {
+    return LatLonDocValuesField.newSlowGeometryQuery(
+        field, queryRelation, new Rectangle(minLat, maxLat, minLon, maxLon));
+  }
+
+  @Override
+  protected Query newLineQuery(String field, QueryRelation queryRelation, Object... lines) {
+    return LatLonDocValuesField.newSlowGeometryQuery(
+        field, queryRelation, Arrays.stream(lines).toArray(Line[]::new));
+  }
+
+  @Override
+  protected Query newPolygonQuery(String field, QueryRelation queryRelation, Object... polygons) {
+    return LatLonDocValuesField.newSlowGeometryQuery(
+        field, queryRelation, Arrays.stream(polygons).toArray(Polygon[]::new));
+  }
+
+  @Override
+  protected Query newDistanceQuery(String field, QueryRelation queryRelation, Object circle) {
+    return LatLonDocValuesField.newSlowGeometryQuery(field, queryRelation, (Circle) circle);
+  }
+
+  @Override
+  protected Query newPointsQuery(String field, QueryRelation queryRelation, Object... points) {
+    Point[] pointsArray = new Point[points.length];
+    for (int i = 0; i < points.length; i++) {
+      double[] point = (double[]) points[i];
+      pointsArray[i] = new Point(point[0], point[1]);
+    }
+    return LatLonDocValuesField.newSlowGeometryQuery(field, queryRelation, pointsArray);
+  }
+}
diff --git a/lucene/core/src/test/org/apache/lucene/document/BaseLatLonPointTestCase.java b/lucene/core/src/test/org/apache/lucene/document/BaseLatLonPointTestCase.java
new file mode 100644
index 0000000..d886979
--- /dev/null
+++ b/lucene/core/src/test/org/apache/lucene/document/BaseLatLonPointTestCase.java
@@ -0,0 +1,139 @@
+/*
+ * 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.document;
+
+import com.carrotsearch.randomizedtesting.generators.RandomPicks;
+import java.util.Arrays;
+import org.apache.lucene.document.ShapeField.QueryRelation;
+import org.apache.lucene.geo.Circle;
+import org.apache.lucene.geo.GeoTestUtil;
+import org.apache.lucene.geo.Line;
+import org.apache.lucene.geo.Point;
+import org.apache.lucene.geo.Polygon;
+import org.apache.lucene.geo.Rectangle;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.index.RandomIndexWriter;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.QueryUtils;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.util.IOUtils;
+
+/**
+ * Base test case for testing geospatial indexing and search functionality for {@link LatLonPoint} *
+ */
+public abstract class BaseLatLonPointTestCase extends BaseLatLonSpatialTestCase {
+
+  @Override
+  protected Query newRectQuery(
+      String field,
+      QueryRelation queryRelation,
+      double minLon,
+      double maxLon,
+      double minLat,
+      double maxLat) {
+    return LatLonPoint.newGeometryQuery(
+        field, queryRelation, new Rectangle(minLat, maxLat, minLon, maxLon));
+  }
+
+  @Override
+  protected Query newLineQuery(String field, QueryRelation queryRelation, Object... lines) {
+    return LatLonPoint.newGeometryQuery(
+        field, queryRelation, Arrays.stream(lines).toArray(Line[]::new));
+  }
+
+  @Override
+  protected Query newPolygonQuery(String field, QueryRelation queryRelation, Object... polygons) {
+    return LatLonPoint.newGeometryQuery(
+        field, queryRelation, Arrays.stream(polygons).toArray(Polygon[]::new));
+  }
+
+  @Override
+  protected Query newDistanceQuery(String field, QueryRelation queryRelation, Object circle) {
+    return LatLonPoint.newGeometryQuery(field, queryRelation, (Circle) circle);
+  }
+
+  @Override
+  protected Query newPointsQuery(String field, QueryRelation queryRelation, Object... points) {
+    Point[] pointsArray = new Point[points.length];
+    for (int i = 0; i < points.length; i++) {
+      double[] point = (double[]) points[i];
+      pointsArray[i] = new Point(point[0], point[1]);
+    }
+    return LatLonPoint.newGeometryQuery(field, queryRelation, pointsArray);
+  }
+
+  public void testBoundingBoxQueriesEquivalence() throws Exception {
+    int numShapes = atLeast(20);
+
+    Directory dir = newDirectory();
+    RandomIndexWriter w = new RandomIndexWriter(random(), dir);
+
+    for (int i = 0; i < numShapes; i++) {
+      indexRandomShapes(w.w, nextShape());
+    }
+    if (random().nextBoolean()) {
+      w.forceMerge(1);
+    }
+
+    ///// search //////
+    IndexReader reader = w.getReader();
+    w.close();
+    IndexSearcher searcher = newSearcher(reader);
+
+    Rectangle box = GeoTestUtil.nextBox();
+
+    Query q1 = LatLonPoint.newBoxQuery(FIELD_NAME, box.minLat, box.maxLat, box.minLon, box.maxLon);
+    Query q2 = new LatLonPointQuery(FIELD_NAME, QueryRelation.INTERSECTS, box);
+    assertEquals(searcher.count(q1), searcher.count(q2));
+
+    IOUtils.close(w, reader, dir);
+  }
+
+  public void testQueryEqualsAndHashcode() {
+    Polygon polygon = GeoTestUtil.nextPolygon();
+    QueryRelation queryRelation =
+        RandomPicks.randomFrom(
+            random(), new QueryRelation[] {QueryRelation.INTERSECTS, QueryRelation.DISJOINT});
+    String fieldName = "foo";
+    Query q1 = newPolygonQuery(fieldName, queryRelation, polygon);
+    Query q2 = newPolygonQuery(fieldName, queryRelation, polygon);
+    QueryUtils.checkEqual(q1, q2);
+    // different field name
+    Query q3 = newPolygonQuery("bar", queryRelation, polygon);
+    QueryUtils.checkUnequal(q1, q3);
+    // different query relation
+    QueryRelation newQueryRelation =
+        RandomPicks.randomFrom(
+            random(), new QueryRelation[] {QueryRelation.INTERSECTS, QueryRelation.DISJOINT});
+    Query q4 = newPolygonQuery(fieldName, newQueryRelation, polygon);
+    if (queryRelation == newQueryRelation) {
+      QueryUtils.checkEqual(q1, q4);
+    } else {
+      QueryUtils.checkUnequal(q1, q4);
+    }
+    // different shape
+    Polygon newPolygon = GeoTestUtil.nextPolygon();
+    ;
+    Query q5 = newPolygonQuery(fieldName, queryRelation, newPolygon);
+    if (polygon.equals(newPolygon)) {
+      QueryUtils.checkEqual(q1, q5);
+    } else {
+      QueryUtils.checkUnequal(q1, q5);
+    }
+  }
+}
diff --git a/lucene/core/src/test/org/apache/lucene/document/BaseLatLonShapeTestCase.java b/lucene/core/src/test/org/apache/lucene/document/BaseLatLonShapeTestCase.java
index 0426ac0..36898a8 100644
--- a/lucene/core/src/test/org/apache/lucene/document/BaseLatLonShapeTestCase.java
+++ b/lucene/core/src/test/org/apache/lucene/document/BaseLatLonShapeTestCase.java
@@ -16,28 +16,14 @@
  */
 package org.apache.lucene.document;
 
-import static org.apache.lucene.geo.GeoEncodingUtils.decodeLatitude;
-import static org.apache.lucene.geo.GeoEncodingUtils.decodeLongitude;
-import static org.apache.lucene.geo.GeoEncodingUtils.encodeLatitude;
-import static org.apache.lucene.geo.GeoEncodingUtils.encodeLatitudeCeil;
-import static org.apache.lucene.geo.GeoEncodingUtils.encodeLongitude;
-import static org.apache.lucene.geo.GeoEncodingUtils.encodeLongitudeCeil;
-import static org.apache.lucene.geo.GeoTestUtil.nextLatitude;
-import static org.apache.lucene.geo.GeoTestUtil.nextLongitude;
-
 import com.carrotsearch.randomizedtesting.generators.RandomPicks;
 import java.util.Arrays;
 import org.apache.lucene.document.ShapeField.QueryRelation;
 import org.apache.lucene.geo.Circle;
-import org.apache.lucene.geo.Component2D;
 import org.apache.lucene.geo.GeoTestUtil;
-import org.apache.lucene.geo.GeoUtils;
-import org.apache.lucene.geo.LatLonGeometry;
 import org.apache.lucene.geo.Line;
-import org.apache.lucene.geo.Point;
 import org.apache.lucene.geo.Polygon;
 import org.apache.lucene.geo.Rectangle;
-import org.apache.lucene.geo.Tessellator;
 import org.apache.lucene.index.IndexReader;
 import org.apache.lucene.index.RandomIndexWriter;
 import org.apache.lucene.search.IndexSearcher;
@@ -45,18 +31,12 @@ import org.apache.lucene.search.Query;
 import org.apache.lucene.search.QueryUtils;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.util.IOUtils;
-import org.apache.lucene.util.TestUtil;
-
-/** Base test case for testing geospatial indexing and search functionality * */
-public abstract class BaseLatLonShapeTestCase extends BaseShapeTestCase {
-
-  protected abstract ShapeType getShapeType();
 
-  protected Object nextShape() {
-    return getShapeType().nextShape();
-  }
+/**
+ * Base test case for testing geospatial indexing and search functionality for {@link LatLonShape} *
+ */
+public abstract class BaseLatLonShapeTestCase extends BaseLatLonSpatialTestCase {
 
-  /** factory method to create a new bounding box query */
   @Override
   protected Query newRectQuery(
       String field,
@@ -68,14 +48,12 @@ public abstract class BaseLatLonShapeTestCase extends BaseShapeTestCase {
     return LatLonShape.newBoxQuery(field, queryRelation, minLat, maxLat, minLon, maxLon);
   }
 
-  /** factory method to create a new line query */
   @Override
   protected Query newLineQuery(String field, QueryRelation queryRelation, Object... lines) {
     return LatLonShape.newLineQuery(
         field, queryRelation, Arrays.stream(lines).toArray(Line[]::new));
   }
 
-  /** factory method to create a new polygon query */
   @Override
   protected Query newPolygonQuery(String field, QueryRelation queryRelation, Object... polygons) {
     return LatLonShape.newPolygonQuery(
@@ -89,76 +67,56 @@ public abstract class BaseLatLonShapeTestCase extends BaseShapeTestCase {
   }
 
   @Override
-  protected Component2D toLine2D(Object... lines) {
-    return LatLonGeometry.create(Arrays.stream(lines).toArray(Line[]::new));
-  }
-
-  @Override
-  protected Component2D toPolygon2D(Object... polygons) {
-    return LatLonGeometry.create(Arrays.stream(polygons).toArray(Polygon[]::new));
-  }
-
-  @Override
-  protected Component2D toRectangle2D(double minX, double maxX, double minY, double maxY) {
-    return LatLonGeometry.create(new Rectangle(minY, maxY, minX, maxX));
-  }
-
-  @Override
-  protected Component2D toPoint2D(Object... points) {
-    double[][] p = Arrays.stream(points).toArray(double[][]::new);
-    org.apache.lucene.geo.Point[] pointArray = new org.apache.lucene.geo.Point[points.length];
-    for (int i = 0; i < points.length; i++) {
-      pointArray[i] = new org.apache.lucene.geo.Point(p[i][0], p[i][1]);
-    }
-    return LatLonGeometry.create(pointArray);
-  }
-
-  @Override
   protected Query newDistanceQuery(String field, QueryRelation queryRelation, Object circle) {
     return LatLonShape.newDistanceQuery(field, queryRelation, (Circle) circle);
   }
 
-  @Override
-  protected Component2D toCircle2D(Object circle) {
-    return LatLonGeometry.create((Circle) circle);
-  }
-
-  @Override
-  protected Circle nextCircle() {
-    final double radiusMeters =
-        random().nextDouble() * GeoUtils.EARTH_MEAN_RADIUS_METERS * Math.PI / 2.0 + 1.0;
-    return new Circle(nextLatitude(), nextLongitude(), radiusMeters);
-  }
+  public void testBoundingBoxQueriesEquivalence() throws Exception {
+    int numShapes = atLeast(20);
 
-  @Override
-  public Rectangle randomQueryBox() {
-    return GeoTestUtil.nextBox();
-  }
+    Directory dir = newDirectory();
+    RandomIndexWriter w = new RandomIndexWriter(random(), dir);
 
-  @Override
-  protected Object[] nextPoints() {
-    int numPoints = TestUtil.nextInt(random(), 1, 20);
-    double[][] points = new double[numPoints][2];
-    for (int i = 0; i < numPoints; i++) {
-      points[i][0] = nextLatitude();
-      points[i][1] = nextLongitude();
+    for (int i = 0; i < numShapes; i++) {
+      indexRandomShapes(w.w, nextShape());
+    }
+    if (random().nextBoolean()) {
+      w.forceMerge(1);
     }
-    return points;
-  }
 
-  @Override
-  protected double rectMinX(Object rect) {
-    return ((Rectangle) rect).minLon;
-  }
+    ///// search //////
+    IndexReader reader = w.getReader();
+    w.close();
+    IndexSearcher searcher = newSearcher(reader);
 
-  @Override
-  protected double rectMaxX(Object rect) {
-    return ((Rectangle) rect).maxLon;
-  }
+    Rectangle box = GeoTestUtil.nextBox();
 
-  @Override
-  protected double rectMinY(Object rect) {
-    return ((Rectangle) rect).minLat;
+    Query q1 =
+        LatLonShape.newBoxQuery(
+            FIELD_NAME, QueryRelation.INTERSECTS, box.minLat, box.maxLat, box.minLon, box.maxLon);
+    Query q2 = new LatLonShapeQuery(FIELD_NAME, QueryRelation.INTERSECTS, box);
+    assertEquals(searcher.count(q1), searcher.count(q2));
+    q1 =
+        LatLonShape.newBoxQuery(
+            FIELD_NAME, QueryRelation.WITHIN, box.minLat, box.maxLat, box.minLon, box.maxLon);
+    q2 = new LatLonShapeQuery(FIELD_NAME, QueryRelation.WITHIN, box);
+    assertEquals(searcher.count(q1), searcher.count(q2));
+    q1 =
+        LatLonShape.newBoxQuery(
+            FIELD_NAME, QueryRelation.CONTAINS, box.minLat, box.maxLat, box.minLon, box.maxLon);
+    if (box.crossesDateline()) {
+      q2 = LatLonShape.newGeometryQuery(FIELD_NAME, QueryRelation.CONTAINS, box);
+    } else {
+      q2 = new LatLonShapeQuery(FIELD_NAME, QueryRelation.CONTAINS, box);
+    }
+    assertEquals(searcher.count(q1), searcher.count(q2));
+    q1 =
+        LatLonShape.newBoxQuery(
+            FIELD_NAME, QueryRelation.DISJOINT, box.minLat, box.maxLat, box.minLon, box.maxLon);
+    q2 = new LatLonShapeQuery(FIELD_NAME, QueryRelation.DISJOINT, box);
+    assertEquals(searcher.count(q1), searcher.count(q2));
+
+    IOUtils.close(w, reader, dir);
   }
 
   public void testBoxQueryEqualsAndHashcode() {
@@ -224,11 +182,6 @@ public abstract class BaseLatLonShapeTestCase extends BaseShapeTestCase {
     }
   }
 
-  /** factory method to create a new line query */
-  protected Query newLineQuery(String field, QueryRelation queryRelation, Line... lines) {
-    return LatLonShape.newLineQuery(field, queryRelation, lines);
-  }
-
   public void testLineQueryEqualsAndHashcode() {
     Line line = nextLine();
     QueryRelation queryRelation = RandomPicks.randomFrom(random(), POINT_LINE_RELATIONS);
@@ -257,59 +210,6 @@ public abstract class BaseLatLonShapeTestCase extends BaseShapeTestCase {
     }
   }
 
-  public void testBoundingBoxQueriesEquivalence() throws Exception {
-    int numShapes = atLeast(20);
-
-    Directory dir = newDirectory();
-    RandomIndexWriter w = new RandomIndexWriter(random(), dir);
-
-    for (int i = 0; i < numShapes; i++) {
-      indexRandomShapes(w.w, nextShape());
-    }
-    if (random().nextBoolean()) {
-      w.forceMerge(1);
-    }
-
-    ///// search //////
-    IndexReader reader = w.getReader();
-    w.close();
-    IndexSearcher searcher = newSearcher(reader);
-
-    Rectangle box = GeoTestUtil.nextBox();
-
-    Query q1 =
-        LatLonShape.newBoxQuery(
-            FIELD_NAME, QueryRelation.INTERSECTS, box.minLat, box.maxLat, box.minLon, box.maxLon);
-    Query q2 = new LatLonShapeQuery(FIELD_NAME, QueryRelation.INTERSECTS, box);
-    assertEquals(searcher.count(q1), searcher.count(q2));
-    q1 =
-        LatLonShape.newBoxQuery(
-            FIELD_NAME, QueryRelation.WITHIN, box.minLat, box.maxLat, box.minLon, box.maxLon);
-    q2 = new LatLonShapeQuery(FIELD_NAME, QueryRelation.WITHIN, box);
-    assertEquals(searcher.count(q1), searcher.count(q2));
-    q1 =
-        LatLonShape.newBoxQuery(
-            FIELD_NAME, QueryRelation.CONTAINS, box.minLat, box.maxLat, box.minLon, box.maxLon);
-    if (box.crossesDateline()) {
-      q2 = LatLonShape.newGeometryQuery(FIELD_NAME, QueryRelation.CONTAINS, box);
-    } else {
-      q2 = new LatLonShapeQuery(FIELD_NAME, QueryRelation.CONTAINS, box);
-    }
-    assertEquals(searcher.count(q1), searcher.count(q2));
-    q1 =
-        LatLonShape.newBoxQuery(
-            FIELD_NAME, QueryRelation.DISJOINT, box.minLat, box.maxLat, box.minLon, box.maxLon);
-    q2 = new LatLonShapeQuery(FIELD_NAME, QueryRelation.DISJOINT, box);
-    assertEquals(searcher.count(q1), searcher.count(q2));
-
-    IOUtils.close(w, reader, dir);
-  }
-
-  /** factory method to create a new polygon query */
-  protected Query newPolygonQuery(String field, QueryRelation queryRelation, Polygon... polygons) {
-    return LatLonShape.newPolygonQuery(field, queryRelation, polygons);
-  }
-
   public void testPolygonQueryEqualsAndHashcode() {
     Polygon polygon = GeoTestUtil.nextPolygon();
     QueryRelation queryRelation = RandomPicks.randomFrom(random(), QueryRelation.values());
@@ -338,99 +238,4 @@ public abstract class BaseLatLonShapeTestCase extends BaseShapeTestCase {
       QueryUtils.checkUnequal(q1, q5);
     }
   }
-
-  @Override
-  protected double rectMaxY(Object rect) {
-    return ((Rectangle) rect).maxLat;
-  }
-
-  @Override
-  protected boolean rectCrossesDateline(Object rect) {
-    return ((Rectangle) rect).crossesDateline();
-  }
-
-  @Override
-  public Line nextLine() {
-    return GeoTestUtil.nextLine();
-  }
-
-  @Override
-  protected Polygon nextPolygon() {
-    return GeoTestUtil.nextPolygon();
-  }
-
-  @Override
-  protected Encoder getEncoder() {
-    return new Encoder() {
-      @Override
-      double decodeX(int encoded) {
-        return decodeLongitude(encoded);
-      }
-
-      @Override
-      double decodeY(int encoded) {
-        return decodeLatitude(encoded);
-      }
-
-      @Override
-      double quantizeX(double raw) {
-        return decodeLongitude(encodeLongitude(raw));
-      }
-
-      @Override
-      double quantizeXCeil(double raw) {
-        return decodeLongitude(encodeLongitudeCeil(raw));
-      }
-
-      @Override
-      double quantizeY(double raw) {
-        return decodeLatitude(encodeLatitude(raw));
-      }
-
-      @Override
-      double quantizeYCeil(double raw) {
-        return decodeLatitude(encodeLatitudeCeil(raw));
-      }
-    };
-  }
-
-  /** internal shape type for testing different shape types */
-  protected enum ShapeType {
-    POINT() {
-      public Point nextShape() {
-        return GeoTestUtil.nextPoint();
-      }
-    },
-    LINE() {
-      public Line nextShape() {
-        return GeoTestUtil.nextLine();
-      }
-    },
-    POLYGON() {
-      public Polygon nextShape() {
-        while (true) {
-          Polygon p = GeoTestUtil.nextPolygon();
-          try {
-            Tessellator.tessellate(p);
-            return p;
-          } catch (IllegalArgumentException e) {
-            // if we can't tessellate; then random polygon generator created a malformed shape
-          }
-        }
-      }
-    },
-    MIXED() {
-      public Object nextShape() {
-        return RandomPicks.randomFrom(random(), subList).nextShape();
-      }
-    };
-
-    static ShapeType[] subList;
-
-    static {
-      subList = new ShapeType[] {POINT, LINE, POLYGON};
-    }
-
-    public abstract Object nextShape();
-  }
 }
diff --git a/lucene/core/src/test/org/apache/lucene/document/BaseLatLonSpatialTestCase.java b/lucene/core/src/test/org/apache/lucene/document/BaseLatLonSpatialTestCase.java
new file mode 100644
index 0000000..518c9be
--- /dev/null
+++ b/lucene/core/src/test/org/apache/lucene/document/BaseLatLonSpatialTestCase.java
@@ -0,0 +1,220 @@
+/*
+ * 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.document;
+
+import static org.apache.lucene.geo.GeoEncodingUtils.decodeLatitude;
+import static org.apache.lucene.geo.GeoEncodingUtils.decodeLongitude;
+import static org.apache.lucene.geo.GeoEncodingUtils.encodeLatitude;
+import static org.apache.lucene.geo.GeoEncodingUtils.encodeLatitudeCeil;
+import static org.apache.lucene.geo.GeoEncodingUtils.encodeLongitude;
+import static org.apache.lucene.geo.GeoEncodingUtils.encodeLongitudeCeil;
+import static org.apache.lucene.geo.GeoTestUtil.nextLatitude;
+import static org.apache.lucene.geo.GeoTestUtil.nextLongitude;
+
+import com.carrotsearch.randomizedtesting.generators.RandomPicks;
+import java.util.Arrays;
+import org.apache.lucene.document.ShapeField.QueryRelation;
+import org.apache.lucene.geo.Circle;
+import org.apache.lucene.geo.Component2D;
+import org.apache.lucene.geo.GeoTestUtil;
+import org.apache.lucene.geo.GeoUtils;
+import org.apache.lucene.geo.LatLonGeometry;
+import org.apache.lucene.geo.Line;
+import org.apache.lucene.geo.Point;
+import org.apache.lucene.geo.Polygon;
+import org.apache.lucene.geo.Rectangle;
+import org.apache.lucene.geo.Tessellator;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.util.TestUtil;
+
+/** Base test case for testing geospatial indexing and search functionality * */
+public abstract class BaseLatLonSpatialTestCase extends BaseSpatialTestCase {
+
+  protected abstract ShapeType getShapeType();
+
+  protected Object nextShape() {
+    return getShapeType().nextShape();
+  }
+
+  @Override
+  protected Component2D toLine2D(Object... lines) {
+    return LatLonGeometry.create(Arrays.stream(lines).toArray(Line[]::new));
+  }
+
+  @Override
+  protected Component2D toPolygon2D(Object... polygons) {
+    return LatLonGeometry.create(Arrays.stream(polygons).toArray(Polygon[]::new));
+  }
+
+  @Override
+  protected Component2D toRectangle2D(double minX, double maxX, double minY, double maxY) {
+    return LatLonGeometry.create(new Rectangle(minY, maxY, minX, maxX));
+  }
+
+  @Override
+  protected Component2D toPoint2D(Object... points) {
+    double[][] p = Arrays.stream(points).toArray(double[][]::new);
+    Point[] pointArray = new Point[points.length];
+    for (int i = 0; i < points.length; i++) {
+      pointArray[i] = new Point(p[i][0], p[i][1]);
+    }
+    return LatLonGeometry.create(pointArray);
+  }
+
+  @Override
+  protected Component2D toCircle2D(Object circle) {
+    return LatLonGeometry.create((Circle) circle);
+  }
+
+  @Override
+  protected Circle nextCircle() {
+    final double radiusMeters =
+        random().nextDouble() * GeoUtils.EARTH_MEAN_RADIUS_METERS * Math.PI / 2.0 + 1.0;
+    return new Circle(nextLatitude(), nextLongitude(), radiusMeters);
+  }
+
+  @Override
+  public Rectangle randomQueryBox() {
+    return GeoTestUtil.nextBox();
+  }
+
+  @Override
+  protected Object[] nextPoints() {
+    int numPoints = TestUtil.nextInt(random(), 1, 20);
+    double[][] points = new double[numPoints][2];
+    for (int i = 0; i < numPoints; i++) {
+      points[i][0] = nextLatitude();
+      points[i][1] = nextLongitude();
+    }
+    return points;
+  }
+
+  @Override
+  protected double rectMinX(Object rect) {
+    return ((Rectangle) rect).minLon;
+  }
+
+  @Override
+  protected double rectMaxX(Object rect) {
+    return ((Rectangle) rect).maxLon;
+  }
+
+  @Override
+  protected double rectMinY(Object rect) {
+    return ((Rectangle) rect).minLat;
+  }
+
+  /** factory method to create a new polygon query */
+  protected Query newPolygonQuery(String field, QueryRelation queryRelation, Polygon... polygons) {
+    return LatLonShape.newPolygonQuery(field, queryRelation, polygons);
+  }
+
+  @Override
+  protected double rectMaxY(Object rect) {
+    return ((Rectangle) rect).maxLat;
+  }
+
+  @Override
+  protected boolean rectCrossesDateline(Object rect) {
+    return ((Rectangle) rect).crossesDateline();
+  }
+
+  @Override
+  public Line nextLine() {
+    return GeoTestUtil.nextLine();
+  }
+
+  @Override
+  protected Polygon nextPolygon() {
+    return GeoTestUtil.nextPolygon();
+  }
+
+  @Override
+  protected Encoder getEncoder() {
+    return new Encoder() {
+      @Override
+      double decodeX(int encoded) {
+        return decodeLongitude(encoded);
+      }
+
+      @Override
+      double decodeY(int encoded) {
+        return decodeLatitude(encoded);
+      }
+
+      @Override
+      double quantizeX(double raw) {
+        return decodeLongitude(encodeLongitude(raw));
+      }
+
+      @Override
+      double quantizeXCeil(double raw) {
+        return decodeLongitude(encodeLongitudeCeil(raw));
+      }
+
+      @Override
+      double quantizeY(double raw) {
+        return decodeLatitude(encodeLatitude(raw));
+      }
+
+      @Override
+      double quantizeYCeil(double raw) {
+        return decodeLatitude(encodeLatitudeCeil(raw));
+      }
+    };
+  }
+
+  /** internal shape type for testing different shape types */
+  protected enum ShapeType {
+    POINT() {
+      public Point nextShape() {
+        return GeoTestUtil.nextPoint();
+      }
+    },
+    LINE() {
+      public Line nextShape() {
+        return GeoTestUtil.nextLine();
+      }
+    },
+    POLYGON() {
+      public Polygon nextShape() {
+        while (true) {
+          Polygon p = GeoTestUtil.nextPolygon();
+          try {
+            Tessellator.tessellate(p);
+            return p;
+          } catch (IllegalArgumentException e) {
+            // if we can't tessellate; then random polygon generator created a malformed shape
+          }
+        }
+      }
+    },
+    MIXED() {
+      public Object nextShape() {
+        return RandomPicks.randomFrom(random(), subList).nextShape();
+      }
+    };
+
+    static ShapeType[] subList;
+
+    static {
+      subList = new ShapeType[] {POINT, LINE, POLYGON};
+    }
+
+    public abstract Object nextShape();
+  }
+}
diff --git a/lucene/core/src/test/org/apache/lucene/document/BaseShapeTestCase.java b/lucene/core/src/test/org/apache/lucene/document/BaseSpatialTestCase.java
similarity index 99%
rename from lucene/core/src/test/org/apache/lucene/document/BaseShapeTestCase.java
rename to lucene/core/src/test/org/apache/lucene/document/BaseSpatialTestCase.java
index 90d11d1..2d2da74 100644
--- a/lucene/core/src/test/org/apache/lucene/document/BaseShapeTestCase.java
+++ b/lucene/core/src/test/org/apache/lucene/document/BaseSpatialTestCase.java
@@ -52,9 +52,9 @@ import org.apache.lucene.util.TestUtil;
  * Base test case for testing spherical and cartesian geometry indexing and search functionality
  *
  * <p>This class is implemented by {@link BaseXYShapeTestCase} for testing XY cartesian geometry and
- * {@link BaseLatLonShapeTestCase} for testing Lat Lon geospatial geometry
+ * {@link BaseLatLonSpatialTestCase} for testing Lat Lon geospatial geometry
  */
-public abstract class BaseShapeTestCase extends LuceneTestCase {
+public abstract class BaseSpatialTestCase extends LuceneTestCase {
 
   /** name of the LatLonShape indexed field */
   protected static final String FIELD_NAME = "shape";
@@ -65,7 +65,7 @@ public abstract class BaseShapeTestCase extends LuceneTestCase {
     QueryRelation.INTERSECTS, QueryRelation.DISJOINT, QueryRelation.CONTAINS
   };
 
-  public BaseShapeTestCase() {
+  public BaseSpatialTestCase() {
     ENCODER = getEncoder();
     VALIDATOR = getValidator();
   }
diff --git a/lucene/core/src/test/org/apache/lucene/document/BaseXYShapeTestCase.java b/lucene/core/src/test/org/apache/lucene/document/BaseXYShapeTestCase.java
index 850a718..410c836 100644
--- a/lucene/core/src/test/org/apache/lucene/document/BaseXYShapeTestCase.java
+++ b/lucene/core/src/test/org/apache/lucene/document/BaseXYShapeTestCase.java
@@ -36,7 +36,7 @@ import org.apache.lucene.search.Query;
 import org.apache.lucene.util.TestUtil;
 
 /** Base test case for testing indexing and search functionality of cartesian geometry * */
-public abstract class BaseXYShapeTestCase extends BaseShapeTestCase {
+public abstract class BaseXYShapeTestCase extends BaseSpatialTestCase {
   protected abstract ShapeType getShapeType();
 
   protected Object nextShape() {
diff --git a/lucene/core/src/test/org/apache/lucene/document/TestLatLonDocValuesMultiPointPointQueries.java b/lucene/core/src/test/org/apache/lucene/document/TestLatLonDocValuesMultiPointPointQueries.java
new file mode 100644
index 0000000..e66a420
--- /dev/null
+++ b/lucene/core/src/test/org/apache/lucene/document/TestLatLonDocValuesMultiPointPointQueries.java
@@ -0,0 +1,99 @@
+/*
+ * 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.document;
+
+import org.apache.lucene.document.ShapeField.QueryRelation;
+import org.apache.lucene.geo.Component2D;
+import org.apache.lucene.geo.Point;
+
+/**
+ * random bounding box, line, and polygon query tests for random indexed arrays of {@code latitude,
+ * longitude} points
+ */
+public class TestLatLonDocValuesMultiPointPointQueries extends BaseLatLonDocValueTestCase {
+
+  @Override
+  protected ShapeType getShapeType() {
+    return ShapeType.POINT;
+  }
+
+  @Override
+  protected Object nextShape() {
+    int n = random().nextInt(4) + 1;
+    Point[] points = new Point[n];
+    for (int i = 0; i < n; i++) {
+      points[i] = (Point) ShapeType.POINT.nextShape();
+    }
+    return points;
+  }
+
+  @Override
+  protected Field[] createIndexableFields(String name, Object o) {
+    Point[] points = (Point[]) o;
+    Field[] fields = new Field[points.length];
+    for (int i = 0; i < points.length; i++) {
+      fields[i] = new LatLonDocValuesField(FIELD_NAME, points[i].getLat(), points[i].getLon());
+    }
+    return fields;
+  }
+
+  @Override
+  public Validator getValidator() {
+    return new MultiPointValidator(ENCODER);
+  }
+
+  protected class MultiPointValidator extends Validator {
+    TestLatLonPointShapeQueries.PointValidator POINTVALIDATOR;
+
+    MultiPointValidator(Encoder encoder) {
+      super(encoder);
+      POINTVALIDATOR = new TestLatLonPointShapeQueries.PointValidator(encoder);
+    }
+
+    @Override
+    public Validator setRelation(QueryRelation relation) {
+      super.setRelation(relation);
+      POINTVALIDATOR.queryRelation = relation;
+      return this;
+    }
+
+    @Override
+    public boolean testComponentQuery(Component2D query, Object shape) {
+      Point[] points = (Point[]) shape;
+      for (Point p : points) {
+        boolean b = POINTVALIDATOR.testComponentQuery(query, p);
+        if (b == true && queryRelation == QueryRelation.INTERSECTS) {
+          return true;
+        } else if (b == true && queryRelation == QueryRelation.CONTAINS) {
+          return true;
+        } else if (b == false && queryRelation == QueryRelation.DISJOINT) {
+          return false;
+        } else if (b == false && queryRelation == QueryRelation.WITHIN) {
+          return false;
+        }
+      }
+      return queryRelation != QueryRelation.INTERSECTS && queryRelation != QueryRelation.CONTAINS;
+    }
+  }
+
+  @Slow
+  @Nightly
+  @Override
+  public void testRandomBig() throws Exception {
+    doTestRandom(10000);
+  }
+}
diff --git a/lucene/core/src/test/org/apache/lucene/document/TestLatLonDocValuesPointPointQueries.java b/lucene/core/src/test/org/apache/lucene/document/TestLatLonDocValuesPointPointQueries.java
new file mode 100644
index 0000000..1e8532b
--- /dev/null
+++ b/lucene/core/src/test/org/apache/lucene/document/TestLatLonDocValuesPointPointQueries.java
@@ -0,0 +1,71 @@
+/*
+ * 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.document;
+
+import org.apache.lucene.document.ShapeField.QueryRelation;
+import org.apache.lucene.geo.Component2D;
+import org.apache.lucene.geo.Point;
+
+/**
+ * random bounding box, line, and polygon query tests for random indexed arrays of {@code latitude,
+ * longitude} points
+ */
+public class TestLatLonDocValuesPointPointQueries extends BaseLatLonDocValueTestCase {
+
+  @Override
+  protected ShapeType getShapeType() {
+    return ShapeType.POINT;
+  }
+
+  @Override
+  protected Field[] createIndexableFields(String name, Object o) {
+    Point point = (Point) o;
+    Field[] fields = new Field[1];
+    fields[0] = new LatLonDocValuesField(FIELD_NAME, point.getLat(), point.getLon());
+    return fields;
+  }
+
+  @Override
+  protected Validator getValidator() {
+    return new TestLatLonPointShapeQueries.PointValidator(this.ENCODER);
+  }
+
+  protected static class PointValidator extends Validator {
+    protected PointValidator(Encoder encoder) {
+      super(encoder);
+    }
+
+    @Override
+    public boolean testComponentQuery(Component2D query, Object shape) {
+      Point p = (Point) shape;
+      if (queryRelation == QueryRelation.CONTAINS) {
+        return testWithinQuery(
+                query, LatLonShape.createIndexableFields("dummy", p.getLat(), p.getLon()))
+            == Component2D.WithinRelation.CANDIDATE;
+      }
+      return testComponentQuery(
+          query, LatLonShape.createIndexableFields("dummy", p.getLat(), p.getLon()));
+    }
+  }
+
+  @Slow
+  @Nightly
+  @Override
+  public void testRandomBig() throws Exception {
+    doTestRandom(10000);
+  }
+}
diff --git a/lucene/core/src/test/org/apache/lucene/document/TestLatLonMultiPointPointQueries.java b/lucene/core/src/test/org/apache/lucene/document/TestLatLonMultiPointPointQueries.java
new file mode 100644
index 0000000..808fb9d
--- /dev/null
+++ b/lucene/core/src/test/org/apache/lucene/document/TestLatLonMultiPointPointQueries.java
@@ -0,0 +1,99 @@
+/*
+ * 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.document;
+
+import org.apache.lucene.document.ShapeField.QueryRelation;
+import org.apache.lucene.geo.Component2D;
+import org.apache.lucene.geo.Point;
+
+/**
+ * random bounding box, line, and polygon query tests for random indexed arrays of {@code latitude,
+ * longitude} points
+ */
+public class TestLatLonMultiPointPointQueries extends BaseLatLonPointTestCase {
+
+  @Override
+  protected ShapeType getShapeType() {
+    return ShapeType.POINT;
+  }
+
+  @Override
+  protected Object nextShape() {
+    int n = random().nextInt(4) + 1;
+    Point[] points = new Point[n];
+    for (int i = 0; i < n; i++) {
+      points[i] = (Point) ShapeType.POINT.nextShape();
+    }
+    return points;
+  }
+
+  @Override
+  protected Field[] createIndexableFields(String name, Object o) {
+    Point[] points = (Point[]) o;
+    Field[] fields = new Field[points.length];
+    for (int i = 0; i < points.length; i++) {
+      fields[i] = new LatLonPoint(FIELD_NAME, points[i].getLat(), points[i].getLon());
+    }
+    return fields;
+  }
+
+  @Override
+  public Validator getValidator() {
+    return new MultiPointValidator(ENCODER);
+  }
+
+  protected class MultiPointValidator extends Validator {
+    TestLatLonPointShapeQueries.PointValidator POINTVALIDATOR;
+
+    MultiPointValidator(Encoder encoder) {
+      super(encoder);
+      POINTVALIDATOR = new TestLatLonPointShapeQueries.PointValidator(encoder);
+    }
+
+    @Override
+    public Validator setRelation(QueryRelation relation) {
+      super.setRelation(relation);
+      POINTVALIDATOR.queryRelation = relation;
+      return this;
+    }
+
+    @Override
+    public boolean testComponentQuery(Component2D query, Object shape) {
+      Point[] points = (Point[]) shape;
+      for (Point p : points) {
+        boolean b = POINTVALIDATOR.testComponentQuery(query, p);
+        if (b == true && queryRelation == QueryRelation.INTERSECTS) {
+          return true;
+        } else if (b == true && queryRelation == QueryRelation.CONTAINS) {
+          return true;
+        } else if (b == false && queryRelation == QueryRelation.DISJOINT) {
+          return false;
+        } else if (b == false && queryRelation == QueryRelation.WITHIN) {
+          return false;
+        }
+      }
+      return queryRelation != QueryRelation.INTERSECTS && queryRelation != QueryRelation.CONTAINS;
+    }
+  }
+
+  @Slow
+  @Nightly
+  @Override
+  public void testRandomBig() throws Exception {
+    doTestRandom(10000);
+  }
+}
diff --git a/lucene/core/src/test/org/apache/lucene/document/TestLatLonPointPointQueries.java b/lucene/core/src/test/org/apache/lucene/document/TestLatLonPointPointQueries.java
new file mode 100644
index 0000000..752f9a0
--- /dev/null
+++ b/lucene/core/src/test/org/apache/lucene/document/TestLatLonPointPointQueries.java
@@ -0,0 +1,69 @@
+/*
+ * 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.document;
+
+import org.apache.lucene.document.ShapeField.QueryRelation;
+import org.apache.lucene.geo.Component2D;
+import org.apache.lucene.geo.Point;
+
+/**
+ * random bounding box, line, and polygon query tests for random indexed arrays of {@code latitude,
+ * longitude} points
+ */
+public class TestLatLonPointPointQueries extends BaseLatLonPointTestCase {
+
+  @Override
+  protected ShapeType getShapeType() {
+    return ShapeType.POINT;
+  }
+
+  @Override
+  protected Validator getValidator() {
+    return new TestLatLonPointShapeQueries.PointValidator(this.ENCODER);
+  }
+
+  @Override
+  protected Field[] createIndexableFields(String name, Object o) {
+    Point point = (Point) o;
+    return new Field[] {new LatLonPoint(FIELD_NAME, point.getLat(), point.getLon())};
+  }
+
+  protected static class PointValidator extends Validator {
+    protected PointValidator(Encoder encoder) {
+      super(encoder);
+    }
+
+    @Override
+    public boolean testComponentQuery(Component2D query, Object shape) {
+      Point p = (Point) shape;
+      if (queryRelation == QueryRelation.CONTAINS) {
+        return testWithinQuery(
+                query, LatLonShape.createIndexableFields("dummy", p.getLat(), p.getLon()))
+            == Component2D.WithinRelation.CANDIDATE;
+      }
+      return testComponentQuery(
+          query, LatLonShape.createIndexableFields("dummy", p.getLat(), p.getLon()));
+    }
+  }
+
+  @Slow
+  @Nightly
+  @Override
+  public void testRandomBig() throws Exception {
+    doTestRandom(10000);
+  }
+}
diff --git a/lucene/core/src/test/org/apache/lucene/document/TestLatLonShape.java b/lucene/core/src/test/org/apache/lucene/document/TestLatLonShape.java
index 70ddb7c..a916602 100644
--- a/lucene/core/src/test/org/apache/lucene/document/TestLatLonShape.java
+++ b/lucene/core/src/test/org/apache/lucene/document/TestLatLonShape.java
@@ -456,6 +456,15 @@ public class TestLatLonShape extends LuceneTestCase {
     RandomIndexWriter writer = new RandomIndexWriter(random(), dir);
     Document document = new Document();
     Point p = GeoTestUtil.nextPoint();
+    double qLat =
+        p.getLat() == GeoUtils.MAX_LAT_INCL
+            ? p.getLat()
+            : GeoEncodingUtils.decodeLatitude(GeoEncodingUtils.encodeLatitudeCeil(p.getLat()));
+    double qLon =
+        p.getLon() == GeoUtils.MAX_LON_INCL
+            ? p.getLon()
+            : GeoEncodingUtils.decodeLongitude(GeoEncodingUtils.encodeLongitudeCeil(p.getLon()));
+    p = new Point(qLat, qLon);
     Field[] fields = LatLonShape.createIndexableFields(FIELDNAME, p.getLat(), p.getLon());
     for (Field f : fields) {
       document.add(f);
diff --git a/lucene/core/src/test/org/apache/lucene/search/TestLatLonDocValuesQueries.java b/lucene/core/src/test/org/apache/lucene/search/TestLatLonDocValuesQueries.java
index f52c769..2d3e6b2 100644
--- a/lucene/core/src/test/org/apache/lucene/search/TestLatLonDocValuesQueries.java
+++ b/lucene/core/src/test/org/apache/lucene/search/TestLatLonDocValuesQueries.java
@@ -18,6 +18,7 @@ package org.apache.lucene.search;
 
 import org.apache.lucene.document.Document;
 import org.apache.lucene.document.LatLonDocValuesField;
+import org.apache.lucene.document.ShapeField;
 import org.apache.lucene.geo.BaseGeoPointTestCase;
 import org.apache.lucene.geo.GeoEncodingUtils;
 import org.apache.lucene.geo.LatLonGeometry;
@@ -49,7 +50,8 @@ public class TestLatLonDocValuesQueries extends BaseGeoPointTestCase {
 
   @Override
   protected Query newGeometryQuery(String field, LatLonGeometry... geometry) {
-    return LatLonDocValuesField.newSlowGeometryQuery(field, geometry);
+    return LatLonDocValuesField.newSlowGeometryQuery(
+        field, ShapeField.QueryRelation.INTERSECTS, geometry);
   }
 
   @Override
diff --git a/lucene/core/src/test/org/apache/lucene/search/TestLatLonPointQueries.java b/lucene/core/src/test/org/apache/lucene/search/TestLatLonPointQueries.java
index 9abbe64..c2cea11 100644
--- a/lucene/core/src/test/org/apache/lucene/search/TestLatLonPointQueries.java
+++ b/lucene/core/src/test/org/apache/lucene/search/TestLatLonPointQueries.java
@@ -19,6 +19,7 @@ package org.apache.lucene.search;
 import java.io.IOException;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.document.LatLonPoint;
+import org.apache.lucene.document.ShapeField;
 import org.apache.lucene.geo.BaseGeoPointTestCase;
 import org.apache.lucene.geo.GeoEncodingUtils;
 import org.apache.lucene.geo.LatLonGeometry;
@@ -55,7 +56,7 @@ public class TestLatLonPointQueries extends BaseGeoPointTestCase {
 
   @Override
   protected Query newGeometryQuery(String field, LatLonGeometry... geometry) {
-    return LatLonPoint.newGeometryQuery(field, geometry);
+    return LatLonPoint.newGeometryQuery(field, ShapeField.QueryRelation.INTERSECTS, geometry);
   }
 
   @Override