Browse Source

Geo: Adds a set of no dependency geo classes for JDBC driver (#36477)

Adds a set of geo classes to represent geo data in the JDBC driver and 
to be used as an intermediate format to pass geo shapes for indexing 
and query generation in #35320.

Relates to #35767 and #35320
Igor Motov 6 years ago
parent
commit
6f91f06d86
29 changed files with 2435 additions and 1 deletions
  1. 49 0
      libs/geo/build.gradle
  2. 104 0
      libs/geo/src/main/java/org/elasticsearch/geo/geometry/Circle.java
  3. 32 0
      libs/geo/src/main/java/org/elasticsearch/geo/geometry/Geometry.java
  4. 85 0
      libs/geo/src/main/java/org/elasticsearch/geo/geometry/GeometryCollection.java
  5. 70 0
      libs/geo/src/main/java/org/elasticsearch/geo/geometry/GeometryUtils.java
  6. 69 0
      libs/geo/src/main/java/org/elasticsearch/geo/geometry/GeometryVisitor.java
  7. 106 0
      libs/geo/src/main/java/org/elasticsearch/geo/geometry/Line.java
  8. 53 0
      libs/geo/src/main/java/org/elasticsearch/geo/geometry/LinearRing.java
  9. 46 0
      libs/geo/src/main/java/org/elasticsearch/geo/geometry/MultiLine.java
  10. 47 0
      libs/geo/src/main/java/org/elasticsearch/geo/geometry/MultiPoint.java
  11. 46 0
      libs/geo/src/main/java/org/elasticsearch/geo/geometry/MultiPolygon.java
  12. 95 0
      libs/geo/src/main/java/org/elasticsearch/geo/geometry/Point.java
  13. 122 0
      libs/geo/src/main/java/org/elasticsearch/geo/geometry/Polygon.java
  14. 168 0
      libs/geo/src/main/java/org/elasticsearch/geo/geometry/Rectangle.java
  15. 36 0
      libs/geo/src/main/java/org/elasticsearch/geo/geometry/ShapeType.java
  16. 24 0
      libs/geo/src/main/java/org/elasticsearch/geo/package-info.java
  17. 560 0
      libs/geo/src/main/java/org/elasticsearch/geo/utils/WellKnownText.java
  18. 203 0
      libs/geo/src/test/java/org/elasticsearch/geo/geometry/BaseGeometryTestCase.java
  19. 51 0
      libs/geo/src/test/java/org/elasticsearch/geo/geometry/CircleTests.java
  20. 54 0
      libs/geo/src/test/java/org/elasticsearch/geo/geometry/GeometryCollectionTests.java
  21. 51 0
      libs/geo/src/test/java/org/elasticsearch/geo/geometry/LineTests.java
  22. 52 0
      libs/geo/src/test/java/org/elasticsearch/geo/geometry/LinearRingTests.java
  23. 51 0
      libs/geo/src/test/java/org/elasticsearch/geo/geometry/MultiLineTests.java
  24. 51 0
      libs/geo/src/test/java/org/elasticsearch/geo/geometry/MultiPointTests.java
  25. 53 0
      libs/geo/src/test/java/org/elasticsearch/geo/geometry/MultiPolygonTests.java
  26. 48 0
      libs/geo/src/test/java/org/elasticsearch/geo/geometry/PointTests.java
  27. 52 0
      libs/geo/src/test/java/org/elasticsearch/geo/geometry/PolygonTests.java
  28. 51 0
      libs/geo/src/test/java/org/elasticsearch/geo/geometry/RectangleTests.java
  29. 6 1
      settings.gradle

+ 49 - 0
libs/geo/build.gradle

@@ -0,0 +1,49 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.
+ */
+
+apply plugin: 'elasticsearch.build'
+apply plugin: 'nebula.maven-base-publish'
+apply plugin: 'nebula.maven-scm'
+
+dependencies {
+    if (isEclipse == false || project.path == ":libs:geo-tests") {
+        testCompile("org.elasticsearch.test:framework:${version}") {
+            exclude group: 'org.elasticsearch', module: 'elasticsearch-geo'
+        }
+    }
+}
+
+forbiddenApisMain {
+    // geo does not depend on server
+    // TODO: Need to decide how we want to handle for forbidden signatures with the changes to core
+    replaceSignatureFiles 'jdk-signatures'
+}
+
+if (isEclipse) {
+    // in eclipse the project is under a fake root, we need to change around the source sets
+    sourceSets {
+        if (project.path == ":libs:geo") {
+            main.java.srcDirs = ['java']
+            main.resources.srcDirs = ['resources']
+        } else {
+            test.java.srcDirs = ['java']
+            test.resources.srcDirs = ['resources']
+        }
+    }
+}

+ 104 - 0
libs/geo/src/main/java/org/elasticsearch/geo/geometry/Circle.java

@@ -0,0 +1,104 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+/**
+ * Circle geometry (not part of WKT standard, but used in elasticsearch)
+ */
+public class Circle implements Geometry {
+    public static final Circle EMPTY = new Circle();
+    private final double lat;
+    private final double lon;
+    private final double radiusMeters;
+
+    private Circle() {
+        lat = 0;
+        lon = 0;
+        radiusMeters = -1;
+    }
+
+    public Circle(final double lat, final double lon, final double radiusMeters) {
+        this.lat = lat;
+        this.lon = lon;
+        this.radiusMeters = radiusMeters;
+        if (radiusMeters < 0 ) {
+            throw new IllegalArgumentException("Circle radius [" + radiusMeters + "] cannot be negative");
+        }
+        GeometryUtils.checkLatitude(lat);
+        GeometryUtils.checkLongitude(lon);
+    }
+
+    @Override
+    public ShapeType type() {
+        return ShapeType.CIRCLE;
+    }
+
+    public double getLat() {
+        return lat;
+    }
+
+    public double getLon() {
+        return lon;
+    }
+
+    public double getRadiusMeters() {
+        return radiusMeters;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        Circle circle = (Circle) o;
+        if (Double.compare(circle.lat, lat) != 0) return false;
+        if (Double.compare(circle.lon, lon) != 0) return false;
+        return (Double.compare(circle.radiusMeters, radiusMeters) == 0);
+    }
+
+    @Override
+    public int hashCode() {
+        int result;
+        long temp;
+        temp = Double.doubleToLongBits(lat);
+        result = (int) (temp ^ (temp >>> 32));
+        temp = Double.doubleToLongBits(lon);
+        result = 31 * result + (int) (temp ^ (temp >>> 32));
+        temp = Double.doubleToLongBits(radiusMeters);
+        result = 31 * result + (int) (temp ^ (temp >>> 32));
+        return result;
+    }
+
+    @Override
+    public <T> T visit(GeometryVisitor<T> visitor) {
+        return visitor.visit(this);
+    }
+
+    @Override
+    public boolean isEmpty() {
+        return radiusMeters < 0;
+    }
+
+    @Override
+    public String toString() {
+        return "lat=" + lat + ", lon=" + lon + ", radius=" + radiusMeters;
+    }
+
+}

+ 32 - 0
libs/geo/src/main/java/org/elasticsearch/geo/geometry/Geometry.java

@@ -0,0 +1,32 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+/**
+ * Base class for all Geometry objects supported by elasticsearch
+ */
+public interface Geometry {
+
+    ShapeType type();
+
+    <T> T visit(GeometryVisitor<T> visitor);
+
+    boolean isEmpty();
+}

+ 85 - 0
libs/geo/src/main/java/org/elasticsearch/geo/geometry/GeometryCollection.java

@@ -0,0 +1,85 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Collection of arbitrary geometry classes
+ */
+public class GeometryCollection<G extends Geometry> implements Geometry, Iterable<G> {
+    public static final GeometryCollection<Geometry> EMPTY = new GeometryCollection<>();
+
+    private final List<G> shapes;
+
+    public GeometryCollection() {
+        shapes = Collections.emptyList();
+    }
+
+    public GeometryCollection(List<G> shapes) {
+        if (shapes == null || shapes.isEmpty()) {
+            throw new IllegalArgumentException("the list of shapes cannot be null or empty");
+        }
+        this.shapes = shapes;
+    }
+
+    @Override
+    public ShapeType type() {
+        return ShapeType.GEOMETRYCOLLECTION;
+    }
+
+    @Override
+    public <T> T visit(GeometryVisitor<T> visitor) {
+        return visitor.visit(this);
+    }
+
+    @Override
+    public boolean isEmpty() {
+        return shapes.isEmpty();
+    }
+
+    public int size() {
+        return shapes.size();
+    }
+
+    public G get(int i) {
+        return shapes.get(i);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        GeometryCollection<?> that = (GeometryCollection<?>) o;
+        return Objects.equals(shapes, that.shapes);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(shapes);
+    }
+
+    @Override
+    public Iterator<G> iterator() {
+        return shapes.iterator();
+    }
+}

+ 70 - 0
libs/geo/src/main/java/org/elasticsearch/geo/geometry/GeometryUtils.java

@@ -0,0 +1,70 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+/**
+ * Geometry-related utility methods
+ */
+final class GeometryUtils {
+    /**
+     * Minimum longitude value.
+     */
+    static final double MIN_LON_INCL = -180.0D;
+
+    /**
+     * Maximum longitude value.
+     */
+    static final double MAX_LON_INCL = 180.0D;
+
+    /**
+     * Minimum latitude value.
+     */
+    static final double MIN_LAT_INCL = -90.0D;
+
+    /**
+     * Maximum latitude value.
+     */
+    static final double MAX_LAT_INCL = 90.0D;
+
+    // No instance:
+    private GeometryUtils() {
+    }
+
+    /**
+     * validates latitude value is within standard +/-90 coordinate bounds
+     */
+    static void checkLatitude(double latitude) {
+        if (Double.isNaN(latitude) || latitude < MIN_LAT_INCL || latitude > MAX_LAT_INCL) {
+            throw new IllegalArgumentException(
+                "invalid latitude " + latitude + "; must be between " + MIN_LAT_INCL + " and " + MAX_LAT_INCL);
+        }
+    }
+
+    /**
+     * validates longitude value is within standard +/-180 coordinate bounds
+     */
+    static void checkLongitude(double longitude) {
+        if (Double.isNaN(longitude) || longitude < MIN_LON_INCL || longitude > MAX_LON_INCL) {
+            throw new IllegalArgumentException(
+                "invalid longitude " + longitude + "; must be between " + MIN_LON_INCL + " and " + MAX_LON_INCL);
+        }
+    }
+
+}

+ 69 - 0
libs/geo/src/main/java/org/elasticsearch/geo/geometry/GeometryVisitor.java

@@ -0,0 +1,69 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+/**
+ * Support class for creating Geometry Visitors.
+ * <p>
+ * This is an implementation of the Visitor pattern. The basic idea is to simplify adding new operations on Geometries, without
+ * constantly modifying and adding new functionality to the Geometry hierarchy and keeping it as lightweight as possible.
+ * <p>
+ * It is a more object-oriented alternative to structures like this:
+ * <pre>
+ * if (obj instanceof This) {
+ *   doThis((This) obj);
+ * } elseif (obj instanceof That) {
+ *   doThat((That) obj);
+ * ...
+ * } else {
+ *   throw new IllegalArgumentException("Unknown object " + obj);
+ * }
+ * </pre>
+ * <p>
+ * The Visitor Pattern replaces this structure with Interface inheritance making it easier to identify all places that are using this
+ * structure, and making a shape a compile-time failure instead of runtime.
+ * <p>
+ * See {@link org.elasticsearch.geo.utils.WellKnownText#toWKT(Geometry, StringBuilder)} for an example of how this interface is used.
+ *
+ * @see <a href="https://en.wikipedia.org/wiki/Visitor_pattern">Visitor Pattern</a>
+ */
+public interface GeometryVisitor<T> {
+
+    T visit(Circle circle);
+
+    T visit(GeometryCollection<?> collection);
+
+    T visit(Line line);
+
+    T visit(LinearRing ring);
+
+    T visit(MultiLine multiLine);
+
+    T visit(MultiPoint multiPoint);
+
+    T visit(MultiPolygon multiPolygon);
+
+    T visit(Point point);
+
+    T visit(Polygon polygon);
+
+    T visit(Rectangle rectangle);
+
+}

+ 106 - 0
libs/geo/src/main/java/org/elasticsearch/geo/geometry/Line.java

@@ -0,0 +1,106 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+import java.util.Arrays;
+
+/**
+ * Represents a Line on the earth's surface in lat/lon decimal degrees.
+ */
+public class Line implements Geometry {
+    public static final Line EMPTY = new Line();
+    private final double[] lats;
+    private final double[] lons;
+
+    protected Line() {
+        lats = new double[0];
+        lons = new double[0];
+    }
+
+    public Line(double[] lats, double[] lons) {
+        this.lats = lats;
+        this.lons = lons;
+        if (lats == null) {
+            throw new IllegalArgumentException("lats must not be null");
+        }
+        if (lons == null) {
+            throw new IllegalArgumentException("lons must not be null");
+        }
+        if (lats.length != lons.length) {
+            throw new IllegalArgumentException("lats and lons must be equal length");
+        }
+        if (lats.length < 2) {
+            throw new IllegalArgumentException("at least two points in the line is required");
+        }
+        for (int i = 0; i < lats.length; i++) {
+            GeometryUtils.checkLatitude(lats[i]);
+            GeometryUtils.checkLongitude(lons[i]);
+        }
+    }
+
+    public int length() {
+        return lats.length;
+    }
+
+    public double getLat(int i) {
+        return lats[i];
+    }
+
+    public double getLon(int i) {
+        return lons[i];
+    }
+
+    @Override
+    public ShapeType type() {
+        return ShapeType.LINESTRING;
+    }
+
+    @Override
+    public <T> T visit(GeometryVisitor<T> visitor) {
+        return visitor.visit(this);
+    }
+
+    @Override
+    public boolean isEmpty() {
+        return lats.length == 0;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        Line line = (Line) o;
+        return Arrays.equals(lats, line.lats) &&
+            Arrays.equals(lons, line.lons);
+    }
+
+    @Override
+    public int hashCode() {
+        int result = Arrays.hashCode(lats);
+        result = 31 * result + Arrays.hashCode(lons);
+        return result;
+    }
+
+    @Override
+    public String toString() {
+        return "lats=" + Arrays.toString(lats) +
+            ", lons=" + Arrays.toString(lons);
+    }
+}

+ 53 - 0
libs/geo/src/main/java/org/elasticsearch/geo/geometry/LinearRing.java

@@ -0,0 +1,53 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+/**
+ * Represents a closed line on the earth's surface in lat/lon decimal degrees.
+ * <p>
+ * Cannot be serialized by WKT directly but used as a part of polygon
+ */
+public class LinearRing extends Line {
+    public static final LinearRing EMPTY = new LinearRing();
+
+    private LinearRing() {
+    }
+
+    public LinearRing(double[] lats, double[] lons) {
+        super(lats, lons);
+        if (lats.length < 2) {
+            throw new IllegalArgumentException("linear ring cannot contain less than 2 points, found " + lats.length);
+        }
+        if (lats[0] != lats[lats.length - 1] || lons[0] != lons[lons.length - 1]) {
+            throw new IllegalArgumentException("first and last points of the linear ring must be the same (it must close itself): lats[0]="
+                + lats[0] + " lats[" + (lats.length - 1) + "]=" + lats[lats.length - 1]);
+        }
+    }
+
+    @Override
+    public ShapeType type() {
+        return ShapeType.LINEARRING;
+    }
+
+    @Override
+    public <T> T visit(GeometryVisitor<T> visitor) {
+        return visitor.visit(this);
+    }
+}

+ 46 - 0
libs/geo/src/main/java/org/elasticsearch/geo/geometry/MultiLine.java

@@ -0,0 +1,46 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+import java.util.List;
+
+/**
+ * Represents a MultiLine geometry object on the earth's surface.
+ */
+public class MultiLine extends GeometryCollection<Line> {
+    public static final MultiLine EMPTY = new MultiLine();
+
+    private MultiLine() {
+    }
+
+    public MultiLine(List<Line> lines) {
+        super(lines);
+    }
+
+    @Override
+    public ShapeType type() {
+        return ShapeType.MULTILINESTRING;
+    }
+
+    @Override
+    public <T> T visit(GeometryVisitor<T> visitor) {
+        return visitor.visit(this);
+    }
+}

+ 47 - 0
libs/geo/src/main/java/org/elasticsearch/geo/geometry/MultiPoint.java

@@ -0,0 +1,47 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+import java.util.List;
+
+/**
+ * Represents a MultiPoint object on the earth's surface in decimal degrees.
+ */
+public class MultiPoint extends GeometryCollection<Point> {
+    public static final MultiPoint EMPTY = new MultiPoint();
+
+    private MultiPoint() {
+    }
+
+    public MultiPoint(List<Point> points) {
+        super(points);
+    }
+
+    @Override
+    public ShapeType type() {
+        return ShapeType.MULTIPOINT;
+    }
+
+    @Override
+    public <T> T visit(GeometryVisitor<T> visitor) {
+        return visitor.visit(this);
+    }
+
+}

+ 46 - 0
libs/geo/src/main/java/org/elasticsearch/geo/geometry/MultiPolygon.java

@@ -0,0 +1,46 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+import java.util.List;
+
+/**
+ * Collection of polygons
+ */
+public class MultiPolygon extends GeometryCollection<Polygon> {
+    public static final MultiPolygon EMPTY = new MultiPolygon();
+
+    private MultiPolygon() {
+    }
+
+    public MultiPolygon(List<Polygon> polygons) {
+        super(polygons);
+    }
+
+    @Override
+    public ShapeType type() {
+        return ShapeType.MULTIPOLYGON;
+    }
+
+    @Override
+    public <T> T visit(GeometryVisitor<T> visitor) {
+        return visitor.visit(this);
+    }
+}

+ 95 - 0
libs/geo/src/main/java/org/elasticsearch/geo/geometry/Point.java

@@ -0,0 +1,95 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+/**
+ * Represents a Point on the earth's surface in decimal degrees.
+ */
+public class Point implements Geometry {
+    public static final Point EMPTY = new Point();
+
+    private final double lat;
+    private final double lon;
+    private final boolean empty;
+
+    private Point() {
+        lat = 0;
+        lon = 0;
+        empty = true;
+    }
+
+    public Point(double lat, double lon) {
+        GeometryUtils.checkLatitude(lat);
+        GeometryUtils.checkLongitude(lon);
+        this.lat = lat;
+        this.lon = lon;
+        this.empty = false;
+    }
+
+    @Override
+    public ShapeType type() {
+        return ShapeType.POINT;
+    }
+
+    public double lat() {
+        return lat;
+    }
+
+    public double lon() {
+        return lon;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        Point point = (Point) o;
+        if (point.empty != empty) return false;
+        if (Double.compare(point.lat, lat) != 0) return false;
+        return Double.compare(point.lon, lon) == 0;
+    }
+
+    @Override
+    public int hashCode() {
+        int result;
+        long temp;
+        temp = Double.doubleToLongBits(lat);
+        result = (int) (temp ^ (temp >>> 32));
+        temp = Double.doubleToLongBits(lon);
+        result = 31 * result + (int) (temp ^ (temp >>> 32));
+        return result;
+    }
+
+    @Override
+    public <T> T visit(GeometryVisitor<T> visitor) {
+        return visitor.visit(this);
+    }
+
+    @Override
+    public boolean isEmpty() {
+        return empty;
+    }
+
+    @Override
+    public String toString() {
+        return "lat=" + lat + ", lon=" + lon;
+    }
+}

+ 122 - 0
libs/geo/src/main/java/org/elasticsearch/geo/geometry/Polygon.java

@@ -0,0 +1,122 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Represents a closed polygon on the earth's surface with optional holes
+ */
+public final class Polygon implements Geometry {
+    public static final Polygon EMPTY = new Polygon();
+    private final LinearRing polygon;
+    private final List<LinearRing> holes;
+
+    private Polygon() {
+        polygon = LinearRing.EMPTY;
+        holes = Collections.emptyList();
+    }
+
+    /**
+     * Creates a new Polygon from the supplied latitude/longitude array, and optionally any holes.
+     */
+    public Polygon(LinearRing polygon, List<LinearRing> holes) {
+        this.polygon = polygon;
+        this.holes = holes;
+        if (holes == null) {
+            throw new IllegalArgumentException("holes must not be null");
+        }
+        checkRing(polygon);
+        for (LinearRing hole : holes) {
+            checkRing(hole);
+        }
+    }
+
+    /**
+     * Creates a new Polygon from the supplied latitude/longitude array, and optionally any holes.
+     */
+    public Polygon(LinearRing polygon) {
+        this(polygon, Collections.emptyList());
+    }
+
+    @Override
+    public ShapeType type() {
+        return ShapeType.POLYGON;
+    }
+
+    private void checkRing(LinearRing ring) {
+        if (ring.length() < 4) {
+            throw new IllegalArgumentException("at least 4 polygon points required");
+        }
+    }
+
+    public int getNumberOfHoles() {
+        return holes.size();
+    }
+
+    public LinearRing getPolygon() {
+        return polygon;
+    }
+
+    public LinearRing getHole(int i) {
+        if (i >= holes.size()) {
+            throw new IllegalArgumentException("Index " + i + " is outside the bounds of the " + holes.size() + " polygon holes");
+        }
+        return holes.get(i);
+    }
+
+    @Override
+    public <T> T visit(GeometryVisitor<T> visitor) {
+        return visitor.visit(this);
+    }
+
+    @Override
+    public boolean isEmpty() {
+        return polygon.isEmpty();
+    }
+
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append("polygon=").append(polygon);
+        if (holes.size() > 0) {
+            sb.append(", holes=");
+            sb.append(holes);
+        }
+        return sb.toString();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        Polygon polygon1 = (Polygon) o;
+        return Objects.equals(polygon, polygon1.polygon) &&
+            Objects.equals(holes, polygon1.holes);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(polygon, holes);
+    }
+}

+ 168 - 0
libs/geo/src/main/java/org/elasticsearch/geo/geometry/Rectangle.java

@@ -0,0 +1,168 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+/**
+ * Represents a lat/lon rectangle in decimal degrees.
+ */
+public class Rectangle implements Geometry {
+    public static final Rectangle EMPTY = new Rectangle();
+    /**
+     * maximum longitude value (in degrees)
+     */
+    private final double minLat;
+    /**
+     * minimum longitude value (in degrees)
+     */
+    private final double minLon;
+    /**
+     * maximum latitude value (in degrees)
+     */
+    private final double maxLat;
+    /**
+     * minimum latitude value (in degrees)
+     */
+    private final double maxLon;
+
+    private final boolean empty;
+
+    private Rectangle() {
+        minLat = 0;
+        minLon = 0;
+        maxLat = 0;
+        maxLon = 0;
+        empty = true;
+    }
+
+    /**
+     * Constructs a bounding box by first validating the provided latitude and longitude coordinates
+     */
+    public Rectangle(double minLat, double maxLat, double minLon, double maxLon) {
+        GeometryUtils.checkLatitude(minLat);
+        GeometryUtils.checkLatitude(maxLat);
+        GeometryUtils.checkLongitude(minLon);
+        GeometryUtils.checkLongitude(maxLon);
+        this.minLon = minLon;
+        this.maxLon = maxLon;
+        this.minLat = minLat;
+        this.maxLat = maxLat;
+        empty = false;
+        if (maxLat < minLat) {
+            throw new IllegalArgumentException("max lat cannot be less than min lat");
+        }
+    }
+
+    public double getWidth() {
+        if (crossesDateline()) {
+            return GeometryUtils.MAX_LON_INCL - minLon + maxLon - GeometryUtils.MIN_LON_INCL;
+        }
+        return maxLon - minLon;
+    }
+
+    public double getHeight() {
+        return maxLat - minLat;
+    }
+
+    public double getMinLat() {
+        return minLat;
+    }
+
+    public double getMinLon() {
+        return minLon;
+    }
+
+    public double getMaxLat() {
+        return maxLat;
+    }
+
+    public double getMaxLon() {
+        return maxLon;
+    }
+
+    @Override
+    public ShapeType type() {
+        return ShapeType.ENVELOPE;
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder b = new StringBuilder();
+        b.append("Rectangle(lat=");
+        b.append(minLat);
+        b.append(" TO ");
+        b.append(maxLat);
+        b.append(" lon=");
+        b.append(minLon);
+        b.append(" TO ");
+        b.append(maxLon);
+        if (maxLon < minLon) {
+            b.append(" [crosses dateline!]");
+        }
+        b.append(")");
+
+        return b.toString();
+    }
+
+    /**
+     * Returns true if this bounding box crosses the dateline
+     */
+    public boolean crossesDateline() {
+        return maxLon < minLon;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        Rectangle rectangle = (Rectangle) o;
+
+        if (Double.compare(rectangle.minLat, minLat) != 0) return false;
+        if (Double.compare(rectangle.minLon, minLon) != 0) return false;
+        if (Double.compare(rectangle.maxLat, maxLat) != 0) return false;
+        return Double.compare(rectangle.maxLon, maxLon) == 0;
+
+    }
+
+    @Override
+    public int hashCode() {
+        int result;
+        long temp;
+        temp = Double.doubleToLongBits(minLat);
+        result = (int) (temp ^ (temp >>> 32));
+        temp = Double.doubleToLongBits(minLon);
+        result = 31 * result + (int) (temp ^ (temp >>> 32));
+        temp = Double.doubleToLongBits(maxLat);
+        result = 31 * result + (int) (temp ^ (temp >>> 32));
+        temp = Double.doubleToLongBits(maxLon);
+        result = 31 * result + (int) (temp ^ (temp >>> 32));
+        return result;
+    }
+
+    @Override
+    public <T> T visit(GeometryVisitor<T> visitor) {
+        return visitor.visit(this);
+    }
+
+    @Override
+    public boolean isEmpty() {
+        return empty;
+    }
+}

+ 36 - 0
libs/geo/src/main/java/org/elasticsearch/geo/geometry/ShapeType.java

@@ -0,0 +1,36 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+/**
+ * Shape types supported by elasticsearch
+ */
+public enum ShapeType {
+    POINT,
+    MULTIPOINT,
+    LINESTRING,
+    MULTILINESTRING,
+    POLYGON,
+    MULTIPOLYGON,
+    GEOMETRYCOLLECTION,
+    LINEARRING, // not serialized by itself in WKT or WKB
+    ENVELOPE, // not part of the actual WKB spec
+    CIRCLE; // not part of the actual WKB spec
+}

+ 24 - 0
libs/geo/src/main/java/org/elasticsearch/geo/package-info.java

@@ -0,0 +1,24 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.
+ */
+
+
+/**
+ * Common Geo classes
+ */
+package org.elasticsearch.geo;

+ 560 - 0
libs/geo/src/main/java/org/elasticsearch/geo/utils/WellKnownText.java

@@ -0,0 +1,560 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.utils;
+
+import org.elasticsearch.geo.geometry.Circle;
+import org.elasticsearch.geo.geometry.Geometry;
+import org.elasticsearch.geo.geometry.GeometryCollection;
+import org.elasticsearch.geo.geometry.GeometryVisitor;
+import org.elasticsearch.geo.geometry.Line;
+import org.elasticsearch.geo.geometry.LinearRing;
+import org.elasticsearch.geo.geometry.MultiLine;
+import org.elasticsearch.geo.geometry.MultiPoint;
+import org.elasticsearch.geo.geometry.MultiPolygon;
+import org.elasticsearch.geo.geometry.Point;
+import org.elasticsearch.geo.geometry.Polygon;
+import org.elasticsearch.geo.geometry.Rectangle;
+
+import java.io.IOException;
+import java.io.StreamTokenizer;
+import java.io.StringReader;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Utility class for converting to and from WKT
+ */
+public class WellKnownText {
+    public static final String EMPTY = "EMPTY";
+    public static final String SPACE = " ";
+    public static final String LPAREN = "(";
+    public static final String RPAREN = ")";
+    public static final String COMMA = ",";
+    public static final String NAN = "NaN";
+
+    private static final String NUMBER = "<NUMBER>";
+    private static final String EOF = "END-OF-STREAM";
+    private static final String EOL = "END-OF-LINE";
+
+    public static String toWKT(Geometry geometry) {
+        StringBuilder builder = new StringBuilder();
+        toWKT(geometry, builder);
+        return builder.toString();
+    }
+
+    public static void toWKT(Geometry geometry, StringBuilder sb) {
+        sb.append(getWKTName(geometry));
+        sb.append(SPACE);
+        if (geometry.isEmpty()) {
+            sb.append(EMPTY);
+        } else {
+            geometry.visit(new GeometryVisitor<Void>() {
+                @Override
+                public Void visit(Circle circle) {
+                    sb.append(LPAREN);
+                    visitPoint(circle.getLon(), circle.getLat());
+                    sb.append(SPACE);
+                    sb.append(circle.getRadiusMeters());
+                    sb.append(RPAREN);
+                    return null;
+                }
+
+                @Override
+                public Void visit(GeometryCollection<?> collection) {
+                    if (collection.size() == 0) {
+                        sb.append(EMPTY);
+                    } else {
+                        sb.append(LPAREN);
+                        toWKT(collection.get(0), sb);
+                        for (int i = 1; i < collection.size(); ++i) {
+                            sb.append(COMMA);
+                            toWKT(collection.get(i), sb);
+                        }
+                        sb.append(RPAREN);
+                    }
+                    return null;
+                }
+
+                @Override
+                public Void visit(Line line) {
+                    sb.append(LPAREN);
+                    visitPoint(line.getLon(0), line.getLat(0));
+                    for (int i = 1; i < line.length(); ++i) {
+                        sb.append(COMMA);
+                        sb.append(SPACE);
+                        visitPoint(line.getLon(i), line.getLat(i));
+                    }
+                    sb.append(RPAREN);
+                    return null;
+                }
+
+                @Override
+                public Void visit(LinearRing ring) {
+                    throw new IllegalArgumentException("Linear ring is not supported by WKT");
+                }
+
+                @Override
+                public Void visit(MultiLine multiLine) {
+                    visitCollection(multiLine);
+                    return null;
+                }
+
+                @Override
+                public Void visit(MultiPoint multiPoint) {
+                    // walk through coordinates:
+                    sb.append(LPAREN);
+                    visitPoint(multiPoint.get(0).lon(), multiPoint.get(0).lat());
+                    for (int i = 1; i < multiPoint.size(); ++i) {
+                        sb.append(COMMA);
+                        sb.append(SPACE);
+                        Point point = multiPoint.get(i);
+                        visitPoint(point.lon(), point.lat());
+                    }
+                    sb.append(RPAREN);
+                    return null;
+                }
+
+                @Override
+                public Void visit(MultiPolygon multiPolygon) {
+                    visitCollection(multiPolygon);
+                    return null;
+                }
+
+                @Override
+                public Void visit(Point point) {
+                    if (point.isEmpty()) {
+                        sb.append(EMPTY);
+                    } else {
+                        sb.append(LPAREN);
+                        visitPoint(point.lon(), point.lat());
+                        sb.append(RPAREN);
+                    }
+                    return null;
+                }
+
+                private void visitPoint(double lon, double lat) {
+                    sb.append(lon).append(SPACE).append(lat);
+                }
+
+                private void visitCollection(GeometryCollection<?> collection) {
+                    if (collection.size() == 0) {
+                        sb.append(EMPTY);
+                    } else {
+                        sb.append(LPAREN);
+                        collection.get(0).visit(this);
+                        for (int i = 1; i < collection.size(); ++i) {
+                            sb.append(COMMA);
+                            collection.get(i).visit(this);
+                        }
+                        sb.append(RPAREN);
+                    }
+                }
+
+                @Override
+                public Void visit(Polygon polygon) {
+                    sb.append(LPAREN);
+                    visit((Line) polygon.getPolygon());
+                    int numberOfHoles = polygon.getNumberOfHoles();
+                    for (int i = 0; i < numberOfHoles; ++i) {
+                        sb.append(", ");
+                        visit((Line) polygon.getHole(i));
+                    }
+                    sb.append(RPAREN);
+                    return null;
+                }
+
+                @Override
+                public Void visit(Rectangle rectangle) {
+                    sb.append(LPAREN);
+                    // minX, maxX, maxY, minY
+                    sb.append(rectangle.getMinLon());
+                    sb.append(COMMA);
+                    sb.append(SPACE);
+                    sb.append(rectangle.getMaxLon());
+                    sb.append(COMMA);
+                    sb.append(SPACE);
+                    sb.append(rectangle.getMaxLat());
+                    sb.append(COMMA);
+                    sb.append(SPACE);
+                    sb.append(rectangle.getMinLat());
+                    sb.append(RPAREN);
+                    return null;
+                }
+            });
+        }
+    }
+
+    public static Geometry fromWKT(String wkt) throws IOException, ParseException {
+        StringReader reader = new StringReader(wkt);
+        try {
+            // setup the tokenizer; configured to read words w/o numbers
+            StreamTokenizer tokenizer = new StreamTokenizer(reader);
+            tokenizer.resetSyntax();
+            tokenizer.wordChars('a', 'z');
+            tokenizer.wordChars('A', 'Z');
+            tokenizer.wordChars(128 + 32, 255);
+            tokenizer.wordChars('0', '9');
+            tokenizer.wordChars('-', '-');
+            tokenizer.wordChars('+', '+');
+            tokenizer.wordChars('.', '.');
+            tokenizer.whitespaceChars(' ', ' ');
+            tokenizer.whitespaceChars('\t', '\t');
+            tokenizer.whitespaceChars('\r', '\r');
+            tokenizer.whitespaceChars('\n', '\n');
+            tokenizer.commentChar('#');
+            return parseGeometry(tokenizer);
+        } finally {
+            reader.close();
+        }
+    }
+
+    /**
+     * parse geometry from the stream tokenizer
+     */
+    private static Geometry parseGeometry(StreamTokenizer stream) throws IOException, ParseException {
+        final String type = nextWord(stream).toLowerCase(Locale.ROOT);
+        switch (type) {
+            case "point":
+                return parsePoint(stream);
+            case "multipoint":
+                return parseMultiPoint(stream);
+            case "linestring":
+                return parseLine(stream);
+            case "multilinestring":
+                return parseMultiLine(stream);
+            case "polygon":
+                return parsePolygon(stream);
+            case "multipolygon":
+                return parseMultiPolygon(stream);
+            case "bbox":
+                return parseBBox(stream);
+            case "geometrycollection":
+                return parseGeometryCollection(stream);
+            case "circle": // Not part of the standard, but we need it for internal serialization
+                return parseCircle(stream);
+        }
+        throw new IllegalArgumentException("Unknown geometry type: " + type);
+    }
+
+    private static GeometryCollection<Geometry> parseGeometryCollection(StreamTokenizer stream) throws IOException, ParseException {
+        if (nextEmptyOrOpen(stream).equals(EMPTY)) {
+            return GeometryCollection.EMPTY;
+        }
+        List<Geometry> shapes = new ArrayList<>();
+        shapes.add(parseGeometry(stream));
+        while (nextCloserOrComma(stream).equals(COMMA)) {
+            shapes.add(parseGeometry(stream));
+        }
+        return new GeometryCollection<>(shapes);
+    }
+
+    private static Point parsePoint(StreamTokenizer stream) throws IOException, ParseException {
+        if (nextEmptyOrOpen(stream).equals(EMPTY)) {
+            return Point.EMPTY;
+        }
+        double lon = nextNumber(stream);
+        double lat = nextNumber(stream);
+        Point pt = new Point(lat, lon);
+        if (isNumberNext(stream) == true) {
+            nextNumber(stream);
+        }
+        nextCloser(stream);
+        return pt;
+    }
+
+    private static void parseCoordinates(StreamTokenizer stream, ArrayList<Double> lats, ArrayList<Double> lons)
+        throws IOException, ParseException {
+        parseCoordinate(stream, lats, lons);
+        while (nextCloserOrComma(stream).equals(COMMA)) {
+            parseCoordinate(stream, lats, lons);
+        }
+    }
+
+    private static void parseCoordinate(StreamTokenizer stream, ArrayList<Double> lats, ArrayList<Double> lons)
+        throws IOException, ParseException {
+        lons.add(nextNumber(stream));
+        lats.add(nextNumber(stream));
+        if (isNumberNext(stream)) {
+            nextNumber(stream);
+        }
+    }
+
+    private static MultiPoint parseMultiPoint(StreamTokenizer stream) throws IOException, ParseException {
+        String token = nextEmptyOrOpen(stream);
+        if (token.equals(EMPTY)) {
+            return MultiPoint.EMPTY;
+        }
+        ArrayList<Double> lats = new ArrayList<>();
+        ArrayList<Double> lons = new ArrayList<>();
+        ArrayList<Point> points = new ArrayList<>();
+        parseCoordinates(stream, lats, lons);
+        for (int i = 0; i < lats.size(); i++) {
+            points.add(new Point(lats.get(i), lons.get(i)));
+        }
+        return new MultiPoint(Collections.unmodifiableList(points));
+    }
+
+    private static Line parseLine(StreamTokenizer stream) throws IOException, ParseException {
+        String token = nextEmptyOrOpen(stream);
+        if (token.equals(EMPTY)) {
+            return Line.EMPTY;
+        }
+        ArrayList<Double> lats = new ArrayList<>();
+        ArrayList<Double> lons = new ArrayList<>();
+        parseCoordinates(stream, lats, lons);
+        return new Line(lats.stream().mapToDouble(i -> i).toArray(), lons.stream().mapToDouble(i -> i).toArray());
+    }
+
+    private static MultiLine parseMultiLine(StreamTokenizer stream) throws IOException, ParseException {
+        String token = nextEmptyOrOpen(stream);
+        if (token.equals(EMPTY)) {
+            return MultiLine.EMPTY;
+        }
+        ArrayList<Line> lines = new ArrayList<>();
+        lines.add(parseLine(stream));
+        while (nextCloserOrComma(stream).equals(COMMA)) {
+            lines.add(parseLine(stream));
+        }
+        return new MultiLine(Collections.unmodifiableList(lines));
+    }
+
+    private static LinearRing parsePolygonHole(StreamTokenizer stream) throws IOException, ParseException {
+        nextOpener(stream);
+        ArrayList<Double> lats = new ArrayList<>();
+        ArrayList<Double> lons = new ArrayList<>();
+        parseCoordinates(stream, lats, lons);
+        return new LinearRing(lats.stream().mapToDouble(i -> i).toArray(), lons.stream().mapToDouble(i -> i).toArray());
+    }
+
+    private static Polygon parsePolygon(StreamTokenizer stream) throws IOException, ParseException {
+        if (nextEmptyOrOpen(stream).equals(EMPTY)) {
+            return Polygon.EMPTY;
+        }
+        nextOpener(stream);
+        ArrayList<Double> lats = new ArrayList<>();
+        ArrayList<Double> lons = new ArrayList<>();
+        parseCoordinates(stream, lats, lons);
+        ArrayList<LinearRing> holes = new ArrayList<>();
+        while (nextCloserOrComma(stream).equals(COMMA)) {
+            holes.add(parsePolygonHole(stream));
+        }
+        if (holes.isEmpty()) {
+            return new Polygon(new LinearRing(lats.stream().mapToDouble(i -> i).toArray(), lons.stream().mapToDouble(i -> i).toArray()));
+        } else {
+            return new Polygon(
+                new LinearRing(lats.stream().mapToDouble(i -> i).toArray(), lons.stream().mapToDouble(i -> i).toArray()),
+                Collections.unmodifiableList(holes));
+        }
+    }
+
+    private static MultiPolygon parseMultiPolygon(StreamTokenizer stream) throws IOException, ParseException {
+        String token = nextEmptyOrOpen(stream);
+        if (token.equals(EMPTY)) {
+            return MultiPolygon.EMPTY;
+        }
+        ArrayList<Polygon> polygons = new ArrayList<>();
+        polygons.add(parsePolygon(stream));
+        while (nextCloserOrComma(stream).equals(COMMA)) {
+            polygons.add(parsePolygon(stream));
+        }
+        return new MultiPolygon(Collections.unmodifiableList(polygons));
+    }
+
+    private static Rectangle parseBBox(StreamTokenizer stream) throws IOException, ParseException {
+        if (nextEmptyOrOpen(stream).equals(EMPTY)) {
+            return Rectangle.EMPTY;
+        }
+        double minLon = nextNumber(stream);
+        nextComma(stream);
+        double maxLon = nextNumber(stream);
+        nextComma(stream);
+        double maxLat = nextNumber(stream);
+        nextComma(stream);
+        double minLat = nextNumber(stream);
+        nextCloser(stream);
+        return new Rectangle(minLat, maxLat, minLon, maxLon);
+    }
+
+
+    private static Circle parseCircle(StreamTokenizer stream) throws IOException, ParseException {
+        if (nextEmptyOrOpen(stream).equals(EMPTY)) {
+            return Circle.EMPTY;
+        }
+        double lon = nextNumber(stream);
+        double lat = nextNumber(stream);
+        double radius = nextNumber(stream);
+        Circle circle = new Circle(lat, lon, radius);
+        if (isNumberNext(stream) == true) {
+            nextNumber(stream);
+        }
+        nextCloser(stream);
+        return circle;
+    }
+
+    /**
+     * next word in the stream
+     */
+    private static String nextWord(StreamTokenizer stream) throws ParseException, IOException {
+        switch (stream.nextToken()) {
+            case StreamTokenizer.TT_WORD:
+                final String word = stream.sval;
+                return word.equalsIgnoreCase(EMPTY) ? EMPTY : word;
+            case '(':
+                return LPAREN;
+            case ')':
+                return RPAREN;
+            case ',':
+                return COMMA;
+        }
+        throw new ParseException("expected word but found: " + tokenString(stream), stream.lineno());
+    }
+
+    private static double nextNumber(StreamTokenizer stream) throws IOException, ParseException {
+        if (stream.nextToken() == StreamTokenizer.TT_WORD) {
+            if (stream.sval.equalsIgnoreCase(NAN)) {
+                return Double.NaN;
+            } else {
+                try {
+                    return Double.parseDouble(stream.sval);
+                } catch (NumberFormatException e) {
+                    throw new ParseException("invalid number found: " + stream.sval, stream.lineno());
+                }
+            }
+        }
+        throw new ParseException("expected number but found: " + tokenString(stream), stream.lineno());
+    }
+
+    private static String tokenString(StreamTokenizer stream) {
+        switch (stream.ttype) {
+            case StreamTokenizer.TT_WORD:
+                return stream.sval;
+            case StreamTokenizer.TT_EOF:
+                return EOF;
+            case StreamTokenizer.TT_EOL:
+                return EOL;
+            case StreamTokenizer.TT_NUMBER:
+                return NUMBER;
+        }
+        return "'" + (char) stream.ttype + "'";
+    }
+
+    private static boolean isNumberNext(StreamTokenizer stream) throws IOException {
+        final int type = stream.nextToken();
+        stream.pushBack();
+        return type == StreamTokenizer.TT_WORD;
+    }
+
+    private static String nextEmptyOrOpen(StreamTokenizer stream) throws IOException, ParseException {
+        final String next = nextWord(stream);
+        if (next.equals(EMPTY) || next.equals(LPAREN)) {
+            return next;
+        }
+        throw new ParseException("expected " + EMPTY + " or " + LPAREN
+            + " but found: " + tokenString(stream), stream.lineno());
+    }
+
+    private static String nextCloser(StreamTokenizer stream) throws IOException, ParseException {
+        if (nextWord(stream).equals(RPAREN)) {
+            return RPAREN;
+        }
+        throw new ParseException("expected " + RPAREN + " but found: " + tokenString(stream), stream.lineno());
+    }
+
+    private static String nextComma(StreamTokenizer stream) throws IOException, ParseException {
+        if (nextWord(stream).equals(COMMA) == true) {
+            return COMMA;
+        }
+        throw new ParseException("expected " + COMMA + " but found: " + tokenString(stream), stream.lineno());
+    }
+
+    private static String nextOpener(StreamTokenizer stream) throws IOException, ParseException {
+        if (nextWord(stream).equals(LPAREN)) {
+            return LPAREN;
+        }
+        throw new ParseException("expected " + LPAREN + " but found: " + tokenString(stream), stream.lineno());
+    }
+
+    private static String nextCloserOrComma(StreamTokenizer stream) throws IOException, ParseException {
+        String token = nextWord(stream);
+        if (token.equals(COMMA) || token.equals(RPAREN)) {
+            return token;
+        }
+        throw new ParseException("expected " + COMMA + " or " + RPAREN
+            + " but found: " + tokenString(stream), stream.lineno());
+    }
+
+    public static String getWKTName(Geometry geometry) {
+        return geometry.visit(new GeometryVisitor<String>() {
+            @Override
+            public String visit(Circle circle) {
+                return "circle";
+            }
+
+            @Override
+            public String visit(GeometryCollection<?> collection) {
+                return "geometrycollection";
+            }
+
+            @Override
+            public String visit(Line line) {
+                return "linestring";
+            }
+
+            @Override
+            public String visit(LinearRing ring) {
+                throw new UnsupportedOperationException("line ring cannot be serialized using WKT");
+            }
+
+            @Override
+            public String visit(MultiLine multiLine) {
+                return "multilinestring";
+            }
+
+            @Override
+            public String visit(MultiPoint multiPoint) {
+                return "multipoint";
+            }
+
+            @Override
+            public String visit(MultiPolygon multiPolygon) {
+                return "multipolygon";
+            }
+
+            @Override
+            public String visit(Point point) {
+                return "point";
+            }
+
+            @Override
+            public String visit(Polygon polygon) {
+                return "polygon";
+            }
+
+            @Override
+            public String visit(Rectangle rectangle) {
+                return "bbox";
+            }
+        });
+    }
+
+}

+ 203 - 0
libs/geo/src/test/java/org/elasticsearch/geo/geometry/BaseGeometryTestCase.java

@@ -0,0 +1,203 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+import org.elasticsearch.ElasticsearchException;
+import org.elasticsearch.Version;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.geo.utils.WellKnownText;
+import org.elasticsearch.test.AbstractWireTestCase;
+
+import java.io.IOException;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Supplier;
+
+abstract class BaseGeometryTestCase<T extends Geometry> extends AbstractWireTestCase<T> {
+
+    @Override
+    protected Writeable.Reader<T> instanceReader() {
+        throw new IllegalStateException("shouldn't be called in this test");
+    }
+
+
+    @SuppressWarnings("unchecked")
+    @Override
+    protected T copyInstance(T instance, Version version) throws IOException {
+        String text = WellKnownText.toWKT(instance);
+        try {
+            return (T) WellKnownText.fromWKT(text);
+        } catch (ParseException e) {
+            throw new ElasticsearchException(e);
+        }
+    }
+
+    public void testVisitor() {
+        testVisitor(createTestInstance());
+    }
+
+    public static void testVisitor(Geometry geom) {
+        AtomicBoolean called = new AtomicBoolean(false);
+        Object result = geom.visit(new GeometryVisitor<Object>() {
+            private Object verify(Geometry geometry, String expectedClass) {
+                assertFalse("Visitor should be called only once", called.getAndSet(true));
+                assertSame(geom, geometry);
+                assertEquals(geometry.getClass().getName(), "org.elasticsearch.geo.geometry." + expectedClass);
+                return "result";
+            }
+
+            @Override
+            public Object visit(Circle circle) {
+                return verify(circle, "Circle");
+            }
+
+            @Override
+            public Object visit(GeometryCollection<?> collection) {
+                return verify(collection, "GeometryCollection");            }
+
+            @Override
+            public Object visit(Line line) {
+                return verify(line, "Line");
+            }
+
+            @Override
+            public Object visit(LinearRing ring) {
+                return verify(ring, "LinearRing");
+            }
+
+            @Override
+            public Object visit(MultiLine multiLine) {
+                return verify(multiLine, "MultiLine");
+            }
+
+            @Override
+            public Object visit(MultiPoint multiPoint) {
+                return verify(multiPoint, "MultiPoint");
+            }
+
+            @Override
+            public Object visit(MultiPolygon multiPolygon) {
+                return verify(multiPolygon, "MultiPolygon");
+            }
+
+            @Override
+            public Object visit(Point point) {
+                return verify(point, "Point");
+            }
+
+            @Override
+            public Object visit(Polygon polygon) {
+                return verify(polygon, "Polygon");
+            }
+
+            @Override
+            public Object visit(Rectangle rectangle) {
+                return verify(rectangle, "Rectangle");
+            }
+        });
+
+        assertTrue("visitor wasn't called", called.get());
+        assertEquals("result", result);
+    }
+
+    public static double randomLat() {
+        return randomDoubleBetween(-90, 90, true);
+    }
+
+    public static double randomLon() {
+        return randomDoubleBetween(-180, 180, true);
+    }
+
+    public static Circle randomCircle() {
+        return new Circle(randomDoubleBetween(-90, 90, true), randomDoubleBetween(-180, 180, true), randomDoubleBetween(0, 100, false));
+    }
+
+    public static Line randomLine() {
+        int size = randomIntBetween(2, 10);
+        double[] lats = new double[size];
+        double[] lons = new double[size];
+        for (int i = 0; i < size; i++) {
+            lats[i] = randomLat();
+            lons[i] = randomLon();
+        }
+        return new Line(lats, lons);
+    }
+
+    public static Point randomPoint() {
+        return new Point(randomLat(), randomLon());
+    }
+
+    public static LinearRing randomLinearRing() {
+        int size = randomIntBetween(3, 10);
+        double[] lats = new double[size + 1];
+        double[] lons = new double[size + 1];
+        for (int i = 0; i < size; i++) {
+            lats[i] = randomLat();
+            lons[i] = randomLon();
+        }
+        lats[size] = lats[0];
+        lons[size] = lons[0];
+        return new LinearRing(lats, lons);
+    }
+
+    public static Polygon randomPolygon() {
+        int size = randomIntBetween(0, 10);
+        List<LinearRing> holes = new ArrayList<>();
+        for (int i = 0; i < size; i++) {
+            holes.add(randomLinearRing());
+        }
+        if (holes.size() > 0) {
+            return new Polygon(randomLinearRing(), holes);
+        } else {
+            return new Polygon(randomLinearRing());
+        }
+    }
+
+    public static Rectangle randomRectangle() {
+        double lat1 = randomLat();
+        double lat2 = randomLat();
+        double minLon = randomLon();
+        double maxLon = randomLon();
+        return new Rectangle(Math.min(lat1, lat2), Math.max(lat1, lat2), minLon, maxLon);
+    }
+
+    public static GeometryCollection<Geometry> randomGeometryCollection() {
+        return randomGeometryCollection(0);
+    }
+
+    private static GeometryCollection<Geometry> randomGeometryCollection(int level) {
+        int size = randomIntBetween(1, 10);
+        List<Geometry> shapes = new ArrayList<>();
+        for (int i = 0; i < size; i++) {
+            @SuppressWarnings("unchecked") Supplier<Geometry> geometry = randomFrom(
+                BaseGeometryTestCase::randomCircle,
+                BaseGeometryTestCase::randomLine,
+                BaseGeometryTestCase::randomPoint,
+                BaseGeometryTestCase::randomPolygon,
+                BaseGeometryTestCase::randomRectangle,
+                level < 3 ? () -> randomGeometryCollection(level + 1) : BaseGeometryTestCase::randomPoint // don't build too deep
+            );
+            shapes.add(geometry.get());
+        }
+        return new GeometryCollection<>(shapes);
+    }
+}

+ 51 - 0
libs/geo/src/test/java/org/elasticsearch/geo/geometry/CircleTests.java

@@ -0,0 +1,51 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+import org.elasticsearch.geo.utils.WellKnownText;
+
+import java.io.IOException;
+import java.text.ParseException;
+
+public class CircleTests extends BaseGeometryTestCase<Circle> {
+    @Override
+    protected Circle createTestInstance() {
+        return new Circle(randomDoubleBetween(-90, 90, true), randomDoubleBetween(-180, 180, true), randomDoubleBetween(0, 100, false));
+    }
+
+    public void testBasicSerialization() throws IOException, ParseException {
+        assertEquals("circle (20.0 10.0 15.0)", WellKnownText.toWKT(new Circle(10, 20, 15)));
+        assertEquals(new Circle(10, 20, 15), WellKnownText.fromWKT("circle (20.0 10.0 15.0)"));
+
+        assertEquals("circle EMPTY", WellKnownText.toWKT(Circle.EMPTY));
+        assertEquals(Circle.EMPTY, WellKnownText.fromWKT("circle EMPTY)"));
+    }
+
+    public void testInitValidation() {
+        IllegalArgumentException ex = expectThrows(IllegalArgumentException.class, () -> new Circle(10, 20, -1));
+        assertEquals("Circle radius [-1.0] cannot be negative", ex.getMessage());
+
+        ex = expectThrows(IllegalArgumentException.class, () -> new Circle(100, 20, 1));
+        assertEquals("invalid latitude 100.0; must be between -90.0 and 90.0", ex.getMessage());
+
+        ex = expectThrows(IllegalArgumentException.class, () -> new Circle(10, 200, 1));
+        assertEquals("invalid longitude 200.0; must be between -180.0 and 180.0", ex.getMessage());
+    }
+}

+ 54 - 0
libs/geo/src/test/java/org/elasticsearch/geo/geometry/GeometryCollectionTests.java

@@ -0,0 +1,54 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+import org.elasticsearch.geo.utils.WellKnownText;
+
+import java.io.IOException;
+import java.text.ParseException;
+import java.util.Arrays;
+import java.util.Collections;
+
+public class GeometryCollectionTests extends BaseGeometryTestCase<GeometryCollection<Geometry>> {
+    @Override
+    protected GeometryCollection<Geometry> createTestInstance() {
+        return randomGeometryCollection();
+    }
+
+    public void testBasicSerialization() throws IOException, ParseException {
+        assertEquals("geometrycollection (point (20.0 10.0),point EMPTY)",
+            WellKnownText.toWKT(new GeometryCollection<Geometry>(Arrays.asList(new Point(10, 20), Point.EMPTY))));
+
+        assertEquals(new GeometryCollection<Geometry>(Arrays.asList(new Point(10, 20), Point.EMPTY)),
+            WellKnownText.fromWKT("geometrycollection (point (20.0 10.0),point EMPTY)"));
+
+        assertEquals("geometrycollection EMPTY", WellKnownText.toWKT(GeometryCollection.EMPTY));
+        assertEquals(GeometryCollection.EMPTY, WellKnownText.fromWKT("geometrycollection EMPTY)"));
+    }
+
+    @SuppressWarnings("ConstantConditions")
+    public void testInitValidation() {
+        IllegalArgumentException ex = expectThrows(IllegalArgumentException.class, () -> new GeometryCollection<>(Collections.emptyList()));
+        assertEquals("the list of shapes cannot be null or empty", ex.getMessage());
+
+        ex = expectThrows(IllegalArgumentException.class, () -> new GeometryCollection<>(null));
+        assertEquals("the list of shapes cannot be null or empty", ex.getMessage());
+    }
+}

+ 51 - 0
libs/geo/src/test/java/org/elasticsearch/geo/geometry/LineTests.java

@@ -0,0 +1,51 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+import org.elasticsearch.geo.utils.WellKnownText;
+
+import java.io.IOException;
+import java.text.ParseException;
+
+public class LineTests extends BaseGeometryTestCase<Line> {
+    @Override
+    protected Line createTestInstance() {
+        return randomLine();
+    }
+
+    public void testBasicSerialization() throws IOException, ParseException {
+        assertEquals("linestring (3.0 1.0, 4.0 2.0)", WellKnownText.toWKT(new Line(new double[]{1, 2}, new double[]{3, 4})));
+        assertEquals(new Line(new double[]{1, 2}, new double[]{3, 4}), WellKnownText.fromWKT("linestring (3 1, 4 2)"));
+
+        assertEquals("linestring EMPTY", WellKnownText.toWKT(Line.EMPTY));
+        assertEquals(Line.EMPTY, WellKnownText.fromWKT("linestring EMPTY)"));
+    }
+
+    public void testInitValidation() {
+        IllegalArgumentException ex = expectThrows(IllegalArgumentException.class, () -> new Line(new double[]{1}, new double[]{3}));
+        assertEquals("at least two points in the line is required", ex.getMessage());
+
+        ex = expectThrows(IllegalArgumentException.class, () -> new Line(new double[]{1, 2, 3, 1}, new double[]{3, 4, 500, 3}));
+        assertEquals("invalid longitude 500.0; must be between -180.0 and 180.0", ex.getMessage());
+
+        ex = expectThrows(IllegalArgumentException.class, () -> new Line(new double[]{1, 100, 3, 1}, new double[]{3, 4, 5, 3}));
+        assertEquals("invalid latitude 100.0; must be between -90.0 and 90.0", ex.getMessage());
+    }
+}

+ 52 - 0
libs/geo/src/test/java/org/elasticsearch/geo/geometry/LinearRingTests.java

@@ -0,0 +1,52 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+import org.elasticsearch.geo.utils.WellKnownText;
+import org.elasticsearch.test.ESTestCase;
+
+public class LinearRingTests extends ESTestCase {
+
+    public void testBasicSerialization() {
+        UnsupportedOperationException ex = expectThrows(UnsupportedOperationException.class,
+            () -> WellKnownText.toWKT(new LinearRing(new double[]{1, 2, 3, 1}, new double[]{3, 4, 5, 3})));
+        assertEquals("line ring cannot be serialized using WKT", ex.getMessage());
+    }
+
+    public void testInitValidation() {
+        IllegalArgumentException ex = expectThrows(IllegalArgumentException.class,
+            () -> new LinearRing(new double[]{1, 2, 3}, new double[]{3, 4, 5}));
+        assertEquals("first and last points of the linear ring must be the same (it must close itself): lats[0]=1.0 lats[2]=3.0",
+            ex.getMessage());
+
+        ex = expectThrows(IllegalArgumentException.class, () -> new LinearRing(new double[]{1}, new double[]{3}));
+        assertEquals("at least two points in the line is required", ex.getMessage());
+
+        ex = expectThrows(IllegalArgumentException.class, () -> new LinearRing(new double[]{1, 2, 3, 1}, new double[]{3, 4, 500, 3}));
+        assertEquals("invalid longitude 500.0; must be between -180.0 and 180.0", ex.getMessage());
+
+        ex = expectThrows(IllegalArgumentException.class, () -> new LinearRing(new double[]{1, 100, 3, 1}, new double[]{3, 4, 5, 3}));
+        assertEquals("invalid latitude 100.0; must be between -90.0 and 90.0", ex.getMessage());
+    }
+
+    public void testVisitor() {
+        BaseGeometryTestCase.testVisitor(new LinearRing(new double[]{1, 2, 3, 1}, new double[]{3, 4, 5, 3}));
+    }
+}

+ 51 - 0
libs/geo/src/test/java/org/elasticsearch/geo/geometry/MultiLineTests.java

@@ -0,0 +1,51 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+import org.elasticsearch.geo.utils.WellKnownText;
+
+import java.io.IOException;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class MultiLineTests extends BaseGeometryTestCase<MultiLine> {
+
+    @Override
+    protected MultiLine createTestInstance() {
+        int size = randomIntBetween(1, 10);
+        List<Line> arr = new ArrayList<Line>();
+        for (int i = 0; i < size; i++) {
+            arr.add(randomLine());
+        }
+        return new MultiLine(arr);
+    }
+
+    public void testBasicSerialization() throws IOException, ParseException {
+        assertEquals("multilinestring ((3.0 1.0, 4.0 2.0))", WellKnownText.toWKT(
+            new MultiLine(Collections.singletonList(new Line(new double[]{1, 2}, new double[]{3, 4})))));
+        assertEquals(new MultiLine(Collections.singletonList(new Line(new double[]{1, 2}, new double[]{3, 4}))),
+            WellKnownText.fromWKT("multilinestring ((3 1, 4 2))"));
+
+        assertEquals("multilinestring EMPTY", WellKnownText.toWKT(MultiLine.EMPTY));
+        assertEquals(MultiLine.EMPTY, WellKnownText.fromWKT("multilinestring EMPTY)"));
+    }
+}

+ 51 - 0
libs/geo/src/test/java/org/elasticsearch/geo/geometry/MultiPointTests.java

@@ -0,0 +1,51 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+import org.elasticsearch.geo.utils.WellKnownText;
+
+import java.io.IOException;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class MultiPointTests extends BaseGeometryTestCase<MultiPoint> {
+
+    @Override
+    protected MultiPoint createTestInstance() {
+        int size = randomIntBetween(1, 10);
+        List<Point> arr = new ArrayList<>();
+        for (int i = 0; i < size; i++) {
+            arr.add(randomPoint());
+        }
+        return new MultiPoint(arr);
+    }
+
+    public void testBasicSerialization() throws IOException, ParseException {
+        assertEquals("multipoint (2.0 1.0)", WellKnownText.toWKT(
+            new MultiPoint(Collections.singletonList(new Point(1, 2)))));
+        assertEquals(new MultiPoint(Collections.singletonList(new Point(1 ,2))),
+            WellKnownText.fromWKT("multipoint (2 1)"));
+
+        assertEquals("multipoint EMPTY", WellKnownText.toWKT(MultiPoint.EMPTY));
+        assertEquals(MultiPoint.EMPTY, WellKnownText.fromWKT("multipoint EMPTY)"));
+    }
+}

+ 53 - 0
libs/geo/src/test/java/org/elasticsearch/geo/geometry/MultiPolygonTests.java

@@ -0,0 +1,53 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+import org.elasticsearch.geo.utils.WellKnownText;
+
+import java.io.IOException;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class MultiPolygonTests extends BaseGeometryTestCase<MultiPolygon> {
+
+    @Override
+    protected MultiPolygon createTestInstance() {
+        int size = randomIntBetween(1, 10);
+        List<Polygon> arr = new ArrayList<>();
+        for (int i = 0; i < size; i++) {
+            arr.add(randomPolygon());
+        }
+        return new MultiPolygon(arr);
+    }
+
+    public void testBasicSerialization() throws IOException, ParseException {
+        assertEquals("multipolygon (((3.0 1.0, 4.0 2.0, 5.0 3.0, 3.0 1.0)))",
+            WellKnownText.toWKT(new MultiPolygon(Collections.singletonList(
+                new Polygon(new LinearRing(new double[]{1, 2, 3, 1}, new double[]{3, 4, 5, 3}))))));
+        assertEquals(new MultiPolygon(Collections.singletonList(
+            new Polygon(new LinearRing(new double[]{1, 2, 3, 1}, new double[]{3, 4, 5, 3})))),
+            WellKnownText.fromWKT("multipolygon (((3.0 1.0, 4.0 2.0, 5.0 3.0, 3.0 1.0)))"));
+
+        assertEquals("multipolygon EMPTY", WellKnownText.toWKT(MultiPolygon.EMPTY));
+        assertEquals(MultiPolygon.EMPTY, WellKnownText.fromWKT("multipolygon EMPTY)"));
+    }
+}

+ 48 - 0
libs/geo/src/test/java/org/elasticsearch/geo/geometry/PointTests.java

@@ -0,0 +1,48 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+import org.elasticsearch.geo.utils.WellKnownText;
+
+import java.io.IOException;
+import java.text.ParseException;
+
+public class PointTests extends BaseGeometryTestCase<Point> {
+    @Override
+    protected Point createTestInstance() {
+        return randomPoint();
+    }
+
+    public void testBasicSerialization() throws IOException, ParseException {
+        assertEquals("point (20.0 10.0)", WellKnownText.toWKT(new Point(10, 20)));
+        assertEquals(new Point(10, 20), WellKnownText.fromWKT("point (20.0 10.0)"));
+
+        assertEquals("point EMPTY", WellKnownText.toWKT(Point.EMPTY));
+        assertEquals(Point.EMPTY, WellKnownText.fromWKT("point EMPTY)"));
+    }
+
+    public void testInitValidation() {
+        IllegalArgumentException ex = expectThrows(IllegalArgumentException.class, () -> new Point(100, 10));
+        assertEquals("invalid latitude 100.0; must be between -90.0 and 90.0", ex.getMessage());
+
+        ex = expectThrows(IllegalArgumentException.class, () -> new Point(10, 500));
+        assertEquals("invalid longitude 500.0; must be between -180.0 and 180.0", ex.getMessage());
+    }
+}

+ 52 - 0
libs/geo/src/test/java/org/elasticsearch/geo/geometry/PolygonTests.java

@@ -0,0 +1,52 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+import org.elasticsearch.geo.utils.WellKnownText;
+
+import java.io.IOException;
+import java.text.ParseException;
+
+public class PolygonTests extends BaseGeometryTestCase<Polygon> {
+    @Override
+    protected Polygon createTestInstance() {
+        return randomPolygon();
+    }
+
+    public void testBasicSerialization() throws IOException, ParseException {
+        assertEquals("polygon ((3.0 1.0, 4.0 2.0, 5.0 3.0, 3.0 1.0))",
+            WellKnownText.toWKT(new Polygon(new LinearRing(new double[]{1, 2, 3, 1}, new double[]{3, 4, 5, 3}))));
+        assertEquals(new Polygon(new LinearRing(new double[]{1, 2, 3, 1}, new double[]{3, 4, 5, 3})),
+            WellKnownText.fromWKT("polygon ((3 1, 4 2, 5 3, 3 1))"));
+
+        assertEquals("polygon EMPTY", WellKnownText.toWKT(Polygon.EMPTY));
+        assertEquals(Polygon.EMPTY, WellKnownText.fromWKT("polygon EMPTY)"));
+    }
+
+    public void testInitValidation() {
+        IllegalArgumentException ex = expectThrows(IllegalArgumentException.class,
+            () -> new Polygon(new LinearRing(new double[]{1, 2, 1}, new double[]{3, 4, 3})));
+        assertEquals("at least 4 polygon points required", ex.getMessage());
+
+        ex = expectThrows(IllegalArgumentException.class,
+            () -> new Polygon(new LinearRing(new double[]{1, 2, 3, 1}, new double[]{3, 4, 5, 3}), null));
+        assertEquals("holes must not be null", ex.getMessage());
+    }
+}

+ 51 - 0
libs/geo/src/test/java/org/elasticsearch/geo/geometry/RectangleTests.java

@@ -0,0 +1,51 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.geo.geometry;
+
+import org.elasticsearch.geo.utils.WellKnownText;
+
+import java.io.IOException;
+import java.text.ParseException;
+
+public class RectangleTests extends BaseGeometryTestCase<Rectangle> {
+    @Override
+    protected Rectangle createTestInstance() {
+        return randomRectangle();
+    }
+
+    public void testBasicSerialization() throws IOException, ParseException {
+        assertEquals("bbox (10.0, 20.0, 40.0, 30.0)", WellKnownText.toWKT(new Rectangle(30, 40, 10, 20)));
+        assertEquals(new Rectangle(30, 40, 10, 20), WellKnownText.fromWKT("bbox (10.0, 20.0, 40.0, 30.0)"));
+
+        assertEquals("bbox EMPTY", WellKnownText.toWKT(Rectangle.EMPTY));
+        assertEquals(Rectangle.EMPTY, WellKnownText.fromWKT("bbox EMPTY)"));
+    }
+
+    public void testInitValidation() {
+        IllegalArgumentException ex = expectThrows(IllegalArgumentException.class, () -> new Rectangle(100, 1, 2, 3));
+        assertEquals("invalid latitude 100.0; must be between -90.0 and 90.0", ex.getMessage());
+
+        ex = expectThrows(IllegalArgumentException.class, () -> new Rectangle(1, 2, 200, 3));
+        assertEquals("invalid longitude 200.0; must be between -180.0 and 180.0", ex.getMessage());
+
+        ex = expectThrows(IllegalArgumentException.class, () -> new Rectangle(2, 1, 2, 3));
+        assertEquals("max lat cannot be less than min lat", ex.getMessage());
+    }
+}

+ 6 - 1
settings.gradle

@@ -95,6 +95,7 @@ if (isEclipse) {
   projects << 'libs:x-content-tests'
   projects << 'libs:secure-sm-tests'
   projects << 'libs:grok-tests'
+  projects << 'libs:geo-tests'
 }
 
 include projects.toArray(new String[0])
@@ -130,7 +131,10 @@ if (isEclipse) {
   project(":libs:grok").buildFileName = 'eclipse-build.gradle'
   project(":libs:grok-tests").projectDir = new File(rootProject.projectDir, 'libs/grok/src/test')
   project(":libs:grok-tests").buildFileName = 'eclipse-build.gradle'
-}
+  project(":libs:geo").projectDir = new File(rootProject.projectDir, 'libs/geo/src/main')
+  project(":libs:geo").buildFileName = 'eclipse-build.gradle'
+  project(":libs:geo-tests").projectDir = new File(rootProject.projectDir, 'libs/geo/src/test')
+  project(":libs:geo-tests").buildFileName = 'eclipse-build.gradle'}
 
 // look for extra plugins for elasticsearch
 File extraProjects = new File(rootProject.projectDir.parentFile, "${dirName}-extra")
@@ -141,3 +145,4 @@ if (extraProjects.exists()) {
 }
 
 project(":libs:cli").name = 'elasticsearch-cli'
+project(":libs:geo").name = 'elasticsearch-geo'