You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sedona.apache.org by ji...@apache.org on 2025/07/23 06:46:00 UTC
(sedona) branch master updated: [GH-2091] WKB, WKT reader & writer for geography (#2090)
This is an automated email from the ASF dual-hosted git repository.
jiayu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sedona.git
The following commit(s) were added to refs/heads/master by this push:
new e71661e807 [GH-2091] WKB, WKT reader & writer for geography (#2090)
e71661e807 is described below
commit e71661e807319ad764bd26e6a47b65a4d9452ec9
Author: Zhuocheng Shang <12...@users.noreply.github.com>
AuthorDate: Tue Jul 22 23:45:54 2025 -0700
[GH-2091] WKB, WKT reader & writer for geography (#2090)
* Create object of S2Geography, and implement PoinGeography with its encoder/decoder
* Add POLYLINE implementation on S2Geography
* Add POLYGON implements on S2Geography
* Match coding style
* "Apply Spotless formatting to PolylineGeographyTest"
* Redesign of S2Geography
- Import org.datasyslab s2-geometry-library
- Clean up S2Geography abstract design
- Update Encode/Decode inside each kind of geography
* clean up unnecessary files in current branch
* Refine design of EncodeTagged in S2Geography
- Adding back EncodeTagged in S2Geography
- Let each geography type calls its own encode / decode function
- Change to use Kyro UnsafeInput and UnsafeOutput
* Modify encoder() and add new test cases
* clean up code of encode and clarify comments
* Update POLYGON to only take one polygon
* Remove S2Regionwrapper & S2Shapewrapper
* clean up minor issue
* GeographyCollection and ShapeIndexGeography implementation
* comcomment geography kind in S2Geography
* format violation
* Adjust Shapeindexgeography encode/decode and shapeID
* Clean up format
* WKB, WKT reader writer with associated geography type
* Fix WKB writer with multi geography & add complex test cases
* add EMPTY test cases
* format cleaning
---
.../sedona/common/S2Geography/Accessors.java | 8 +-
.../common/S2Geography/MultiPolygonGeography.java | 54 ++
.../sedona/common/S2Geography/PointGeography.java | 19 +-
.../common/S2Geography/PolygonGeography.java | 64 ++
.../common/S2Geography/PolylineGeography.java | 29 +
.../sedona/common/S2Geography/S2Geography.java | 6 +
.../common/S2Geography/SinglePointGeography.java | 31 +
.../S2Geography/SinglePolylineGeography.java | 31 +
.../sedona/common/S2Geography/WKBReader.java | 456 ++++++++++
.../sedona/common/S2Geography/WKBWriter.java | 461 ++++++++++
.../sedona/common/S2Geography/WKTReader.java | 929 +++++++++++++++++++++
.../sedona/common/S2Geography/WKTWriter.java | 887 ++++++++++++++++++++
.../sedona/common/S2Geography/TestHelper.java | 118 +++
.../sedona/common/S2Geography/WKBReaderTest.java | 385 +++++++++
.../sedona/common/S2Geography/WKBWriterTest.java | 156 ++++
.../common/S2Geography/WKTReadWriterTest.java | 146 ++++
16 files changed, 3775 insertions(+), 5 deletions(-)
diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/Accessors.java b/common/src/main/java/org/apache/sedona/common/S2Geography/Accessors.java
index c30377fb30..c3a6f1b5a0 100644
--- a/common/src/main/java/org/apache/sedona/common/S2Geography/Accessors.java
+++ b/common/src/main/java/org/apache/sedona/common/S2Geography/Accessors.java
@@ -24,7 +24,7 @@ public class Accessors {
public Accessors() {}
- public boolean S2_isEmpty(S2Geography s2Geography) {
+ public static boolean S2_isEmpty(S2Geography s2Geography) {
for (int i = 0; i < s2Geography.numShapes(); i++) {
S2Shape shape = s2Geography.shape(i);
if (!shape.isEmpty()) return false;
@@ -32,7 +32,7 @@ public class Accessors {
return true;
}
- public boolean S2_isCollection(PolygonGeography polygonGeography) {
+ public static boolean S2_isCollection(PolygonGeography polygonGeography) {
int numOuterLoops = 0;
for (int i = 0; i < polygonGeography.polygon.numLoops(); i++) {
S2Loop loop = polygonGeography.polygon.loop(i);
@@ -79,7 +79,7 @@ public class Accessors {
}
}
- public int S2_dimension(S2Geography s2Geography) {
+ public static int S2_dimension(S2Geography s2Geography) {
int dimension = s2Geography.dimension();
if (dimension != -1) return dimension;
@@ -90,7 +90,7 @@ public class Accessors {
return dimension;
}
- public int S2_numPoints(S2Geography s2Geography) {
+ public static int S2_numPoints(S2Geography s2Geography) {
int numPoints = 0;
for (int i = 0; i < s2Geography.numShapes(); i++) {
S2Shape shape = s2Geography.shape(i);
diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/MultiPolygonGeography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/MultiPolygonGeography.java
new file mode 100644
index 0000000000..99b5a5ebd0
--- /dev/null
+++ b/common/src/main/java/org/apache/sedona/common/S2Geography/MultiPolygonGeography.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sedona.common.S2Geography;
+
+import com.google.common.geometry.S2Polygon;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+public class MultiPolygonGeography extends GeographyCollection {
+ /**
+ * Wrap each raw S2Polygon in a PolygonGeography, then hand it off to GeographyCollection to do
+ * the rest (including serialization).
+ */
+ public MultiPolygonGeography(List<S2Polygon> polygons) {
+ super(
+ polygons.stream()
+ .map(PolygonGeography::new) // wrap each S2Polygon
+ .collect(Collectors.toList())); // into a List<PolygonGeography>
+ if (polygons.isEmpty()) {
+ new MultiPolygonGeography();
+ }
+ }
+
+ public MultiPolygonGeography() {
+ super(Collections.emptyList());
+ }
+
+ public List<S2Geography> getFeatures() {
+ return features;
+ }
+
+ @Override
+ public int dimension() {
+ // every child PolygonGeography
+ return 2;
+ }
+}
diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/PointGeography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/PointGeography.java
index 17d7a3b53c..d46a5b48fc 100644
--- a/common/src/main/java/org/apache/sedona/common/S2Geography/PointGeography.java
+++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PointGeography.java
@@ -27,6 +27,9 @@ import com.google.common.geometry.PrimitiveArrays.Bytes;
import java.io.*;
import java.util.*;
import java.util.List;
+import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.CoordinateSequence;
+import org.locationtech.jts.geom.impl.CoordinateArraySequence;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -67,7 +70,7 @@ public class PointGeography extends S2Geography {
@Override
public int numShapes() {
- return points.isEmpty() ? 0 : 1;
+ return points.size();
}
@Override
@@ -239,4 +242,18 @@ public class PointGeography extends S2Geography {
geo.points.addAll(pts);
return geo;
}
+
+ public CoordinateSequence getCoordinateSequence() {
+ List<S2Point> pts = getPoints();
+ Coordinate[] coordArray = new Coordinate[pts.size()];
+ for (int i = 0; i < pts.size(); i++) {
+ S2Point pt = pts.get(i);
+ S2LatLng ll = new S2LatLng(pt);
+ double lat = ll.latDegrees();
+ double lon = ll.lngDegrees();
+ Coordinate c = new Coordinate(lon, lat);
+ coordArray[i] = c;
+ }
+ return new CoordinateArraySequence(coordArray);
+ }
}
diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java
index 2dccbf05d4..66ad219aea 100644
--- a/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java
+++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java
@@ -18,13 +18,20 @@
*/
package org.apache.sedona.common.S2Geography;
+import static org.apache.sedona.common.spider.Generator.GEOMETRY_FACTORY;
+
import com.esotericsoftware.kryo.io.Input;
import com.esotericsoftware.kryo.io.UnsafeInput;
import com.esotericsoftware.kryo.io.UnsafeOutput;
import com.google.common.geometry.*;
import java.io.IOException;
+import java.util.ArrayList;
import java.util.List;
import java.util.logging.Logger;
+import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.CoordinateSequence;
+import org.locationtech.jts.geom.LinearRing;
+import org.locationtech.jts.geom.impl.CoordinateArraySequence;
public class PolygonGeography extends S2Geography {
private static final Logger logger = Logger.getLogger(PolygonGeography.class.getName());
@@ -99,4 +106,61 @@ public class PolygonGeography extends S2Geography {
return geo;
}
+
+ public CoordinateSequence getCoordinateSequence() {
+ // Count total vertices across all loops
+ int total = 0;
+ for (int li = 0; li < polygon.numLoops(); li++) {
+ S2Loop loop = polygon.loop(li);
+ total += loop.numVertices();
+ }
+ // Build array of Coordinates
+ Coordinate[] coords = new Coordinate[total];
+ int idx = 0;
+ for (int li = 0; li < polygon.numLoops(); li++) {
+ S2Loop loop = polygon.loop(li);
+ int nv = loop.numVertices();
+ for (int vi = 0; vi < nv; vi++, idx++) {
+ S2Point p = loop.vertex(vi);
+ coords[idx] = new Coordinate(p.getX(), p.getY(), p.getZ());
+ }
+ }
+ return new CoordinateArraySequence(coords);
+ }
+
+ public LinearRing getExteriorRing() {
+ if (polygon.numLoops() == 0) return GEOMETRY_FACTORY.createLinearRing(new Coordinate[0]);
+ S2Loop loop = polygon.loop(0);
+ Coordinate[] coords = new Coordinate[loop.numVertices() + 1];
+ for (int i = 0; i < loop.numVertices(); i++) {
+ S2Point pt = loop.vertex(i);
+ S2LatLng ll = new S2LatLng(pt);
+ double lat = ll.latDegrees();
+ double lon = ll.lngDegrees();
+ Coordinate c = new Coordinate(lon, lat);
+ coords[i] = c;
+ }
+ coords[coords.length - 1] = coords[0];
+ return GEOMETRY_FACTORY.createLinearRing(coords);
+ }
+
+ /** Returns all interior loops as JTS LineStrings. */
+ public List<LinearRing> getLoops() {
+ List<LinearRing> loops = new ArrayList<>();
+ for (int li = 1; li < polygon.numLoops(); li++) {
+ S2Loop loop = polygon.loop(li);
+ Coordinate[] coords = new Coordinate[loop.numVertices() + 1];
+ for (int idx = 0; idx < loop.numVertices(); idx++) {
+ S2Point pt = loop.vertex(idx);
+ S2LatLng ll = new S2LatLng(pt);
+ double lat = ll.latDegrees();
+ double lon = ll.lngDegrees();
+ Coordinate c = new Coordinate(lon, lat);
+ coords[idx] = c;
+ }
+ coords[coords.length - 1] = coords[0];
+ loops.add(GEOMETRY_FACTORY.createLinearRing(coords));
+ }
+ return loops;
+ }
}
diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java
index ad8088222d..030418268e 100644
--- a/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java
+++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java
@@ -28,6 +28,9 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.logging.Logger;
+import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.CoordinateSequence;
+import org.locationtech.jts.geom.impl.CoordinateArraySequence;
/** A Geography representing zero or more polylines using S2Polyline. */
public class PolylineGeography extends S2Geography {
@@ -141,4 +144,30 @@ public class PolylineGeography extends S2Geography {
}
return geo;
}
+
+ public CoordinateSequence getCoordinateSequence() {
+ // 1) Count total vertices across all polylines
+ int totalVerts = 0;
+ for (S2Polyline pl : polylines) {
+ totalVerts += pl.numVertices();
+ }
+
+ // 2) Build a flat array of JTS Coordinates
+ Coordinate[] coords = new Coordinate[totalVerts];
+ int idx = 0;
+ for (S2Polyline pl : polylines) {
+ int n = pl.numVertices();
+ for (int i = 0; i < n; i++, idx++) {
+ S2Point pt = pl.vertex(i);
+ S2LatLng ll = new S2LatLng(pt);
+ double lat = ll.latDegrees();
+ double lon = ll.lngDegrees();
+ Coordinate c = new Coordinate(lon, lat);
+ coords[idx] = c;
+ }
+ }
+
+ // 3) Wrap in a CoordinateArraySequence
+ return new CoordinateArraySequence(coords);
+ }
}
diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java
index 567dc3036b..9c257c76da 100644
--- a/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java
+++ b/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java
@@ -42,6 +42,12 @@ public abstract class S2Geography {
this.kind = kind;
}
+ public void setSRID(int srid) {}
+
+ public int getSRID() {
+ return -1;
+ }
+
public enum GeographyKind {
UNINITIALIZED(0),
POINT(1),
diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/SinglePointGeography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/SinglePointGeography.java
new file mode 100644
index 0000000000..f546b0c38d
--- /dev/null
+++ b/common/src/main/java/org/apache/sedona/common/S2Geography/SinglePointGeography.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sedona.common.S2Geography;
+
+import com.google.common.geometry.S2Point;
+
+public class SinglePointGeography extends PointGeography {
+ public SinglePointGeography(S2Point p) {
+ super(p);
+ }
+
+ public SinglePointGeography() {
+ super();
+ }
+}
diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/SinglePolylineGeography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/SinglePolylineGeography.java
new file mode 100644
index 0000000000..2417c39aa7
--- /dev/null
+++ b/common/src/main/java/org/apache/sedona/common/S2Geography/SinglePolylineGeography.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sedona.common.S2Geography;
+
+import com.google.common.geometry.S2Polyline;
+
+public class SinglePolylineGeography extends PolylineGeography {
+ public SinglePolylineGeography(S2Polyline p) {
+ super(p);
+ }
+
+ public SinglePolylineGeography() {
+ super();
+ }
+}
diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/WKBReader.java b/common/src/main/java/org/apache/sedona/common/S2Geography/WKBReader.java
new file mode 100644
index 0000000000..08a18028aa
--- /dev/null
+++ b/common/src/main/java/org/apache/sedona/common/S2Geography/WKBReader.java
@@ -0,0 +1,456 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sedona.common.S2Geography;
+
+/*
+ * Copyright (c) 2016 Vivid Solutions.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License 2.0
+ * and Eclipse Distribution License v. 1.0 which accompanies this distribution.
+ * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ */
+import com.google.common.geometry.*;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import org.locationtech.jts.geom.*;
+import org.locationtech.jts.io.*;
+
+/** This code is a custom modified version of JTS WKBReader */
+public class WKBReader {
+ /**
+ * Converts a hexadecimal string to a byte array. The hexadecimal digit symbols are
+ * case-insensitive.
+ *
+ * @param hex a string containing hex digits
+ * @return an array of bytes with the value of the hex string
+ */
+ public static byte[] hexToBytes(String hex) {
+ int byteLen = hex.length() / 2;
+ byte[] bytes = new byte[byteLen];
+
+ for (int i = 0; i < hex.length() / 2; i++) {
+ int i2 = 2 * i;
+ if (i2 + 1 > hex.length()) throw new IllegalArgumentException("Hex string has odd length");
+
+ int nib1 = hexToInt(hex.charAt(i2));
+ int nib0 = hexToInt(hex.charAt(i2 + 1));
+ byte b = (byte) ((nib1 << 4) + (byte) nib0);
+ bytes[i] = b;
+ }
+ return bytes;
+ }
+
+ private static int hexToInt(char hex) {
+ int nib = Character.digit(hex, 16);
+ if (nib < 0) throw new IllegalArgumentException("Invalid hex digit: '" + hex + "'");
+ return nib;
+ }
+
+ private static final String INVALID_GEOM_TYPE_MSG = "Invalid geometry type encountered in ";
+
+ private static final String FIELD_NUMCOORDS = "numCoords";
+
+ private static final String FIELD_NUMRINGS = "numRings";
+
+ private static final String FIELD_NUMELEMS = "numElems";
+
+ private GeometryFactory factory;
+ private CoordinateSequenceFactory csFactory;
+ private PrecisionModel precisionModel;
+ // default dimension - will be set on read
+ private int inputDimension = 2;
+ /**
+ * true if structurally invalid input should be reported rather than repaired. At some point this
+ * could be made client-controllable.
+ */
+ private boolean isStrict = false;
+
+ private ByteOrderDataInStream dis = new ByteOrderDataInStream();
+ private double[] ordValues;
+
+ private int maxNumFieldValue;
+
+ /** Default constructor uses a JTS Array-based sequence factory. */
+ public WKBReader() {
+ this(new GeometryFactory());
+ }
+
+ public WKBReader(GeometryFactory geometryFactory) {
+ this.factory = geometryFactory;
+ precisionModel = factory.getPrecisionModel();
+ csFactory = factory.getCoordinateSequenceFactory();
+ }
+
+ /**
+ * Reads a single {@link S2Geography} in WKB format from a byte array.
+ *
+ * @param bytes the byte array to read from
+ * @return the geometry read
+ * @throws ParseException if the WKB is ill-formed
+ */
+ public S2Geography read(byte[] bytes) throws ParseException {
+ try {
+ // Use a very high limit to avoid malformed input OOM
+ return read(new ByteArrayInStream(bytes), Integer.MAX_VALUE);
+ } catch (IOException ex) {
+ throw new RuntimeException("I/O error reading WKB: " + ex.getMessage(), ex);
+ }
+ }
+
+ /**
+ * Reads a {@link S2Geography} in binary WKB format from an {@link InStream}.
+ *
+ * @param is the stream to read from
+ * @return the Geometry read
+ * @throws IOException if the underlying stream creates an error
+ * @throws ParseException if the WKB is ill-formed
+ */
+ public S2Geography read(InStream is) throws IOException, ParseException {
+ // can't tell size of InStream, but MAX_VALUE should be safe
+ return read(is, Integer.MAX_VALUE);
+ }
+
+ private S2Geography read(InStream is, int maxCoordNum) throws IOException, ParseException {
+ /**
+ * This puts an upper bound on the allowed value in coordNum fields. It avoids OOM exceptions
+ * due to malformed input.
+ */
+ this.maxNumFieldValue = maxCoordNum;
+ dis.setInStream(is);
+ return readGeometry(0);
+ }
+
+ private int readNumField(String fieldName) throws IOException, ParseException {
+ // num field is unsigned int, but Java has only signed int
+ int num = dis.readInt();
+ if (num < 0 || num > maxNumFieldValue) {
+ throw new ParseException(fieldName + " value is too large");
+ }
+ return num;
+ }
+
+ private S2Geography readGeometry(int SRID) throws IOException, ParseException {
+
+ // determine byte order
+ byte byteOrderWKB = dis.readByte();
+
+ // always set byte order, since it may change from geometry to geometry
+ if (byteOrderWKB == WKBConstants.wkbNDR) {
+ dis.setOrder(ByteOrderValues.LITTLE_ENDIAN);
+ } else if (byteOrderWKB == WKBConstants.wkbXDR) {
+ dis.setOrder(ByteOrderValues.BIG_ENDIAN);
+ } else if (isStrict) {
+ throw new ParseException("Unknown geometry byte order (not NDR or XDR): " + byteOrderWKB);
+ }
+ // if not strict and not XDR or NDR, then we just use the dis default set at the
+ // start of the geometry (if a multi-geometry). This allows WBKReader to work
+ // with Spatialite native BLOB WKB, as well as other WKB variants that might just
+ // specify endian-ness at the start of the multigeometry.
+
+ int typeInt = dis.readInt();
+
+ /**
+ * To get geometry type mask out EWKB flag bits, and use only low 3 digits of type word. This
+ * supports both EWKB and ISO/OGC.
+ */
+ int geometryType = (typeInt & 0xffff) % 1000;
+
+ // handle 3D and 4D WKB geometries
+ // geometries with Z coordinates have the 0x80 flag (postgis EWKB)
+ // or are in the 1000 range (Z) or in the 3000 range (ZM) of geometry type (ISO/OGC 06-103r4)
+ boolean hasZ =
+ ((typeInt & 0x80000000) != 0
+ || (typeInt & 0xffff) / 1000 == 1
+ || (typeInt & 0xffff) / 1000 == 3);
+ // geometries with M coordinates have the 0x40 flag (postgis EWKB)
+ // or are in the 1000 range (M) or in the 3000 range (ZM) of geometry type (ISO/OGC 06-103r4)
+ boolean hasM =
+ ((typeInt & 0x40000000) != 0
+ || (typeInt & 0xffff) / 1000 == 2
+ || (typeInt & 0xffff) / 1000 == 3);
+ // System.out.println(typeInt + " - " + geometryType + " - hasZ:" + hasZ);
+ inputDimension = 2 + (hasZ ? 1 : 0) + (hasM ? 1 : 0);
+
+ EnumSet<Ordinate> ordinateFlags = EnumSet.of(Ordinate.X, Ordinate.Y);
+ if (hasZ) {
+ ordinateFlags.add(Ordinate.Z);
+ }
+ if (hasM) {
+ ordinateFlags.add(Ordinate.M);
+ }
+
+ // determine if SRIDs are present (EWKB only)
+ boolean hasSRID = (typeInt & 0x20000000) != 0;
+ if (hasSRID) {
+ SRID = dis.readInt();
+ }
+
+ // only allocate ordValues buffer if necessary
+ if (ordValues == null || ordValues.length < inputDimension)
+ ordValues = new double[inputDimension];
+
+ S2Geography geog = null;
+ switch (geometryType) {
+ case WKBConstants.wkbPoint:
+ geog = readPoint(ordinateFlags);
+ break;
+ case WKBConstants.wkbLineString:
+ geog = readPolyline(ordinateFlags);
+ break;
+ case WKBConstants.wkbPolygon:
+ geog = readPolygon(ordinateFlags);
+ break;
+ case WKBConstants.wkbMultiPoint:
+ geog = readMultiPoint(SRID);
+ break;
+ case WKBConstants.wkbMultiLineString:
+ geog = readMultiPolyline(SRID);
+ break;
+ case WKBConstants.wkbMultiPolygon:
+ geog = readMultiPolygon(SRID);
+ break;
+ case WKBConstants.wkbGeometryCollection:
+ geog = readGeographyCollection(SRID);
+ break;
+ default:
+ throw new ParseException("Unknown WKB type " + geometryType);
+ }
+ setSRID(geog, SRID);
+ return geog;
+ }
+
+ /**
+ * Sets the SRID, if it was specified in the WKB
+ *
+ * @param g the geometry to update
+ * @return the geometry with an updated SRID value, if required
+ */
+ private S2Geography setSRID(S2Geography g, int SRID) {
+ if (SRID != 0) g.setSRID(SRID);
+ return g;
+ }
+
+ private SinglePointGeography readPoint(EnumSet<Ordinate> ordinateFlags)
+ throws IOException, ParseException {
+ CoordinateSequence pts = readCoordinateSequence(1, ordinateFlags);
+ // If X and Y are NaN create a empty point
+ if (pts.size() <= 0 || Double.isNaN(pts.getX(0)) || Double.isNaN(pts.getY(0))) {
+ return new SinglePointGeography();
+ }
+ double lon = pts.getX(0);
+ double lat = pts.getY(0);
+ S2Point s2Point = S2LatLng.fromDegrees(lat, lon).toPoint();
+
+ // Build via S2Builder + S2PointVectorLayer
+ S2Builder builder = new S2Builder.Builder().build();
+ S2PointVectorLayer layer = new S2PointVectorLayer();
+ builder.startLayer(layer);
+ builder.addPoint(s2Point);
+
+ // must call build() before reading out the points
+ S2Error error = new S2Error();
+ if (!builder.build(error)) {
+ throw new IOException("Failed to build S2 point layer: " + error.text());
+ }
+ // Extract the resulting points
+ List<S2Point> points = layer.getPointVector();
+ if (points.isEmpty()) {
+ return new SinglePointGeography();
+ }
+ return new SinglePointGeography(points.get(0));
+ }
+
+ private SinglePolylineGeography readPolyline(EnumSet<Ordinate> ordinateFlags)
+ throws IOException, ParseException {
+ int size = readNumField(FIELD_NUMCOORDS);
+ CoordinateSequence seq = readCoordinateSequenceLineString(size, ordinateFlags);
+ if (seq.size() < 2) {
+ // empty or extended-but-all-NaN → empty geography
+ return new SinglePolylineGeography();
+ }
+
+ List<S2Point> pts = new ArrayList<>(seq.size());
+ for (int i = 0; i < seq.size(); i++) {
+ double lon = seq.getX(i);
+ double lat = seq.getY(i);
+ pts.add(S2LatLng.fromDegrees(lat, lon).toPoint());
+ }
+
+ S2Builder builder = new S2Builder.Builder().build();
+ S2PolylineLayer layer = new S2PolylineLayer();
+ builder.startLayer(layer);
+
+ builder.addPolyline(new S2Polyline(pts));
+
+ S2Error error = new S2Error();
+ if (!builder.build(error)) {
+ throw new IOException("Failed to build S2 polyline: " + error.text());
+ }
+ S2Polyline s2poly = layer.getPolyline();
+ return new SinglePolylineGeography(s2poly);
+ }
+
+ private PolygonGeography readPolygon(EnumSet<Ordinate> ordinateFlags)
+ throws IOException, ParseException {
+ int numRings = readNumField(FIELD_NUMRINGS);
+ if (numRings <= 0) {
+ return new PolygonGeography();
+ }
+
+ List<S2Loop> loops = new ArrayList<>(numRings);
+ for (int r = 0; r < numRings; r++) {
+ int coordCount = readNumField(FIELD_NUMCOORDS);
+ CoordinateSequence seq = readCoordinateSequenceRing(coordCount, ordinateFlags);
+ // build the loop
+ List<S2Point> pts = new ArrayList<>(seq.size());
+ for (int i = 0; i < seq.size(); i++) {
+ pts.add(S2LatLng.fromDegrees(seq.getY(i), seq.getX(i)).toPoint());
+ }
+ loops.add(new S2Loop(pts));
+ }
+ if (loops.isEmpty()) {
+ return new PolygonGeography();
+ }
+
+ // Now feed those loops into S2Builder + S2PolygonLayer:
+ S2Builder builder = new S2Builder.Builder().build();
+ S2PolygonLayer polyLayer = new S2PolygonLayer();
+ builder.startLayer(polyLayer);
+
+ // add shell + holes
+ for (S2Loop loop : loops) {
+ builder.addLoop(loop);
+ }
+
+ // build
+ S2Error error = new S2Error();
+ if (!builder.build(error)) {
+ throw new IOException("S2Builder failed: " + error.text());
+ }
+
+ // extract the stitched polygon
+ S2Polygon s2poly = polyLayer.getPolygon();
+
+ // wrap in your PolygonGeography
+ return new PolygonGeography(s2poly);
+ }
+
+ private PointGeography readMultiPoint(int SRID) throws IOException, ParseException {
+ int numGeom = readNumField(FIELD_NUMELEMS);
+ List<S2Point> pts = new ArrayList<>(numGeom);
+ for (int i = 0; i < numGeom; i++) {
+ S2Geography point = readGeometry(SRID);
+ if (!(point instanceof PointGeography)) {
+ throw new ParseException(INVALID_GEOM_TYPE_MSG + "MultiPoint");
+ }
+ pts.addAll(((PointGeography) point).getPoints());
+ }
+ return new PointGeography(pts);
+ }
+
+ private PolylineGeography readMultiPolyline(int SRID) throws IOException, ParseException {
+ int numGeom = readNumField(FIELD_NUMELEMS);
+ List<S2Polyline> polylines = new ArrayList<>(numGeom);
+ for (int i = 0; i < numGeom; i++) {
+ S2Geography polyline = readGeometry(SRID);
+ if (!(polyline instanceof PolylineGeography)) {
+ throw new ParseException(INVALID_GEOM_TYPE_MSG + "MultiPolyline");
+ }
+ polylines.addAll(((PolylineGeography) polyline).getPolylines());
+ }
+ return new PolylineGeography(polylines);
+ }
+
+ private MultiPolygonGeography readMultiPolygon(int SRID) throws IOException, ParseException {
+ int numGeom = readNumField(FIELD_NUMELEMS);
+ List<S2Polygon> polygons = new ArrayList<>(numGeom);
+ for (int i = 0; i < numGeom; i++) {
+ S2Geography geom = readGeometry(SRID);
+ polygons.add(((PolygonGeography) geom).polygon);
+ }
+ return new MultiPolygonGeography(polygons);
+ }
+
+ private GeographyCollection readGeographyCollection(int SRID) throws IOException, ParseException {
+ int numGeom = readNumField(FIELD_NUMELEMS);
+ S2Geography[] geoms = new S2Geography[numGeom];
+ for (int i = 0; i < numGeom; i++) {
+ geoms[i] = readGeometry(SRID);
+ }
+ return new GeographyCollection(List.of(geoms));
+ }
+
+ private CoordinateSequence readCoordinateSequence(int size, EnumSet<Ordinate> ordinateFlags)
+ throws IOException, ParseException {
+ CoordinateSequence seq =
+ csFactory.create(size, inputDimension, ordinateFlags.contains(Ordinate.M) ? 1 : 0);
+ int targetDim = seq.getDimension();
+ if (targetDim > inputDimension) targetDim = inputDimension;
+ for (int i = 0; i < size; i++) {
+ readCoordinate();
+ for (int j = 0; j < targetDim; j++) {
+ seq.setOrdinate(i, j, ordValues[j]);
+ }
+ }
+ return seq;
+ }
+
+ private CoordinateSequence readCoordinateSequenceLineString(
+ int size, EnumSet<Ordinate> ordinateFlags) throws IOException, ParseException {
+ CoordinateSequence seq = readCoordinateSequence(size, ordinateFlags);
+ if (isStrict) return seq;
+ if (seq.size() == 0 || seq.size() >= 2) return seq;
+ return CoordinateSequences.extend(csFactory, seq, 2);
+ }
+
+ private CoordinateSequence readCoordinateSequenceRing(int size, EnumSet<Ordinate> ordinateFlags)
+ throws IOException, ParseException {
+ CoordinateSequence seq = readCoordinateSequence(size, ordinateFlags);
+ if (isStrict) return seq;
+ if (CoordinateSequences.isRing(seq)) return seq;
+ return CoordinateSequences.ensureValidRing(csFactory, seq);
+ }
+
+ /**
+ * Reads a coordinate value with the specified dimensionality. Makes the X and Y ordinates precise
+ * according to the precision model in use.
+ *
+ * @throws ParseException
+ */
+ private void readCoordinate() throws IOException, ParseException {
+ for (int i = 0; i < inputDimension; i++) {
+ if (i <= 1) {
+ try {
+ double v = dis.readDouble();
+ ordValues[i] = precisionModel.makePrecise(v);
+ } catch (ParseException pe) {
+ return;
+ }
+ } else {
+ ordValues[i] = dis.readDouble();
+ }
+ }
+ }
+}
diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/WKBWriter.java b/common/src/main/java/org/apache/sedona/common/S2Geography/WKBWriter.java
new file mode 100644
index 0000000000..bc9853e795
--- /dev/null
+++ b/common/src/main/java/org/apache/sedona/common/S2Geography/WKBWriter.java
@@ -0,0 +1,461 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sedona.common.S2Geography;
+
+import com.google.common.geometry.*;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.EnumSet;
+import java.util.List;
+import org.locationtech.jts.geom.*;
+import org.locationtech.jts.io.*;
+
+public class WKBWriter {
+ /** A custom modified version based on JTS WKBWriter */
+ /**
+ * Converts a byte array to a hexadecimal string.
+ *
+ * @param bytes
+ * @return a string of hexadecimal digits
+ * @deprecated
+ */
+ public static String bytesToHex(byte[] bytes) {
+ return toHex(bytes);
+ }
+
+ /**
+ * Converts a byte array to a hexadecimal string.
+ *
+ * @param bytes a byte array
+ * @return a string of hexadecimal digits
+ */
+ public static String toHex(byte[] bytes) {
+ StringBuffer buf = new StringBuffer();
+ for (int i = 0; i < bytes.length; i++) {
+ byte b = bytes[i];
+ buf.append(toHexDigit((b >> 4) & 0x0F));
+ buf.append(toHexDigit(b & 0x0F));
+ }
+ return buf.toString();
+ }
+
+ private static char toHexDigit(int n) {
+ if (n < 0 || n > 15) throw new IllegalArgumentException("Nibble value out of range: " + n);
+ if (n <= 9) return (char) ('0' + n);
+ return (char) ('A' + (n - 10));
+ }
+
+ private EnumSet<Ordinate> outputOrdinates;
+ private int outputDimension = 2;
+ private int byteOrder;
+ private boolean includeSRID = false;
+ private ByteArrayOutputStream byteArrayOS = new ByteArrayOutputStream();
+ private OutStream byteArrayOutStream = new OutputStreamOutStream(byteArrayOS);
+ // holds output data values
+ private byte[] buf = new byte[8];
+
+ /**
+ * Creates a writer that writes {@link Geometry}s with output dimension = 2 and BIG_ENDIAN byte
+ * order
+ */
+ public WKBWriter() {
+ this(2, ByteOrderValues.BIG_ENDIAN);
+ }
+
+ /**
+ * Creates a writer that writes {@link Geometry}s with the given dimension (2 or 3) for output
+ * coordinates and {@link ByteOrderValues#BIG_ENDIAN} byte order. If the input geometry has a
+ * small coordinate dimension, coordinates will be padded with {@link Coordinate#NULL_ORDINATE}.
+ *
+ * @param outputDimension the coordinate dimension to output (2 or 3)
+ */
+ public WKBWriter(int outputDimension) {
+ this(outputDimension, ByteOrderValues.BIG_ENDIAN);
+ }
+
+ public WKBWriter(int outputDimension, boolean includeSRID) {
+ this(outputDimension, ByteOrderValues.BIG_ENDIAN, includeSRID);
+ }
+
+ public WKBWriter(int outputDimension, int byteOrder) {
+ this(outputDimension, byteOrder, false);
+ }
+
+ public WKBWriter(int outputDimension, int byteOrder, boolean includeSRID) {
+ this.outputDimension = outputDimension;
+ this.byteOrder = byteOrder;
+ this.includeSRID = includeSRID;
+
+ if (outputDimension < 2 || outputDimension > 4)
+ throw new IllegalArgumentException("Output dimension must be 2 to 4");
+
+ this.outputOrdinates = EnumSet.of(Ordinate.X, Ordinate.Y);
+ if (outputDimension > 2) outputOrdinates.add(Ordinate.Z);
+ if (outputDimension > 3) outputOrdinates.add(Ordinate.M);
+ }
+
+ /**
+ * Sets the {@link Ordinate} that are to be written. Possible members are:
+ *
+ * <ul>
+ * <li>{@link Ordinate#X}
+ * <li>{@link Ordinate#Y}
+ * <li>{@link Ordinate#Z}
+ * <li>{@link Ordinate#M}
+ * </ul>
+ *
+ * Values of {@link Ordinate#X} and {@link Ordinate#Y} are always assumed and not particularly
+ * checked for.
+ *
+ * @param outputOrdinates A set of {@link Ordinate} values
+ */
+ public void setOutputOrdinates(EnumSet<Ordinate> outputOrdinates) {
+
+ this.outputOrdinates.remove(Ordinate.Z);
+ this.outputOrdinates.remove(Ordinate.M);
+
+ if (this.outputDimension == 3) {
+ if (outputOrdinates.contains(Ordinate.Z)) this.outputOrdinates.add(Ordinate.Z);
+ else if (outputOrdinates.contains(Ordinate.M)) this.outputOrdinates.add(Ordinate.M);
+ }
+ if (this.outputDimension == 4) {
+ if (outputOrdinates.contains(Ordinate.Z)) this.outputOrdinates.add(Ordinate.Z);
+ if (outputOrdinates.contains(Ordinate.M)) this.outputOrdinates.add(Ordinate.M);
+ }
+ }
+
+ /**
+ * Gets a bit-pattern defining which ordinates should be
+ *
+ * @return an ordinate bit-pattern
+ * @see #setOutputOrdinates(EnumSet)
+ */
+ public EnumSet<Ordinate> getOutputOrdinates() {
+ return this.outputOrdinates;
+ }
+
+ /**
+ * Writes a {@link S2Geography} into a byte array.
+ *
+ * @param geog the geometry to write
+ * @return the byte array containing the WKB
+ */
+ public byte[] write(S2Geography geog) {
+ try {
+ byteArrayOS.reset();
+ write(geog, byteArrayOutStream);
+ } catch (IOException ex) {
+ throw new RuntimeException("Unexpected IO exception: " + ex.getMessage());
+ }
+ return byteArrayOS.toByteArray();
+ }
+
+ /**
+ * Writes a {@link S2Geography} to an {@link OutStream}.
+ *
+ * @param geog the geometry to write
+ * @param os the out stream to write to
+ * @throws IOException if an I/O error occurs
+ */
+ public void write(S2Geography geog, OutStream os) throws IOException {
+ if (geog instanceof SinglePointGeography) {
+ writePoint(WKBConstants.wkbPoint, (SinglePointGeography) geog, os);
+ } else if (geog instanceof PointGeography) {
+ writeMultiPoint(WKBConstants.wkbMultiPoint, (PointGeography) geog, os);
+ } else if (geog instanceof SinglePolylineGeography) {
+ writePolyline(WKBConstants.wkbLineString, (SinglePolylineGeography) geog, os);
+ } else if (geog instanceof PolylineGeography) {
+ writeMultiPolyline(WKBConstants.wkbMultiLineString, (PolylineGeography) geog, os);
+ } else if (geog instanceof PolygonGeography) {
+ writePolygon(WKBConstants.wkbPolygon, (PolygonGeography) geog, os);
+ } else if (geog instanceof MultiPolygonGeography) {
+ writeMultiPolygon(WKBConstants.wkbMultiPolygon, (MultiPolygonGeography) geog, os);
+ } else if (geog instanceof GeographyCollection) {
+ writeGeographyCollection(WKBConstants.wkbGeometryCollection, (GeographyCollection) geog, os);
+ }
+ }
+
+ private void writePoint(int geometryType, SinglePointGeography pt, OutStream os)
+ throws IOException {
+ writeByteOrder(os);
+ writeGeometryType(geometryType, pt, os);
+ if (pt.numShapes() == 0) {
+ writeNaNs(outputDimension, os);
+ } else {
+ S2Point p = pt.getPoints().get(0);
+ S2LatLng ll = new S2LatLng(p);
+
+ double lon = ll.lngDegrees();
+ double lat = ll.latDegrees();
+
+ ByteOrderValues.putDouble(lon, buf, byteOrder);
+ os.write(buf, 8);
+ ByteOrderValues.putDouble(lat, buf, byteOrder);
+ os.write(buf, 8);
+ }
+ }
+
+ private void writeMultiPoint(int geometryType, PointGeography mp, OutStream os)
+ throws IOException {
+ // 1) write the outer MultiPoint header
+ writeByteOrder(os);
+ writeGeometryType(WKBConstants.wkbMultiPoint, mp, os);
+ writeInt(mp.numShapes(), os); // count = number of points
+
+ // 2) temporarily disable SRID on the nested shapes
+ boolean oldIncludeSRID = this.includeSRID;
+ this.includeSRID = false;
+
+ // 3) for each point, write a proper Point WKB
+ for (int i = 0; i < mp.numShapes(); i++) {
+ S2Point p = mp.getPoints().get(i);
+ S2LatLng ll = new S2LatLng(p);
+ double lon = ll.lngDegrees();
+ double lat = ll.latDegrees();
+
+ // nested Point header
+ writeByteOrder(os);
+ writeGeometryType(WKBConstants.wkbPoint, mp, os);
+
+ // coords
+ ByteOrderValues.putDouble(lon, buf, byteOrder);
+ os.write(buf, 8);
+ ByteOrderValues.putDouble(lat, buf, byteOrder);
+ os.write(buf, 8);
+ }
+
+ // 4) restore SRID flag
+ this.includeSRID = oldIncludeSRID;
+ }
+
+ private void writePolyline(int geometryType, SinglePolylineGeography polyline, OutStream os)
+ throws IOException {
+ writeByteOrder(os);
+ writeGeometryType(geometryType, polyline, os);
+ List<S2Polyline> s2line = polyline.getPolylines();
+ for (S2Polyline s2 : s2line) {
+ List<S2Point> verts = s2.vertices();
+ writeInt(verts.size(), os);
+ for (S2Point p : verts) {
+ // round to 6 decimal places
+ S2LatLng ll = new S2LatLng(p);
+ double lon = ll.lngDegrees();
+ double lat = ll.latDegrees();
+
+ ByteOrderValues.putDouble(lon, buf, byteOrder);
+ os.write(buf, 8);
+ ByteOrderValues.putDouble(lat, buf, byteOrder);
+ os.write(buf, 8);
+ }
+ }
+ }
+
+ private void writeMultiPolyline(int geometryType, PolylineGeography polyline, OutStream os)
+ throws IOException {
+ // 1) Outer MultiLineString header
+ writeByteOrder(os);
+ writeGeometryType(WKBConstants.wkbMultiLineString, polyline, os);
+
+ List<S2Polyline> lines = polyline.getPolylines();
+ // 2) Write the number of LineStrings
+ writeInt(lines.size(), os);
+
+ // Temporarily disable SRID on nested shapes
+ boolean oldIncludeSRID = this.includeSRID;
+ this.includeSRID = false;
+
+ // 3) For each LineString:
+ for (S2Polyline s2line : lines) {
+ List<S2Point> verts = s2line.vertices();
+
+ // 3a) Nested LineString header
+ writeByteOrder(os);
+ writeGeometryType(WKBConstants.wkbLineString, polyline, os);
+
+ // 3b) Vertex count
+ writeInt(verts.size(), os);
+
+ // 3c) Coordinates
+ for (S2Point p : verts) {
+ S2LatLng ll = new S2LatLng(p);
+ double lon = ll.lngDegrees();
+ double lat = ll.latDegrees();
+
+ ByteOrderValues.putDouble(lon, buf, byteOrder);
+ os.write(buf, 8);
+ ByteOrderValues.putDouble(lat, buf, byteOrder);
+ os.write(buf, 8);
+ }
+ }
+
+ // 4) Restore SRID flag
+ this.includeSRID = oldIncludeSRID;
+ }
+
+ private void writePolygon(int geometryType, PolygonGeography poly, OutStream os)
+ throws IOException {
+ writeByteOrder(os);
+ writeGeometryType(geometryType, poly, os);
+ S2Polygon s2poly = poly.polygon;
+ List<S2Loop> loops = s2poly.getLoops();
+ writeInt(loops.size(), os);
+ for (S2Loop loop : loops) {
+ int n = loop.numVertices();
+ writeInt(n, os);
+ for (int i = 0; i < n; i++) {
+ S2LatLng ll = new S2LatLng(loop.vertex(i));
+ double lon = ll.lngDegrees();
+ double lat = ll.latDegrees();
+
+ ByteOrderValues.putDouble(lon, buf, byteOrder);
+ os.write(buf, 8);
+ ByteOrderValues.putDouble(lat, buf, byteOrder);
+ os.write(buf, 8);
+ }
+ }
+ }
+
+ private void writeMultiPolygon(int geometryType, MultiPolygonGeography multiPoly, OutStream os)
+ throws IOException {
+ // 1) Outer MultiPolygon header
+ writeByteOrder(os);
+ writeGeometryType(WKBConstants.wkbMultiPolygon, multiPoly, os);
+
+ // 2) Number of polygons
+ List<S2Geography> polys = multiPoly.getFeatures(); // however you expose each sub-polygon
+ writeInt(polys.size(), os);
+
+ // 3) Disable SRID on nested shapes if you include SRID in the outer header
+ boolean oldIncludeSRID = this.includeSRID;
+ this.includeSRID = false;
+
+ // 4) For each polygon, write a full Polygon WKB
+ for (S2Geography pg : polys) {
+ // 4a) Nested Polygon header
+ writeByteOrder(os);
+ writeGeometryType(WKBConstants.wkbPolygon, pg, os);
+
+ // 4b) Number of rings
+ PolygonGeography s2poly = (PolygonGeography) pg;
+ List<S2Loop> loops = s2poly.polygon.getLoops();
+ writeInt(loops.size(), os);
+
+ // 4c) For each ring, write vertex count + coords
+ for (S2Loop loop : loops) {
+ int n = loop.numVertices();
+ writeInt(n, os);
+ for (int i = 0; i < n; i++) {
+ S2LatLng ll = new S2LatLng(loop.vertex(i));
+ double lon = ll.lngDegrees();
+ double lat = ll.latDegrees();
+
+ // X (lon) then Y (lat)—matches Point/LineString ordering
+ ByteOrderValues.putDouble(lon, buf, byteOrder);
+ os.write(buf, 8);
+ ByteOrderValues.putDouble(lat, buf, byteOrder);
+ os.write(buf, 8);
+ }
+ }
+ }
+
+ // 5) Restore SRID flag
+ this.includeSRID = oldIncludeSRID;
+ }
+
+ private void writeGeographyCollection(int geometryType, GeographyCollection gc, OutStream os)
+ throws IOException {
+ writeByteOrder(os);
+ writeGeometryType(geometryType, gc, os);
+ writeInt(gc.numShapes(), os);
+ boolean originalIncludeSRID = this.includeSRID;
+ this.includeSRID = false;
+ for (int i = 0; i < gc.numShapes(); i++) {
+ write(gc.getFeatures().get(i), os);
+ }
+ this.includeSRID = originalIncludeSRID;
+ }
+
+ private void writeByteOrder(OutStream os) throws IOException {
+ if (byteOrder == ByteOrderValues.LITTLE_ENDIAN) buf[0] = WKBConstants.wkbNDR;
+ else buf[0] = WKBConstants.wkbXDR;
+ os.write(buf, 1);
+ }
+
+ private void writeGeometryType(int geometryType, S2Geography g, OutStream os) throws IOException {
+ int ordinals = 0;
+ if (outputOrdinates.contains(Ordinate.Z)) {
+ ordinals = ordinals | 0x80000000;
+ }
+
+ if (outputOrdinates.contains(Ordinate.M)) {
+ ordinals = ordinals | 0x40000000;
+ }
+
+ int flag3D = (outputDimension > 2) ? ordinals : 0;
+ int typeInt = geometryType | flag3D;
+ typeInt |= includeSRID ? 0x20000000 : 0;
+ writeInt(typeInt, os);
+ if (includeSRID) {
+ writeInt(g.getSRID(), os);
+ }
+ }
+
+ private void writeInt(int intValue, OutStream os) throws IOException {
+ ByteOrderValues.putInt(intValue, buf, byteOrder);
+ os.write(buf, 4);
+ }
+
+ private void writeCoordinateSequence(CoordinateSequence seq, boolean writeSize, OutStream os)
+ throws IOException {
+ if (writeSize) writeInt(seq.size(), os);
+
+ for (int i = 0; i < seq.size(); i++) {
+ writeCoordinate(seq, i, os);
+ }
+ }
+
+ private void writeCoordinate(CoordinateSequence seq, int index, OutStream os) throws IOException {
+ ByteOrderValues.putDouble(seq.getX(index), buf, byteOrder);
+ os.write(buf, 8);
+ ByteOrderValues.putDouble(seq.getY(index), buf, byteOrder);
+ os.write(buf, 8);
+
+ // only write 3rd dim if caller has requested it for this writer
+ if (outputDimension >= 3) {
+ // if 3rd dim is requested, only write it if the CoordinateSequence provides it
+ double ordVal = seq.getOrdinate(index, 2);
+ ByteOrderValues.putDouble(ordVal, buf, byteOrder);
+ os.write(buf, 8);
+ }
+ // only write 4th dim if caller has requested it for this writer
+ if (outputDimension == 4) {
+ // if 4th dim is requested, only write it if the CoordinateSequence provides it
+ double ordVal = seq.getOrdinate(index, 3);
+ ByteOrderValues.putDouble(ordVal, buf, byteOrder);
+ os.write(buf, 8);
+ }
+ }
+
+ private void writeNaNs(int numNaNs, OutStream os) throws IOException {
+ for (int i = 0; i < numNaNs; i++) {
+ ByteOrderValues.putDouble(Double.NaN, buf, byteOrder);
+ os.write(buf, 8);
+ }
+ }
+}
diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/WKTReader.java b/common/src/main/java/org/apache/sedona/common/S2Geography/WKTReader.java
new file mode 100644
index 0000000000..dc16092d44
--- /dev/null
+++ b/common/src/main/java/org/apache/sedona/common/S2Geography/WKTReader.java
@@ -0,0 +1,929 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sedona.common.S2Geography;
+
+import com.google.common.geometry.*;
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StreamTokenizer;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Locale;
+import org.locationtech.jts.geom.*;
+import org.locationtech.jts.geom.impl.CoordinateArraySequenceFactory;
+import org.locationtech.jts.io.Ordinate;
+import org.locationtech.jts.io.ParseException;
+import org.locationtech.jts.io.WKTConstants;
+import org.locationtech.jts.util.Assert;
+
+public class WKTReader {
+ private static final String COMMA = ",";
+ private static final String L_PAREN = "(";
+ private static final String R_PAREN = ")";
+ private static final String NAN_SYMBOL = "NaN";
+
+ private GeometryFactory geometryFactory;
+ private CoordinateSequenceFactory csFactory;
+ private static CoordinateSequenceFactory csFactoryXYZM =
+ CoordinateArraySequenceFactory.instance();
+ private PrecisionModel precisionModel;
+
+ /** Flag indicating that the old notation of coordinates in JTS is supported. */
+ private static final boolean ALLOW_OLD_JTS_COORDINATE_SYNTAX = true;
+
+ private boolean isAllowOldJtsCoordinateSyntax = ALLOW_OLD_JTS_COORDINATE_SYNTAX;
+
+ /** Flag indicating that the old notation of MultiPoint coordinates in JTS is supported. */
+ private static final boolean ALLOW_OLD_JTS_MULTIPOINT_SYNTAX = true;
+
+ private boolean isAllowOldJtsMultipointSyntax = ALLOW_OLD_JTS_MULTIPOINT_SYNTAX;
+
+ private boolean isFixStructure = false;
+
+ public WKTReader() {
+ this.geometryFactory = new GeometryFactory();
+ this.csFactory = geometryFactory.getCoordinateSequenceFactory();
+ this.precisionModel = geometryFactory.getPrecisionModel();
+ }
+
+ /**
+ * Sets a flag indicating, that coordinates may have 3 ordinate values even though no Z or M
+ * ordinate indicator is present. The default value is {@link #ALLOW_OLD_JTS_COORDINATE_SYNTAX}.
+ *
+ * @param value a boolean value
+ */
+ public void setIsOldJtsCoordinateSyntaxAllowed(boolean value) {
+ isAllowOldJtsCoordinateSyntax = value;
+ }
+
+ /**
+ * Sets a flag indicating, that point coordinates in a MultiPoint geometry must not be enclosed in
+ * paren. The default value is {@link #ALLOW_OLD_JTS_MULTIPOINT_SYNTAX}
+ *
+ * @param value a boolean value
+ */
+ public void setIsOldJtsMultiPointSyntaxAllowed(boolean value) {
+ isAllowOldJtsMultipointSyntax = value;
+ }
+
+ /**
+ * Sets a flag indicating that the structure of input geometry should be fixed so that the
+ * geometry can be constructed without error. This involves adding coordinates if the input
+ * coordinate sequence is shorter than required.
+ *
+ * @param isFixStructure true if the input structure should be fixed
+ * @see LinearRing#MINIMUM_VALID_SIZE
+ */
+ public void setFixStructure(boolean isFixStructure) {
+ this.isFixStructure = isFixStructure;
+ }
+
+ /**
+ * Reads a Well-Known Text representation of a {@link Geometry} from a {@link String}.
+ *
+ * @param wellKnownText one or more <Geometry Tagged Text> strings (see the OpenGIS Simple
+ * Features Specification) separated by whitespace
+ * @return a <code>Geometry</code> specified by <code>wellKnownText</code>
+ * @throws ParseException if a parsing problem occurs
+ */
+ public S2Geography read(String wellKnownText) throws ParseException {
+ StringReader reader = new StringReader(wellKnownText);
+ try {
+ return read(reader);
+ } finally {
+ reader.close();
+ }
+ }
+
+ /**
+ * Reads a Well-Known Text representation of a {@link S2Geography} from a {@link Reader}.
+ *
+ * @param reader a Reader which will return a <Geometry Tagged Text> string (see the OpenGIS
+ * Simple Features Specification)
+ * @return a <code>Geometry</code> read from <code>reader</code>
+ * @throws ParseException if a parsing problem occurs
+ */
+ public S2Geography read(Reader reader) throws ParseException {
+ StreamTokenizer tokenizer = createTokenizer(reader);
+ try {
+ return readGeometryTaggedText(tokenizer);
+ } catch (IOException e) {
+ throw new ParseException(e.toString());
+ }
+ }
+
+ /**
+ * Utility function to create the tokenizer
+ *
+ * @param reader a reader
+ * @return a WKT Tokenizer.
+ */
+ private static StreamTokenizer createTokenizer(Reader reader) {
+ StreamTokenizer tokenizer = new StreamTokenizer(reader);
+ // set tokenizer to NOT parse numbers
+ 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(0, ' ');
+ tokenizer.commentChar('#');
+
+ return tokenizer;
+ }
+
+ /**
+ * Reads a <code>Coordinate</Code> from a stream using the given {@link StreamTokenizer}.
+ *
+ * <p>All ordinate values are read, but -depending on the {@link CoordinateSequenceFactory} of the
+ * underlying {@link GeometryFactory}- not necessarily all can be handled. Those are silently
+ * dropped.
+ *
+ * @param tokenizer the tokenizer to use
+ * @param ordinateFlags a bit-mask defining the ordinates to read.
+ * @param tryParen a value indicating if a starting {@link #L_PAREN} should be probed.
+ * @return a {@link Coordinate} of appropriate dimension containing the read ordinate values
+ * @throws IOException if an I/O error occurs
+ * @throws ParseException if an unexpected token was encountered
+ */
+ private Coordinate getCoordinate(
+ StreamTokenizer tokenizer, EnumSet<Ordinate> ordinateFlags, boolean tryParen)
+ throws IOException, ParseException {
+ boolean opened = false;
+ if (tryParen && isOpenerNext(tokenizer)) {
+ tokenizer.nextToken();
+ opened = true;
+ }
+
+ // create a sequence for one coordinate
+ int offsetM = ordinateFlags.contains(Ordinate.Z) ? 1 : 0;
+ Coordinate coord = createCoordinate(ordinateFlags);
+ coord.setOrdinate(CoordinateSequence.X, precisionModel.makePrecise(getNextNumber(tokenizer)));
+ coord.setOrdinate(CoordinateSequence.Y, precisionModel.makePrecise(getNextNumber(tokenizer)));
+
+ // additionally read other vertices
+ if (ordinateFlags.contains(Ordinate.Z))
+ coord.setOrdinate(CoordinateSequence.Z, getNextNumber(tokenizer));
+ if (ordinateFlags.contains(Ordinate.M))
+ coord.setOrdinate(CoordinateSequence.Z + offsetM, getNextNumber(tokenizer));
+
+ if (ordinateFlags.size() == 2
+ && this.isAllowOldJtsCoordinateSyntax
+ && isNumberNext(tokenizer)) {
+ coord.setOrdinate(CoordinateSequence.Z, getNextNumber(tokenizer));
+ }
+
+ // read close token if it was opened here
+ if (opened) {
+ getNextCloser(tokenizer);
+ }
+
+ return coord;
+ }
+
+ private Coordinate createCoordinate(EnumSet<Ordinate> ordinateFlags) {
+ boolean hasZ = ordinateFlags.contains(Ordinate.Z);
+ boolean hasM = ordinateFlags.contains(Ordinate.M);
+ if (hasZ && hasM) return new CoordinateXYZM();
+ if (hasM) return new CoordinateXYM();
+ if (hasZ || this.isAllowOldJtsCoordinateSyntax) return new Coordinate();
+ return new CoordinateXY();
+ }
+
+ /**
+ * Reads a <code>Coordinate</Code> from a stream using the given {@link StreamTokenizer}.
+ *
+ * <p>All ordinate values are read, but -depending on the {@link CoordinateSequenceFactory} of the
+ * underlying {@link GeometryFactory}- not necessarily all can be handled. Those are silently
+ * dropped.
+ *
+ * <p>
+ *
+ * @param tokenizer the tokenizer to use
+ * @param ordinateFlags a bit-mask defining the ordinates to read.
+ * @return a {@link CoordinateSequence} of length 1 containing the read ordinate values
+ * @throws IOException if an I/O error occurs
+ * @throws ParseException if an unexpected token was encountered
+ */
+ private CoordinateSequence getCoordinateSequence(
+ StreamTokenizer tokenizer, EnumSet<Ordinate> ordinateFlags, int minSize, boolean isRing)
+ throws IOException, ParseException {
+ if (getNextEmptyOrOpener(tokenizer).equals(WKTConstants.EMPTY))
+ return createCoordinateSequenceEmpty(ordinateFlags);
+
+ List<Coordinate> coordinates = new ArrayList<Coordinate>();
+ do {
+ coordinates.add(getCoordinate(tokenizer, ordinateFlags, false));
+ } while (getNextCloserOrComma(tokenizer).equals(COMMA));
+
+ if (isFixStructure) {
+ fixStructure(coordinates, minSize, isRing);
+ }
+ Coordinate[] coordArray = coordinates.toArray(new Coordinate[0]);
+ return csFactory.create(coordArray);
+ }
+
+ private static void fixStructure(List<Coordinate> coords, int minSize, boolean isRing) {
+ if (coords.size() == 0) return;
+ if (isRing && !isClosed(coords)) {
+ coords.add(coords.get(0).copy());
+ }
+ while (coords.size() < minSize) {
+ coords.add(coords.get(coords.size() - 1).copy());
+ }
+ }
+
+ private static boolean isClosed(List<Coordinate> coords) {
+ if (coords.size() == 0) return true;
+ if (coords.size() == 1 || !coords.get(0).equals2D(coords.get(coords.size() - 1))) {
+ return false;
+ }
+ return true;
+ }
+
+ private CoordinateSequence createCoordinateSequenceEmpty(EnumSet<Ordinate> ordinateFlags)
+ throws IOException, ParseException {
+ return csFactory.create(
+ 0, toDimension(ordinateFlags), ordinateFlags.contains(Ordinate.M) ? 1 : 0);
+ }
+
+ /**
+ * Reads a <code>CoordinateSequence</Code> from a stream using the given {@link StreamTokenizer}
+ * for an old-style JTS MultiPoint (Point coordinates not enclosed in parentheses).
+ *
+ * <p>All ordinate values are read, but -depending on the {@link CoordinateSequenceFactory} of the
+ * underlying {@link GeometryFactory}- not necessarily all can be handled. Those are silently
+ * dropped.
+ *
+ * @param tokenizer the tokenizer to use
+ * @param ordinateFlags a bit-mask defining the ordinates to read.
+ * @return a {@link CoordinateSequence} of length 1 containing the read ordinate values
+ * @throws IOException if an I/O error occurs
+ * @throws ParseException if an unexpected token was encountered S
+ */
+ private CoordinateSequence getCoordinateSequenceOldMultiPoint(
+ StreamTokenizer tokenizer, EnumSet<Ordinate> ordinateFlags)
+ throws IOException, ParseException {
+
+ List<Coordinate> coordinates = new ArrayList<Coordinate>();
+ do {
+ coordinates.add(getCoordinate(tokenizer, ordinateFlags, true));
+ } while (getNextCloserOrComma(tokenizer).equals(COMMA));
+
+ Coordinate[] coordArray = coordinates.toArray(new Coordinate[0]);
+ return csFactory.create(coordArray);
+ }
+
+ /**
+ * Computes the required dimension based on the given ordinate values. It is assumed that {@link
+ * Ordinate#X} and {@link Ordinate#Y} are included.
+ *
+ * @param ordinateFlags the ordinate bit-mask
+ * @return the number of dimensions required to store ordinates for the given bit-mask.
+ */
+ private int toDimension(EnumSet<Ordinate> ordinateFlags) {
+ int dimension = 2;
+ if (ordinateFlags.contains(Ordinate.Z)) dimension++;
+ if (ordinateFlags.contains(Ordinate.M)) dimension++;
+
+ if (dimension == 2 && this.isAllowOldJtsCoordinateSyntax) dimension++;
+
+ return dimension;
+ }
+
+ /**
+ * Tests if the next token in the stream is a number
+ *
+ * @param tokenizer the tokenizer
+ * @return {@code true} if the next token is a number, otherwise {@code false}
+ * @throws IOException if an I/O error occurs
+ */
+ private static boolean isNumberNext(StreamTokenizer tokenizer) throws IOException {
+ int type = tokenizer.nextToken();
+ tokenizer.pushBack();
+ return type == StreamTokenizer.TT_WORD;
+ }
+
+ /**
+ * Tests if the next token in the stream is a left opener ({@link #L_PAREN})
+ *
+ * @param tokenizer the tokenizer
+ * @return {@code true} if the next token is a {@link #L_PAREN}, otherwise {@code false}
+ * @throws IOException if an I/O error occurs
+ */
+ private static boolean isOpenerNext(StreamTokenizer tokenizer) throws IOException {
+ int type = tokenizer.nextToken();
+ tokenizer.pushBack();
+ return type == '(';
+ }
+
+ /**
+ * Parses the next number in the stream. Numbers with exponents are handled. <tt>NaN</tt> values
+ * are handled correctly, and the case of the "NaN" symbol is not significant.
+ *
+ * @param tokenizer tokenizer over a stream of text in Well-known Text
+ * @return the next number in the stream
+ * @throws ParseException if the next token is not a valid number
+ * @throws IOException if an I/O error occurs
+ */
+ private double getNextNumber(StreamTokenizer tokenizer) throws IOException, ParseException {
+ int type = tokenizer.nextToken();
+ switch (type) {
+ case StreamTokenizer.TT_WORD:
+ {
+ if (tokenizer.sval.equalsIgnoreCase(NAN_SYMBOL)) {
+ return Double.NaN;
+ } else {
+ try {
+ return Double.parseDouble(tokenizer.sval);
+ } catch (NumberFormatException ex) {
+ throw parseErrorWithLine(tokenizer, "Invalid number: " + tokenizer.sval);
+ }
+ }
+ }
+ }
+ throw parseErrorExpected(tokenizer, "number");
+ }
+
+ /**
+ * Returns the next EMPTY or L_PAREN in the stream as uppercase text.
+ *
+ * @return the next EMPTY or L_PAREN in the stream as uppercase text.
+ * @throws ParseException if the next token is not EMPTY or L_PAREN
+ * @throws IOException if an I/O error occurs
+ * @param tokenizer tokenizer over a stream of text in Well-known Text
+ */
+ private static String getNextEmptyOrOpener(StreamTokenizer tokenizer)
+ throws IOException, ParseException {
+ String nextWord = getNextWord(tokenizer);
+ if (nextWord.equalsIgnoreCase(WKTConstants.Z)) {
+ // z = true;
+ nextWord = getNextWord(tokenizer);
+ } else if (nextWord.equalsIgnoreCase(WKTConstants.M)) {
+ // m = true;
+ nextWord = getNextWord(tokenizer);
+ } else if (nextWord.equalsIgnoreCase(WKTConstants.ZM)) {
+ // z = true;
+ // m = true;
+ nextWord = getNextWord(tokenizer);
+ }
+ if (nextWord.equals(WKTConstants.EMPTY) || nextWord.equals(L_PAREN)) {
+ return nextWord;
+ }
+ throw parseErrorExpected(tokenizer, WKTConstants.EMPTY + " or " + L_PAREN);
+ }
+
+ /**
+ * Returns the next ordinate flag information in the stream as uppercase text. This can be Z, M or
+ * ZM.
+ *
+ * @return the next EMPTY or L_PAREN in the stream as uppercase text.
+ * @throws ParseException if the next token is not EMPTY or L_PAREN
+ * @throws IOException if an I/O error occurs
+ * @param tokenizer tokenizer over a stream of text in Well-known Text
+ */
+ private static EnumSet<Ordinate> getNextOrdinateFlags(StreamTokenizer tokenizer)
+ throws IOException, ParseException {
+
+ EnumSet<Ordinate> result = EnumSet.of(Ordinate.X, Ordinate.Y);
+
+ String nextWord = lookAheadWord(tokenizer).toUpperCase(Locale.ROOT);
+ if (nextWord.equalsIgnoreCase(WKTConstants.Z)) {
+ tokenizer.nextToken();
+ result.add(Ordinate.Z);
+ } else if (nextWord.equalsIgnoreCase(WKTConstants.M)) {
+ tokenizer.nextToken();
+ result.add(Ordinate.M);
+ } else if (nextWord.equalsIgnoreCase(WKTConstants.ZM)) {
+ tokenizer.nextToken();
+ result.add(Ordinate.Z);
+ result.add(Ordinate.M);
+ }
+ return result;
+ }
+
+ /**
+ * Returns the next word in the stream.
+ *
+ * @param tokenizer tokenizer over a stream of text in Well-known Text format. The next token must
+ * be a word.
+ * @return the next word in the stream as uppercase text
+ * @throws ParseException if the next token is not a word
+ * @throws IOException if an I/O error occurs
+ */
+ private static String lookAheadWord(StreamTokenizer tokenizer)
+ throws IOException, ParseException {
+ String nextWord = getNextWord(tokenizer);
+ tokenizer.pushBack();
+ return nextWord;
+ }
+
+ /**
+ * Returns the next {@link #R_PAREN} or {@link #COMMA} in the stream.
+ *
+ * @return the next R_PAREN or COMMA in the stream
+ * @throws ParseException if the next token is not R_PAREN or COMMA
+ * @throws IOException if an I/O error occurs
+ * @param tokenizer tokenizer over a stream of text in Well-known Text
+ */
+ private static String getNextCloserOrComma(StreamTokenizer tokenizer)
+ throws IOException, ParseException {
+ String nextWord = getNextWord(tokenizer);
+ if (nextWord.equals(COMMA) || nextWord.equals(R_PAREN)) {
+ return nextWord;
+ }
+ throw parseErrorExpected(tokenizer, COMMA + " or " + R_PAREN);
+ }
+
+ /**
+ * Returns the next {@link #R_PAREN} in the stream.
+ *
+ * @param tokenizer tokenizer over a stream of text in Well-known Text format. The next token must
+ * be R_PAREN.
+ * @return the next R_PAREN in the stream
+ * @throws ParseException if the next token is not R_PAREN
+ * @throws IOException if an I/O error occurs
+ */
+ private String getNextCloser(StreamTokenizer tokenizer) throws IOException, ParseException {
+ String nextWord = getNextWord(tokenizer);
+ if (nextWord.equals(R_PAREN)) {
+ return nextWord;
+ }
+ throw parseErrorExpected(tokenizer, R_PAREN);
+ }
+
+ /**
+ * Returns the next word in the stream.
+ *
+ * @return the next word in the stream as uppercase text
+ * @throws ParseException if the next token is not a word
+ * @throws IOException if an I/O error occurs
+ * @param tokenizer tokenizer over a stream of text in Well-known Text
+ */
+ private static String getNextWord(StreamTokenizer tokenizer) throws IOException, ParseException {
+ int type = tokenizer.nextToken();
+ switch (type) {
+ case StreamTokenizer.TT_WORD:
+ String word = tokenizer.sval;
+ if (word.equalsIgnoreCase(WKTConstants.EMPTY)) return WKTConstants.EMPTY;
+ return word;
+
+ case '(':
+ return L_PAREN;
+ case ')':
+ return R_PAREN;
+ case ',':
+ return COMMA;
+ }
+ throw parseErrorExpected(tokenizer, "word");
+ }
+
+ /**
+ * Creates a formatted ParseException reporting that the current token was unexpected.
+ *
+ * @param expected a description of what was expected
+ */
+ private static ParseException parseErrorExpected(StreamTokenizer tokenizer, String expected) {
+ // throws Asserts for tokens that should never be seen
+ if (tokenizer.ttype == StreamTokenizer.TT_NUMBER)
+ Assert.shouldNeverReachHere("Unexpected NUMBER token");
+ if (tokenizer.ttype == StreamTokenizer.TT_EOL)
+ Assert.shouldNeverReachHere("Unexpected EOL token");
+
+ String tokenStr = tokenString(tokenizer);
+ return parseErrorWithLine(tokenizer, "Expected " + expected + " but found " + tokenStr);
+ }
+
+ /**
+ * Creates a formatted ParseException reporting that the current token was unexpected.
+ *
+ * @param msg a description of what was expected
+ */
+ private static ParseException parseErrorWithLine(StreamTokenizer tokenizer, String msg) {
+ return new ParseException(msg + " (line " + tokenizer.lineno() + ")");
+ }
+
+ /**
+ * Gets a description of the current token type
+ *
+ * @param tokenizer the tokenizer
+ * @return a description of the current token
+ */
+ private static String tokenString(StreamTokenizer tokenizer) {
+ switch (tokenizer.ttype) {
+ case StreamTokenizer.TT_NUMBER:
+ return "<NUMBER>";
+ case StreamTokenizer.TT_EOL:
+ return "End-of-Line";
+ case StreamTokenizer.TT_EOF:
+ return "End-of-Stream";
+ case StreamTokenizer.TT_WORD:
+ return "'" + tokenizer.sval + "'";
+ }
+ return "'" + (char) tokenizer.ttype + "'";
+ }
+
+ /**
+ * Creates a <code>Geometry</code> using the next token in the stream.
+ *
+ * @return a <code>Geometry</code> specified by the next token in the stream
+ * @throws ParseException if the coordinates used to create a <code>Polygon</code> shell and holes
+ * do not form closed linestrings, or if an unexpected token was encountered
+ * @throws IOException if an I/O error occurs
+ * @param tokenizer tokenizer over a stream of text in Well-known Text
+ */
+ private S2Geography readGeometryTaggedText(StreamTokenizer tokenizer)
+ throws IOException, ParseException {
+ String type;
+
+ EnumSet<Ordinate> ordinateFlags = EnumSet.of(Ordinate.X, Ordinate.Y);
+ type = getNextWord(tokenizer).toUpperCase(Locale.ROOT);
+ if (type.endsWith(WKTConstants.ZM)) {
+ ordinateFlags.add(Ordinate.Z);
+ ordinateFlags.add(Ordinate.M);
+ } else if (type.endsWith(WKTConstants.Z)) {
+ ordinateFlags.add(Ordinate.Z);
+ } else if (type.endsWith(WKTConstants.M)) {
+ ordinateFlags.add(Ordinate.M);
+ }
+ return readGeometryTaggedText(tokenizer, type, ordinateFlags);
+ }
+
+ private S2Geography readGeometryTaggedText(
+ StreamTokenizer tokenizer, String type, EnumSet<Ordinate> ordinateFlags)
+ throws IOException, ParseException {
+
+ if (ordinateFlags.size() == 2) {
+ ordinateFlags = getNextOrdinateFlags(tokenizer);
+ }
+
+ // if we can create a sequence with the required dimension everything is ok, otherwise
+ // we need to take a different coordinate sequence factory.
+ // It would be good to not have to try/catch this but if the CoordinateSequenceFactory
+ // exposed a value indicating which min/max dimension it can handle or even an
+ // ordinate bit-flag.
+ try {
+ csFactory.create(0, toDimension(ordinateFlags), ordinateFlags.contains(Ordinate.M) ? 1 : 0);
+ } catch (Exception e) {
+ geometryFactory =
+ new GeometryFactory(
+ geometryFactory.getPrecisionModel(), geometryFactory.getSRID(), csFactoryXYZM);
+ }
+
+ if (isTypeName(tokenizer, type, WKTConstants.POINT)) {
+ return readPointText(tokenizer, ordinateFlags);
+ } else if (isTypeName(tokenizer, type, WKTConstants.LINESTRING)) {
+ return readPolylineText(tokenizer, ordinateFlags);
+ } else if (isTypeName(tokenizer, type, WKTConstants.LINEARRING)) {
+ return readPolygonText(tokenizer, ordinateFlags);
+ } else if (isTypeName(tokenizer, type, WKTConstants.POLYGON)) {
+ return readPolygonText(tokenizer, ordinateFlags);
+ } else if (isTypeName(tokenizer, type, WKTConstants.MULTIPOINT)) {
+ return readMultiPointText(tokenizer, ordinateFlags);
+ } else if (isTypeName(tokenizer, type, WKTConstants.MULTILINESTRING)) {
+ return readMultiPolylineText(tokenizer, ordinateFlags);
+ } else if (isTypeName(tokenizer, type, WKTConstants.MULTIPOLYGON)) {
+ return readMultiPolygonText(tokenizer, ordinateFlags);
+ } else if (isTypeName(tokenizer, type, WKTConstants.GEOMETRYCOLLECTION)) {
+ return readGeographyCollectionText(tokenizer, ordinateFlags);
+ }
+ throw parseErrorWithLine(tokenizer, "Unknown geometry type: " + type);
+ }
+
+ private boolean isTypeName(StreamTokenizer tokenizer, String type, String typeName)
+ throws ParseException {
+ if (!type.startsWith(typeName)) return false;
+
+ String modifiers = type.substring(typeName.length());
+ boolean isValidMod =
+ modifiers.length() <= 2
+ && (modifiers.length() == 0
+ || modifiers.equals(WKTConstants.Z)
+ || modifiers.equals(WKTConstants.M)
+ || modifiers.equals(WKTConstants.ZM));
+ if (!isValidMod) {
+ throw parseErrorWithLine(tokenizer, "Invalid dimension modifiers: " + type);
+ }
+
+ return true;
+ }
+
+ /**
+ * Creates a <code>Point</code> using the next token in the stream.
+ *
+ * @param tokenizer tokenizer over a stream of text in Well-known Text format. The next tokens
+ * must form a <Point Text>.
+ * @return a <code>Point</code> specified by the next token in the stream
+ * @throws IOException if an I/O error occurs
+ * @throws ParseException if an unexpected token was encountered
+ */
+ private SinglePointGeography readPointText(
+ StreamTokenizer tokenizer, EnumSet<Ordinate> ordinateFlags)
+ throws IOException, ParseException {
+ CoordinateSequence pts = getCoordinateSequence(tokenizer, ordinateFlags, 1, false);
+ // If X and Y are NaN create a empty point
+ if (pts.size() <= 0 || Double.isNaN(pts.getX(0)) || Double.isNaN(pts.getY(0))) {
+ return new SinglePointGeography();
+ }
+ double lon = pts.getX(0);
+ double lat = pts.getY(0);
+ S2Point s2Point = S2LatLng.fromDegrees(lat, lon).toPoint();
+
+ // Build via S2Builder + S2PointVectorLayer
+ S2Builder builder = new S2Builder.Builder().build();
+ S2PointVectorLayer layer = new S2PointVectorLayer();
+ builder.startLayer(layer);
+ builder.addPoint(s2Point);
+
+ // must call build() before reading out the points
+ S2Error error = new S2Error();
+ if (!builder.build(error)) {
+ throw new IOException("Failed to build S2 point layer: " + error.text());
+ }
+ // Extract the resulting points
+ List<S2Point> points = layer.getPointVector();
+ if (points.isEmpty()) {
+ return new SinglePointGeography();
+ }
+ return new SinglePointGeography(points.get(0));
+ }
+
+ /**
+ * Creates a <code>LineString</code> using the next token in the stream.
+ *
+ * @param tokenizer tokenizer over a stream of text in Well-known Text format. The next tokens
+ * must form a <LineString Text>.
+ * @return a <code>LineString</code> specified by the next token in the stream
+ * @throws IOException if an I/O error occurs
+ * @throws ParseException if an unexpected token was encountered
+ */
+ private SinglePolylineGeography readPolylineText(
+ StreamTokenizer tokenizer, EnumSet<Ordinate> ordinateFlags)
+ throws IOException, ParseException {
+ CoordinateSequence seq =
+ getCoordinateSequence(tokenizer, ordinateFlags, LineString.MINIMUM_VALID_SIZE, false);
+ if (seq.size() < 2) {
+ // empty or extended-but-all-NaN → empty geography
+ return new SinglePolylineGeography();
+ }
+
+ List<S2Point> pts = new ArrayList<>(seq.size());
+ for (int i = 0; i < seq.size(); i++) {
+ double lon = seq.getX(i);
+ double lat = seq.getY(i);
+ pts.add(S2LatLng.fromDegrees(lat, lon).toPoint());
+ }
+
+ S2Builder builder = new S2Builder.Builder().build();
+ S2PolylineLayer layer = new S2PolylineLayer();
+ builder.startLayer(layer);
+
+ builder.addPolyline(new S2Polyline(pts));
+
+ S2Error error = new S2Error();
+ if (!builder.build(error)) {
+ throw new IOException("Failed to build S2 polyline: " + error.text());
+ }
+ S2Polyline s2poly = layer.getPolyline();
+ return new SinglePolylineGeography(s2poly);
+ }
+
+ /**
+ * Creates a <code>LinearRing</code> using the next token in the stream.
+ *
+ * @param tokenizer tokenizer over a stream of text in Well-known Text format. The next tokens
+ * must form a <LineString Text>.
+ * @return a <code>LinearRing</code> specified by the next token in the stream
+ * @throws IOException if an I/O error occurs
+ * @throws ParseException if the coordinates used to create the <code>LinearRing</code> do not
+ * form a closed linestring, or if an unexpected token was encountered
+ */
+ private S2Loop readLoopText(StreamTokenizer tokenizer, EnumSet<Ordinate> ordinateFlags)
+ throws IOException, ParseException {
+ CoordinateSequence seq =
+ getCoordinateSequence(tokenizer, ordinateFlags, LinearRing.MINIMUM_VALID_SIZE, true);
+ // build the loop
+ List<S2Point> pts = new ArrayList<>(seq.size());
+ for (int i = 0; i < seq.size(); i++) {
+ pts.add(S2LatLng.fromDegrees(seq.getY(i), seq.getX(i)).toPoint());
+ }
+ return new S2Loop(pts);
+ }
+
+ /**
+ * Creates a <code>MultiPoint</code> using the next tokens in the stream.
+ *
+ * @param tokenizer tokenizer over a stream of text in Well-known Text format. The next tokens
+ * must form a <MultiPoint Text>.
+ * @return a <code>MultiPoint</code> specified by the next token in the stream
+ * @throws IOException if an I/O error occurs
+ * @throws ParseException if an unexpected token was encountered
+ */
+ private PointGeography readMultiPointText(
+ StreamTokenizer tokenizer, EnumSet<Ordinate> ordinateFlags)
+ throws IOException, ParseException {
+ String nextToken = getNextEmptyOrOpener(tokenizer);
+ if (nextToken.equals(WKTConstants.EMPTY)) {
+ return new PointGeography();
+ }
+
+ // check for old-style JTS syntax (no parentheses surrounding Point coordinates) and parse it if
+ // present
+ // MD 2009-02-21 - this is only provided for backwards compatibility for a few versions
+ if (isAllowOldJtsMultipointSyntax) {
+ String nextWord = lookAheadWord(tokenizer);
+ if (nextWord != L_PAREN && nextWord != WKTConstants.EMPTY) {
+ CoordinateSequence pts = getCoordinateSequenceOldMultiPoint(tokenizer, ordinateFlags);
+ if (Double.isNaN(pts.getX(0)) || Double.isNaN(pts.getY(0))) {
+ return new PointGeography();
+ }
+ List<S2Point> points = new ArrayList<>(pts.size());
+ // Build via S2Builder + S2PointVectorLayer
+ S2Builder builder = new S2Builder.Builder().build();
+ S2PointVectorLayer layer = new S2PointVectorLayer();
+ // must call build() before reading out the points
+ S2Error error = new S2Error();
+ for (int i = 0; i < pts.size(); i++) {
+ double lon = pts.getX(i);
+ double lat = pts.getY(i);
+ S2Point s2Point = S2LatLng.fromDegrees(lat, lon).toPoint();
+
+ builder.startLayer(layer);
+ builder.addPoint(s2Point);
+
+ if (!builder.build(error)) {
+ throw new IOException("Failed to build S2 point layer: " + error.text());
+ }
+ }
+ for (int i = 0; i < layer.getPointVector().size(); i++) {
+ // Extract the resulting points
+ points.add(layer.getPointVector().get(i));
+ }
+ return new PointGeography(points);
+ }
+ }
+
+ List<S2Point> points = new ArrayList<S2Point>();
+ PointGeography point = readPointText(tokenizer, ordinateFlags);
+ points.addAll(point.getPoints());
+ nextToken = getNextCloserOrComma(tokenizer);
+ while (nextToken.equals(COMMA)) {
+ point = readPointText(tokenizer, ordinateFlags);
+ points.addAll(point.getPoints());
+ nextToken = getNextCloserOrComma(tokenizer);
+ }
+ return new PointGeography(points);
+ }
+
+ /**
+ * Creates a <code>Polygon</code> using the next token in the stream.
+ *
+ * @param tokenizer tokenizer over a stream of text in Well-known Text format. The next tokens
+ * must form a <Polygon Text>.
+ * @return a <code>Polygon</code> specified by the next token in the stream
+ * @throws ParseException if the coordinates used to create the <code>Polygon</code> shell and
+ * holes do not form closed linestrings, or if an unexpected token was encountered.
+ * @throws IOException if an I/O error occurs
+ */
+ private PolygonGeography readPolygonText(
+ StreamTokenizer tokenizer, EnumSet<Ordinate> ordinateFlags)
+ throws IOException, ParseException {
+ String nextToken = getNextEmptyOrOpener(tokenizer);
+ if (nextToken.equals(WKTConstants.EMPTY)) {
+ return new PolygonGeography();
+ }
+
+ List<S2Loop> holes = new ArrayList<S2Loop>();
+
+ S2Loop shell = readLoopText(tokenizer, ordinateFlags);
+ holes.add(shell);
+ nextToken = getNextCloserOrComma(tokenizer);
+ while (nextToken.equals(COMMA)) {
+ S2Loop hole = readLoopText(tokenizer, ordinateFlags);
+ holes.add(hole);
+ nextToken = getNextCloserOrComma(tokenizer);
+ }
+
+ // Now feed those loops into S2Builder + S2PolygonLayer:
+ S2Builder builder = new S2Builder.Builder().build();
+ S2PolygonLayer polyLayer = new S2PolygonLayer();
+ builder.startLayer(polyLayer);
+
+ // add shell + holes
+ for (S2Loop loop : holes) {
+ builder.addLoop(loop);
+ }
+
+ // build
+ S2Error error = new S2Error();
+ if (!builder.build(error)) {
+ throw new IOException("S2Builder failed: " + error.text());
+ }
+
+ // extract the stitched polygon
+ S2Polygon s2poly = polyLayer.getPolygon();
+
+ // wrap in your PolygonGeography
+ return new PolygonGeography(s2poly);
+ }
+
+ /**
+ * Creates a <code>MultiLineString</code> using the next token in the stream.
+ *
+ * @param tokenizer tokenizer over a stream of text in Well-known Text format. The next tokens
+ * must form a <MultiLineString Text>.
+ * @return a <code>MultiLineString</code> specified by the next token in the stream
+ * @throws IOException if an I/O error occurs
+ * @throws ParseException if an unexpected token was encountered
+ */
+ private PolylineGeography readMultiPolylineText(
+ StreamTokenizer tokenizer, EnumSet<Ordinate> ordinateFlags)
+ throws IOException, ParseException {
+ String nextToken = getNextEmptyOrOpener(tokenizer);
+ if (nextToken.equals(WKTConstants.EMPTY)) {
+ return new PolylineGeography();
+ }
+
+ List<S2Polyline> lineStrings = new ArrayList<S2Polyline>();
+ do {
+ PolylineGeography lineString = readPolylineText(tokenizer, ordinateFlags);
+ lineStrings.addAll(lineString.getPolylines());
+ nextToken = getNextCloserOrComma(tokenizer);
+ } while (nextToken.equals(COMMA));
+ return new PolylineGeography(lineStrings);
+ }
+
+ /**
+ * Creates a <code>MultiPolygon</code> using the next token in the stream.
+ *
+ * @param tokenizer tokenizer over a stream of text in Well-known Text format. The next tokens
+ * must form a <MultiPolygon Text>.
+ * @return a <code>MultiPolygon</code> specified by the next token in the stream, or if if the
+ * coordinates used to create the <code>Polygon</code> shells and holes do not form closed
+ * linestrings.
+ * @throws IOException if an I/O error occurs
+ * @throws ParseException if an unexpected token was encountered
+ */
+ private MultiPolygonGeography readMultiPolygonText(
+ StreamTokenizer tokenizer, EnumSet<Ordinate> ordinateFlags)
+ throws IOException, ParseException {
+ String nextToken = getNextEmptyOrOpener(tokenizer);
+ if (nextToken.equals(WKTConstants.EMPTY)) {
+ return new MultiPolygonGeography(new ArrayList<>());
+ }
+ List<S2Polygon> polygons = new ArrayList<S2Polygon>();
+ do {
+ PolygonGeography polygon = readPolygonText(tokenizer, ordinateFlags);
+ polygons.add(polygon.polygon);
+ nextToken = getNextCloserOrComma(tokenizer);
+ } while (nextToken.equals(COMMA));
+ return new MultiPolygonGeography(polygons);
+ }
+
+ /**
+ * Creates a <code>GeometryCollection</code> using the next token in the stream.
+ *
+ * @param tokenizer tokenizer over a stream of text in Well-known Text format. The next tokens
+ * must form a <GeometryCollection Text>.
+ * @return a <code>GeometryCollection</code> specified by the next token in the stream
+ * @throws ParseException if the coordinates used to create a <code>Polygon</code> shell and holes
+ * do not form closed linestrings, or if an unexpected token was encountered
+ * @throws IOException if an I/O error occurs
+ */
+ private GeographyCollection readGeographyCollectionText(
+ StreamTokenizer tokenizer, EnumSet<Ordinate> ordinateFlags)
+ throws IOException, ParseException {
+ String nextToken = getNextEmptyOrOpener(tokenizer);
+ if (nextToken.equals(WKTConstants.EMPTY)) {
+ return new GeographyCollection();
+ }
+ List<S2Geography> geometries = new ArrayList<S2Geography>();
+ do {
+ S2Geography geometry = readGeometryTaggedText(tokenizer);
+ geometries.add(geometry);
+ nextToken = getNextCloserOrComma(tokenizer);
+ } while (nextToken.equals(COMMA));
+ return new GeographyCollection(geometries);
+ }
+}
diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/WKTWriter.java b/common/src/main/java/org/apache/sedona/common/S2Geography/WKTWriter.java
new file mode 100644
index 0000000000..dd5661097a
--- /dev/null
+++ b/common/src/main/java/org/apache/sedona/common/S2Geography/WKTWriter.java
@@ -0,0 +1,887 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sedona.common.S2Geography;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.util.EnumSet;
+import org.locationtech.jts.geom.*;
+import org.locationtech.jts.io.Ordinate;
+import org.locationtech.jts.io.OrdinateFormat;
+import org.locationtech.jts.io.WKTConstants;
+import org.locationtech.jts.util.Assert;
+
+public class WKTWriter {
+ /**
+ * Generates the WKT for a <tt>POINT</tt> specified by a {@link Coordinate}.
+ *
+ * @param p0 the point coordinate
+ * @return the WKT
+ */
+ public static String toPoint(Coordinate p0) {
+ return WKTConstants.POINT + " ( " + format(p0) + " )";
+ }
+
+ public static String format(Coordinate p) {
+ return format(p.x, p.y);
+ }
+
+ private static String format(double x, double y) {
+ return OrdinateFormat.DEFAULT.format(x) + " " + OrdinateFormat.DEFAULT.format(y);
+ }
+
+ private static final int INDENT = 2;
+ private static final int OUTPUT_DIMENSION = 2;
+
+ /**
+ * Creates the <code>DecimalFormat</code> used to write <code>double</code>s with a sufficient
+ * number of decimal places.
+ *
+ * @param precisionModel the <code>PrecisionModel</code> used to determine the number of decimal
+ * places to write.
+ * @return a <code>DecimalFormat</code> that write <code>double</code> s without scientific
+ * notation.
+ */
+ private static OrdinateFormat createFormatter(PrecisionModel precisionModel) {
+ return OrdinateFormat.create(precisionModel.getMaximumSignificantDigits());
+ }
+
+ /**
+ * Returns a <code>String</code> of repeated characters.
+ *
+ * @param ch the character to repeat
+ * @param count the number of times to repeat the character
+ * @return a <code>String</code> of characters
+ */
+ private static String stringOfChar(char ch, int count) {
+ StringBuilder buf = new StringBuilder(count);
+ for (int i = 0; i < count; i++) {
+ buf.append(ch);
+ }
+ return buf.toString();
+ }
+
+ private EnumSet<Ordinate> outputOrdinates;
+ private final int outputDimension;
+ private PrecisionModel precisionModel = null;
+ private OrdinateFormat ordinateFormat = null;
+ private boolean isFormatted = false;
+ private int coordsPerLine = -1;
+ private String indentTabStr;
+
+ /** Creates a new WKTWriter with default settings */
+ public WKTWriter() {
+ this(OUTPUT_DIMENSION);
+ }
+
+ /**
+ * Creates a writer that writes {@link Geometry}s with the given output dimension (2 to 4). The
+ * output follows the following rules:
+ *
+ * <ul>
+ * <li>If the specified <b>output dimension is 3</b> and the <b>z is measure flag is set to
+ * true</b>, the Z value of coordinates will be written if it is present (i.e. if it is not
+ * <code>Double.NaN</code>)
+ * <li>If the specified <b>output dimension is 3</b> and the <b>z is measure flag is set to
+ * false</b>, the Measure value of coordinates will be written if it is present (i.e. if it
+ * is not <code>Double.NaN</code>)
+ * <li>If the specified <b>output dimension is 4</b>, the Z value of coordinates will be written
+ * even if it is not present when the Measure value is present. The Measure value of
+ * coordinates will be written if it is present (i.e. if it is not <code>Double.NaN</code>)
+ * </ul>
+ *
+ * See also {@link #setOutputOrdinates(EnumSet)}
+ *
+ * @param outputDimension the coordinate dimension to output (2 to 4)
+ */
+ public WKTWriter(int outputDimension) {
+
+ setTab(INDENT);
+ this.outputDimension = outputDimension;
+ setPrecisionModel(new PrecisionModel());
+
+ if (outputDimension < 2 || outputDimension > 4)
+ throw new IllegalArgumentException("Invalid output dimension (must be 2 to 4)");
+
+ this.outputOrdinates = EnumSet.of(Ordinate.X, Ordinate.Y);
+ if (outputDimension > 2) outputOrdinates.add(Ordinate.Z);
+ if (outputDimension > 3) outputOrdinates.add(Ordinate.M);
+ }
+
+ /**
+ * Sets whether the output will be formatted.
+ *
+ * @param isFormatted true if the output is to be formatted
+ */
+ public void setFormatted(boolean isFormatted) {
+ this.isFormatted = isFormatted;
+ }
+
+ /**
+ * Sets the maximum number of coordinates per line written in formatted output. If the provided
+ * coordinate number is <= 0, coordinates will be written all on one line.
+ *
+ * @param coordsPerLine the number of coordinates per line to output.
+ */
+ public void setMaxCoordinatesPerLine(int coordsPerLine) {
+ this.coordsPerLine = coordsPerLine;
+ }
+
+ /**
+ * Sets the tab size to use for indenting.
+ *
+ * @param size the number of spaces to use as the tab string
+ * @throws IllegalArgumentException if the size is non-positive
+ */
+ public void setTab(int size) {
+ if (size <= 0) throw new IllegalArgumentException("Tab count must be positive");
+ this.indentTabStr = stringOfChar(' ', size);
+ }
+
+ /**
+ * Sets the {@link Ordinate} that are to be written. Possible members are:
+ *
+ * <ul>
+ * <li>{@link Ordinate#X}
+ * <li>{@link Ordinate#Y}
+ * <li>{@link Ordinate#Z}
+ * <li>{@link Ordinate#M}
+ * </ul>
+ *
+ * Values of {@link Ordinate#X} and {@link Ordinate#Y} are always assumed and not particularly
+ * checked for.
+ *
+ * @param outputOrdinates A set of {@link Ordinate} values
+ */
+ public void setOutputOrdinates(EnumSet<Ordinate> outputOrdinates) {
+
+ this.outputOrdinates.remove(Ordinate.Z);
+ this.outputOrdinates.remove(Ordinate.M);
+
+ if (this.outputDimension == 3) {
+ if (outputOrdinates.contains(Ordinate.Z)) this.outputOrdinates.add(Ordinate.Z);
+ else if (outputOrdinates.contains(Ordinate.M)) this.outputOrdinates.add(Ordinate.M);
+ }
+ if (this.outputDimension == 4) {
+ if (outputOrdinates.contains(Ordinate.Z)) this.outputOrdinates.add(Ordinate.Z);
+ if (outputOrdinates.contains(Ordinate.M)) this.outputOrdinates.add(Ordinate.M);
+ }
+ }
+
+ /**
+ * Gets a bit-pattern defining which ordinates should be
+ *
+ * @return an ordinate bit-pattern
+ * @see #setOutputOrdinates(EnumSet)
+ */
+ public EnumSet<Ordinate> getOutputOrdinates() {
+ return this.outputOrdinates;
+ }
+
+ /**
+ * Sets a {@link PrecisionModel} that should be used on the ordinates written.
+ *
+ * <p>If none/{@code null} is assigned, the precision model of the {@link Geometry#getFactory()}
+ * is used.
+ *
+ * <p>Note: The precision model is applied to all ordinate values, not just x and y.
+ *
+ * @param precisionModel the flag indicating if {@link Coordinate#z}/{} is actually a measure
+ * value.
+ */
+ public void setPrecisionModel(PrecisionModel precisionModel) {
+ this.precisionModel = precisionModel;
+ this.ordinateFormat = OrdinateFormat.create(precisionModel.getMaximumSignificantDigits());
+ }
+
+ /**
+ * Converts a <code>Geometry</code> to its Well-known Text representation.
+ *
+ * @param geometry a <code>Geometry</code> to process
+ * @return a <Geometry Tagged Text> string (see the OpenGIS Simple Features Specification)
+ */
+ public String write(S2Geography geometry) {
+ Writer sw = new StringWriter();
+
+ try {
+ writeFormatted(geometry, false, sw);
+ } catch (IOException ex) {
+ Assert.shouldNeverReachHere();
+ }
+ return sw.toString();
+ }
+
+ /**
+ * Converts a <code>Geometry</code> to its Well-known Text representation.
+ *
+ * @param geometry a <code>Geometry</code> to process
+ */
+ public void write(S2Geography geometry, Writer writer) throws IOException {
+ // write the geometry
+ writeFormatted(geometry, isFormatted, writer);
+ }
+
+ /**
+ * Converts a <code>Geometry</code> to its Well-known Text representation.
+ *
+ * @param geometry a <code>Geometry</code> to process
+ */
+ private void writeFormatted(S2Geography geometry, boolean useFormatting, Writer writer)
+ throws IOException {
+ OrdinateFormat formatter = getFormatter(geometry);
+ // append the WKT
+ appendGeometryTaggedText(geometry, useFormatting, writer, formatter);
+ }
+
+ private OrdinateFormat getFormatter(S2Geography geometry) {
+ // if present use the cached formatter
+ if (ordinateFormat != null) return ordinateFormat;
+
+ // no precision model was specified, so use the geometry's
+ PrecisionModel pm = new PrecisionModel(); // geometry.getPrecisionModel();
+ OrdinateFormat formatter = createFormatter(pm);
+ return formatter;
+ }
+
+ /**
+ * Converts a <code>Geometry</code> to <Geometry Tagged Text> format, then appends it to the
+ * writer.
+ *
+ * @param geometry the <code>Geometry</code> to process
+ * @param useFormatting flag indicating that the output should be formatted
+ * @param writer the output writer to append to
+ * @param formatter the <code>DecimalFormatter</code> to use to convert from a precise coordinate
+ * to an external coordinate
+ */
+ private void appendGeometryTaggedText(
+ S2Geography geometry, boolean useFormatting, Writer writer, OrdinateFormat formatter)
+ throws IOException {
+ EnumSet<Ordinate> seq = getOutputOrdinates();
+ // Append the WKT
+ appendGeometryTaggedText(geometry, seq, useFormatting, 0, writer, formatter);
+ }
+ /**
+ * Converts a <code>Geometry</code> to <Geometry Tagged Text> format, then appends it to the
+ * writer.
+ *
+ * @param geometry the <code>Geometry</code> to process
+ * @param useFormatting flag indicating that the output should be formatted
+ * @param level the indentation level
+ * @param writer the output writer to append to
+ * @param formatter the <code>DecimalFormatter</code> to use to convert from a precise coordinate
+ * to an external coordinate
+ */
+ private void appendGeometryTaggedText(
+ S2Geography geometry,
+ EnumSet<Ordinate> outputOrdinates,
+ boolean useFormatting,
+ int level,
+ Writer writer,
+ OrdinateFormat formatter)
+ throws IOException {
+ // indent before writing
+ indent(useFormatting, level, writer);
+
+ // ——— Handle Points ———
+ if (geometry instanceof SinglePointGeography) {
+ appendPointTaggedText(
+ (SinglePointGeography) geometry,
+ outputOrdinates,
+ useFormatting,
+ level,
+ writer,
+ formatter);
+ return;
+ }
+
+ if (geometry instanceof PointGeography) {
+ appendMultiPointTaggedText(
+ (PointGeography) geometry, outputOrdinates, useFormatting, level, writer, formatter);
+ return;
+ }
+
+ // ——— Handle LineStrings ———
+ if (geometry instanceof SinglePolylineGeography) {
+ appendPolylineTaggedText(
+ (SinglePolylineGeography) geometry,
+ outputOrdinates,
+ useFormatting,
+ level,
+ writer,
+ formatter);
+ return;
+ }
+
+ if (geometry instanceof PolylineGeography) {
+ appendMultiLineStringTaggedText(
+ (PolylineGeography) geometry, outputOrdinates, useFormatting, level, writer, formatter);
+ return;
+ }
+
+ // ——— Handle Polygons ———
+ if (geometry instanceof PolygonGeography) {
+ appendPolygonTaggedText(
+ (PolygonGeography) geometry, outputOrdinates, useFormatting, level, writer, formatter);
+ return;
+ }
+
+ if (geometry instanceof MultiPolygonGeography) {
+ appendMultiPolygonTaggedText(
+ (MultiPolygonGeography) geometry,
+ outputOrdinates,
+ useFormatting,
+ level,
+ writer,
+ formatter);
+ return;
+ }
+
+ // ——— Handle Collections ———
+ if (geometry instanceof GeographyCollection) {
+ appendGeometryCollectionTaggedText(
+ (GeographyCollection) geometry, outputOrdinates, useFormatting, level, writer, formatter);
+ return;
+ }
+
+ Assert.shouldNeverReachHere("Unsupported Geometry implementation: " + geometry.getClass());
+ }
+
+ /**
+ * Converts a <code>Coordinate</code> to <Point Tagged Text> format, then appends it to the
+ * writer.
+ *
+ * @param point the <code>Point</code> to process
+ * @param useFormatting flag indicating that the output should be formatted
+ * @param level the indentation level
+ * @param writer the output writer to append to
+ * @param formatter the formatter to use when writing numbers
+ */
+ private void appendPointTaggedText(
+ PointGeography point,
+ EnumSet<Ordinate> outputOrdinates,
+ boolean useFormatting,
+ int level,
+ Writer writer,
+ OrdinateFormat formatter)
+ throws IOException {
+ writer.write(WKTConstants.POINT);
+ writer.write(" ");
+ appendOrdinateText(outputOrdinates, writer);
+ appendSequenceText(
+ point.getCoordinateSequence(),
+ outputOrdinates,
+ useFormatting,
+ level,
+ false,
+ writer,
+ formatter);
+ }
+
+ /**
+ * Converts a <code>LineString</code> to <LineString Tagged Text> format, then appends it to
+ * the writer.
+ *
+ * @param lineString the <code>LineString</code> to process
+ * @param useFormatting flag indicating that the output should be formatted
+ * @param level the indentation level
+ * @param writer the output writer to append to
+ * @param formatter the <code>DecimalFormatter</code> to use to convert from a precise coordinate
+ * to an external coordinate
+ */
+ private void appendPolylineTaggedText(
+ PolylineGeography lineString,
+ EnumSet<Ordinate> outputOrdinates,
+ boolean useFormatting,
+ int level,
+ Writer writer,
+ OrdinateFormat formatter)
+ throws IOException {
+ writer.write(WKTConstants.LINESTRING);
+ writer.write(" ");
+ appendOrdinateText(outputOrdinates, writer);
+ appendSequenceText(
+ lineString.getCoordinateSequence(),
+ outputOrdinates,
+ useFormatting,
+ level,
+ false,
+ writer,
+ formatter);
+ }
+ /**
+ * Converts a <code>Polygon</code> to <Polygon Tagged Text> format, then appends it to the
+ * writer.
+ *
+ * @param polygon the <code>Polygon</code> to process
+ * @param useFormatting flag indicating that the output should be formatted
+ * @param level the indentation level
+ * @param writer the output writer to append to
+ * @param formatter the <code>DecimalFormatter</code> to use to convert from a precise coordinate
+ * to an external coordinate
+ */
+ private void appendPolygonTaggedText(
+ PolygonGeography polygon,
+ EnumSet<Ordinate> outputOrdinates,
+ boolean useFormatting,
+ int level,
+ Writer writer,
+ OrdinateFormat formatter)
+ throws IOException {
+ writer.write(WKTConstants.POLYGON);
+ writer.write(" ");
+ appendOrdinateText(outputOrdinates, writer);
+ appendPolygonText(polygon, outputOrdinates, useFormatting, level, false, writer, formatter);
+ }
+
+ /**
+ * Converts a <code>MultiPoint</code> to <MultiPoint Tagged Text> format, then appends it to
+ * the writer.
+ *
+ * @param multipoint the <code>MultiPoint</code> to process
+ * @param useFormatting flag indicating that the output should be formatted
+ * @param level the indentation level
+ * @param writer the output writer to append to
+ * @param formatter the <code>DecimalFormatter</code> to use to convert from a precise coordinate
+ * to an external coordinate
+ */
+ private void appendMultiPointTaggedText(
+ PointGeography multipoint,
+ EnumSet<Ordinate> outputOrdinates,
+ boolean useFormatting,
+ int level,
+ Writer writer,
+ OrdinateFormat formatter)
+ throws IOException {
+ writer.write(WKTConstants.MULTIPOINT);
+ writer.write(" ");
+ appendOrdinateText(outputOrdinates, writer);
+ appendMultiPointText(multipoint, outputOrdinates, useFormatting, level, writer, formatter);
+ }
+
+ /**
+ * Converts a <code>MultiLineString</code> to <MultiLineString Tagged Text> format, then
+ * appends it to the writer.
+ *
+ * @param multiLineString the <code>MultiLineString</code> to process
+ * @param useFormatting flag indicating that the output should be formatted
+ * @param level the indentation level
+ * @param writer the output writer to append to
+ * @param formatter the <code>DecimalFormatter</code> to use to convert from a precise coordinate
+ * to an external coordinate
+ */
+ private void appendMultiLineStringTaggedText(
+ PolylineGeography multiLineString,
+ EnumSet<Ordinate> outputOrdinates,
+ boolean useFormatting,
+ int level,
+ Writer writer,
+ OrdinateFormat formatter)
+ throws IOException {
+ writer.write(WKTConstants.MULTILINESTRING);
+ writer.write(" ");
+ appendOrdinateText(outputOrdinates, writer);
+ appendMultiLineStringText(
+ multiLineString, outputOrdinates, useFormatting, level, /*false, */ writer, formatter);
+ }
+
+ /**
+ * Converts a <code>MultiPolygon</code> to <MultiPolygon Tagged Text> format, then appends
+ * it to the writer.
+ *
+ * @param multiPolygon the <code>MultiPolygon</code> to process
+ * @param useFormatting flag indicating that the output should be formatted
+ * @param level the indentation level
+ * @param writer the output writer to append to
+ * @param formatter the <code>DecimalFormatter</code> to use to convert from a precise coordinate
+ * to an external coordinate
+ */
+ private void appendMultiPolygonTaggedText(
+ MultiPolygonGeography multiPolygon,
+ EnumSet<Ordinate> outputOrdinates,
+ boolean useFormatting,
+ int level,
+ Writer writer,
+ OrdinateFormat formatter)
+ throws IOException {
+ writer.write(WKTConstants.MULTIPOLYGON);
+ writer.write(" ");
+ appendOrdinateText(outputOrdinates, writer);
+ appendMultiPolygonText(multiPolygon, outputOrdinates, useFormatting, level, writer, formatter);
+ }
+
+ /**
+ * Converts a <code>GeometryCollection</code> to <GeometryCollection Tagged Text> format,
+ * then appends it to the writer.
+ *
+ * @param geometryCollection the <code>GeometryCollection</code> to process
+ * @param useFormatting flag indicating that the output should be formatted
+ * @param level the indentation level
+ * @param writer the output writer to append to
+ * @param formatter the <code>DecimalFormatter</code> to use to convert from a precise coordinate
+ * to an external coordinate
+ */
+ private void appendGeometryCollectionTaggedText(
+ GeographyCollection geometryCollection,
+ EnumSet<Ordinate> outputOrdinates,
+ boolean useFormatting,
+ int level,
+ Writer writer,
+ OrdinateFormat formatter)
+ throws IOException {
+ writer.write(WKTConstants.GEOMETRYCOLLECTION);
+ writer.write(" ");
+ appendOrdinateText(outputOrdinates, writer);
+ appendGeometryCollectionText(
+ geometryCollection, outputOrdinates, useFormatting, level, writer, formatter);
+ }
+
+ /**
+ * Appends the i'th coordinate from the sequence to the writer
+ *
+ * <p>If the {@code seq} has coordinates that are {@link double.NAN}, these are not written, even
+ * though {@link #outputDimension} suggests this.
+ *
+ * @param seq the <code>CoordinateSequence</code> to process
+ * @param i the index of the coordinate to write
+ * @param writer the output writer to append to
+ * @param formatter the formatter to use for writing ordinate values
+ */
+ private void appendCoordinate(
+ CoordinateSequence seq,
+ EnumSet<Ordinate> outputOrdinates,
+ int i,
+ Writer writer,
+ OrdinateFormat formatter)
+ throws IOException {
+ writer.write(writeNumber(seq.getX(i), formatter) + " " + writeNumber(seq.getY(i), formatter));
+
+ if (outputOrdinates.contains(Ordinate.Z)) {
+ writer.write(" ");
+ writer.write(writeNumber(seq.getZ(i), formatter));
+ }
+
+ if (outputOrdinates.contains(Ordinate.M)) {
+ writer.write(" ");
+ writer.write(writeNumber(seq.getM(i), formatter));
+ }
+ }
+
+ /**
+ * Converts a <code>double</code> to a <code>String</code>, not in scientific notation.
+ *
+ * @param d the <code>double</code> to convert
+ * @return the <code>double</code> as a <code>String</code>, not in scientific notation
+ */
+ private static String writeNumber(double d, OrdinateFormat formatter) {
+ return formatter.format(d);
+ }
+
+ /**
+ * Appends additional ordinate information. This function may
+ *
+ * <ul>
+ * <li>append 'Z' if in {@code outputOrdinates} the {@link Ordinate#Z} value is included
+ * <li>append 'M' if in {@code outputOrdinates} the {@link Ordinate#M} value is included
+ * <li>append 'ZM' if in {@code outputOrdinates} the {@link Ordinate#Z} and {@link Ordinate#M}
+ * values are included
+ * </ul>
+ *
+ * @param outputOrdinates a bit-pattern of ordinates to write.
+ * @param writer the output writer to append to.
+ * @throws IOException if an error occurs while using the writer.
+ */
+ private void appendOrdinateText(EnumSet<Ordinate> outputOrdinates, Writer writer)
+ throws IOException {
+
+ if (outputOrdinates.contains(Ordinate.Z)) writer.append(WKTConstants.Z);
+ if (outputOrdinates.contains(Ordinate.M)) writer.append(WKTConstants.M);
+ }
+
+ /**
+ * Appends all members of a <code>CoordinateSequence</code> to the stream. Each {@code Coordinate}
+ * is separated from another using a colon, the ordinates of a {@code Coordinate} are separated by
+ * a space.
+ *
+ * @param seq the <code>CoordinateSequence</code> to process
+ * @param useFormatting flag indicating that
+ * @param level the indentation level
+ * @param indentFirst flag indicating that the first {@code Coordinate} of the sequence should be
+ * indented for better visibility
+ * @param writer the output writer to append to
+ * @param formatter the formatter to use for writing ordinate values.
+ */
+ private void appendSequenceText(
+ CoordinateSequence seq,
+ EnumSet<Ordinate> outputOrdinates,
+ boolean useFormatting,
+ int level,
+ boolean indentFirst,
+ Writer writer,
+ OrdinateFormat formatter)
+ throws IOException {
+ if (seq.size() == 0) {
+ writer.write(WKTConstants.EMPTY);
+ } else {
+ if (indentFirst) indent(useFormatting, level, writer);
+ writer.write("(");
+ for (int i = 0; i < seq.size(); i++) {
+ if (i > 0) {
+ writer.write(", ");
+ if (coordsPerLine > 0 && i % coordsPerLine == 0) {
+ indent(useFormatting, level + 1, writer);
+ }
+ }
+ appendCoordinate(seq, outputOrdinates, i, writer, formatter);
+ }
+ writer.write(")");
+ }
+ }
+
+ /**
+ * Converts a <code>Polygon</code> to <Polygon Text> format, then appends it to the writer.
+ *
+ * @param polygon the <code>Polygon</code> to process
+ * @param useFormatting flag indicating that
+ * @param level the indentation level
+ * @param indentFirst flag indicating that the first {@code Coordinate} of the sequence should be
+ * indented for better visibility
+ * @param writer the output writer to append to
+ * @param formatter the formatter to use for writing ordinate values.
+ */
+ private void appendPolygonText(
+ PolygonGeography polygon,
+ EnumSet<Ordinate> outputOrdinates,
+ boolean useFormatting,
+ int level,
+ boolean indentFirst,
+ Writer writer,
+ OrdinateFormat formatter)
+ throws IOException {
+ if (polygon == null || polygon.polygon.isEmpty()) {
+ writer.write(WKTConstants.EMPTY);
+ } else {
+ if (indentFirst) indent(useFormatting, level, writer);
+ writer.write("(");
+ appendSequenceText(
+ polygon.getExteriorRing().getCoordinateSequence(),
+ outputOrdinates,
+ useFormatting,
+ level,
+ false,
+ writer,
+ formatter);
+ for (LineString hole : polygon.getLoops()) {
+ writer.write(", ");
+ appendSequenceText(
+ hole.getCoordinateSequence(),
+ outputOrdinates,
+ useFormatting,
+ level + 1,
+ true,
+ writer,
+ formatter);
+ }
+ writer.write(")");
+ }
+ }
+
+ /**
+ * Converts a <code>MultiPoint</code> to <MultiPoint Text> format, then appends it to the
+ * writer.
+ *
+ * @param multiPoint the <code>MultiPoint</code> to process
+ * @param useFormatting flag indicating that
+ * @param level the indentation level
+ * @param writer the output writer to append to
+ * @param formatter the formatter to use for writing ordinate values.
+ */
+ private void appendMultiPointText(
+ PointGeography multiPoint,
+ EnumSet<Ordinate> outputOrdinates,
+ boolean useFormatting,
+ int level,
+ Writer writer,
+ OrdinateFormat formatter)
+ throws IOException {
+ if (multiPoint.numShapes() == 0) {
+ writer.write(WKTConstants.EMPTY);
+ } else {
+ writer.write("(");
+ CoordinateSequence sequence = multiPoint.getCoordinateSequence();
+ for (int i = 0; i < sequence.size(); i++) {
+ if (i > 0) {
+ writer.write(", ");
+ indentCoords(useFormatting, i, level + 1, writer);
+ }
+ appendSequenceText(
+ new SinglePointGeography(multiPoint.getPoints().get(i)).getCoordinateSequence(),
+ outputOrdinates,
+ useFormatting,
+ level,
+ false,
+ writer,
+ formatter);
+ }
+ writer.write(")");
+ }
+ }
+
+ /**
+ * Converts a <code>MultiLineString</code> to <MultiLineString Text> format, then appends it
+ * to the writer.
+ *
+ * @param multiLineString the <code>MultiLineString</code> to process
+ * @param useFormatting flag indicating that
+ * @param level the indentation level //@param indentFirst flag indicating that the first {@code
+ * Coordinate} of the sequence should be indented for // better visibility
+ * @param writer the output writer to append to
+ * @param formatter the formatter to use for writing ordinate values.
+ */
+ private void appendMultiLineStringText(
+ PolylineGeography multiLineString,
+ EnumSet<Ordinate> outputOrdinates,
+ boolean useFormatting,
+ int level, /*boolean indentFirst, */
+ Writer writer,
+ OrdinateFormat formatter)
+ throws IOException {
+ if (multiLineString.numShapes() == 0) {
+ writer.write(WKTConstants.EMPTY);
+ } else {
+ int level2 = level;
+ boolean doIndent = false;
+ writer.write("(");
+ for (int i = 0; i < multiLineString.numShapes(); i++) {
+ if (i > 0) {
+ writer.write(", ");
+ level2 = level + 1;
+ doIndent = true;
+ }
+ appendSequenceText(
+ new SinglePolylineGeography(multiLineString.getPolylines().get(i))
+ .getCoordinateSequence(),
+ outputOrdinates,
+ useFormatting,
+ level2,
+ doIndent,
+ writer,
+ formatter);
+ }
+ writer.write(")");
+ }
+ }
+
+ /**
+ * Converts a <code>MultiPolygon</code> to <MultiPolygon Text> format, then appends it to
+ * the writer.
+ *
+ * @param multiPolygon the <code>MultiPolygon</code> to process
+ * @param useFormatting flag indicating that
+ * @param level the indentation level
+ * @param writer the output writer to append to
+ * @param formatter the formatter to use for writing ordinate values.
+ */
+ private void appendMultiPolygonText(
+ MultiPolygonGeography multiPolygon,
+ EnumSet<Ordinate> outputOrdinates,
+ boolean useFormatting,
+ int level,
+ Writer writer,
+ OrdinateFormat formatter)
+ throws IOException {
+ if (multiPolygon.numShapes() == 0) {
+ writer.write(WKTConstants.EMPTY);
+ } else {
+ int level2 = level;
+ boolean doIndent = false;
+ writer.write("(");
+ for (int i = 0; i < multiPolygon.numShapes(); i++) {
+ if (i > 0) {
+ writer.write(", ");
+ level2 = level + 1;
+ doIndent = true;
+ }
+ appendPolygonText(
+ (PolygonGeography) multiPolygon.getFeatures().get(i),
+ outputOrdinates,
+ useFormatting,
+ level2,
+ doIndent,
+ writer,
+ formatter);
+ }
+ writer.write(")");
+ }
+ }
+
+ /**
+ * Converts a <code>GeometryCollection</code> to <GeometryCollectionText> format, then
+ * appends it to the writer.
+ *
+ * @param geometryCollection the <code>GeometryCollection</code> to process
+ * @param useFormatting flag indicating that
+ * @param level the indentation level
+ * @param writer the output writer to append to
+ * @param formatter the formatter to use for writing ordinate values.
+ */
+ private void appendGeometryCollectionText(
+ GeographyCollection geometryCollection,
+ EnumSet<Ordinate> outputOrdinates,
+ boolean useFormatting,
+ int level,
+ Writer writer,
+ OrdinateFormat formatter)
+ throws IOException {
+ if (geometryCollection.numShapes() == 0) {
+ writer.write(WKTConstants.EMPTY);
+ } else {
+ int level2 = level;
+ writer.write("(");
+ for (int i = 0; i < geometryCollection.numShapes(); i++) {
+ if (i > 0) {
+ writer.write(", ");
+ level2 = level + 1;
+ }
+ appendGeometryTaggedText(
+ geometryCollection.getFeatures().get(i),
+ outputOrdinates,
+ useFormatting,
+ level2,
+ writer,
+ formatter);
+ }
+ writer.write(")");
+ }
+ }
+
+ private void indentCoords(boolean useFormatting, int coordIndex, int level, Writer writer)
+ throws IOException {
+ if (coordsPerLine <= 0 || coordIndex % coordsPerLine != 0) return;
+ indent(useFormatting, level, writer);
+ }
+
+ private void indent(boolean useFormatting, int level, Writer writer) throws IOException {
+ if (!useFormatting || level <= 0) return;
+ writer.write("\n");
+ for (int i = 0; i < level; i++) {
+ writer.write(indentTabStr);
+ }
+ }
+}
diff --git a/common/src/test/java/org/apache/sedona/common/S2Geography/TestHelper.java b/common/src/test/java/org/apache/sedona/common/S2Geography/TestHelper.java
index 5449584859..eb8eae88fc 100644
--- a/common/src/test/java/org/apache/sedona/common/S2Geography/TestHelper.java
+++ b/common/src/test/java/org/apache/sedona/common/S2Geography/TestHelper.java
@@ -18,6 +18,7 @@
*/
package org.apache.sedona.common.S2Geography;
+import static org.apache.sedona.common.S2Geography.Accessors.S2_isEmpty;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
@@ -27,9 +28,12 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
+import org.locationtech.jts.io.ParseException;
public class TestHelper {
+ private static final double EPS = 1e-6;
+
public static void assertRoundTrip(S2Geography original, EncodeOptions opts) throws IOException {
// 1) Encode to bytes
ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -148,4 +152,118 @@ public class TestHelper {
sb.append("))");
return sb.toString();
}
+
+ public static void checkWKBGeography(String wkbHex, String expectedWKT) throws ParseException {
+ WKBReader wkbReader = new WKBReader();
+ byte[] wkb = WKBReader.hexToBytes(wkbHex);
+ S2Geography geoWKB = wkbReader.read(wkb);
+
+ WKTReader wktReader = new WKTReader();
+ S2Geography geoWKT = wktReader.read(expectedWKT);
+
+ boolean isEqual = compareTo(geoWKT, geoWKT) == 0;
+ if (!isEqual) {
+ System.out.println(geoWKB);
+ System.out.println(geoWKT);
+ }
+ assertTrue(isEqual);
+ }
+
+ public static int compareTo(S2Geography geo1, S2Geography geo2) {
+ int compare = geo1.kind.getKind() - geo2.kind.getKind();
+ if (compare != 0) {
+ return compare;
+ }
+
+ compare = Integer.compare(geo1.numShapes(), geo2.numShapes());
+ if (compare != 0) return compare;
+
+ if (geo1 instanceof SinglePointGeography && geo2 instanceof SinglePointGeography) {
+ if (S2_isEmpty(geo1) && S2_isEmpty(geo2)) return 0;
+ assertPointEquals(
+ ((SinglePointGeography) geo1).getPoints().get(0),
+ ((SinglePointGeography) geo2).getPoints().get(0));
+ } else if (geo1 instanceof PointGeography && geo2 instanceof PointGeography) {
+ if (S2_isEmpty(geo1) && S2_isEmpty(geo2)) return 0;
+ checkPointShape((PointGeography) geo1, (PointGeography) geo2);
+ } else if (geo1 instanceof SinglePolylineGeography && geo2 instanceof SinglePolylineGeography) {
+ if (S2_isEmpty(geo1) && S2_isEmpty(geo2)) return 0;
+ checkPolylineShape((PolylineGeography) geo1, (PolylineGeography) geo2);
+ } else if (geo1 instanceof PolylineGeography && geo2 instanceof PolylineGeography) {
+ if (S2_isEmpty(geo1) && S2_isEmpty(geo2)) return 0;
+ checkPolylineShape((PolylineGeography) geo1, (PolylineGeography) geo2);
+ } else if (geo1 instanceof PolygonGeography && geo2 instanceof PolygonGeography) {
+ if (S2_isEmpty(geo1) && S2_isEmpty(geo2)) return 0;
+ checkPolygonShape((PolygonGeography) geo1, (PolygonGeography) geo2);
+ } else if (geo1 instanceof MultiPolygonGeography && geo2 instanceof MultiPolygonGeography) {
+ if (S2_isEmpty(geo1) && S2_isEmpty(geo2)) return 0;
+ assertEquals(geo1.numShapes(), geo2.numShapes());
+ for (int i = 0; i < geo1.numShapes(); i++) {
+ PolygonGeography poly1 = (PolygonGeography) ((MultiPolygonGeography) geo1).features.get(i);
+ PolygonGeography poly2 = (PolygonGeography) ((MultiPolygonGeography) geo1).features.get(i);
+ checkPolygonShape(poly1, poly2);
+ }
+ } else if (geo1 instanceof GeographyCollection && geo2 instanceof GeographyCollection) {
+ if (S2_isEmpty(geo1) && S2_isEmpty(geo2)) return 0;
+ assertEquals(geo1.numShapes(), geo2.numShapes());
+ for (int i = 0; i < geo1.numShapes(); i++) {
+ S2Geography g1 = (S2Geography) ((GeographyCollection) geo1).features.get(i);
+ S2Geography g2 = (S2Geography) ((GeographyCollection) geo2).features.get(i);
+ compareTo(g1, g2);
+ }
+ }
+ return 0;
+ }
+
+ private static void checkPointShape(PointGeography point1, PointGeography point2) {
+ List<S2Point> pOrig = point1.getPoints();
+ List<S2Point> pDec = point2.getPoints();
+ assertEquals("Point count", pOrig.size(), pDec.size());
+ for (int i = 0; i < pOrig.size(); i++) {
+ assertPointEquals(pOrig.get(i), pDec.get(i));
+ }
+ }
+
+ private static void checkPolylineShape(PolylineGeography line1, PolylineGeography line2) {
+ assertEquals("Line count", line1.getPolylines().size(), line2.getPolylines().size());
+ List<S2Polyline> lineOrig = line1.getPolylines();
+ List<S2Polyline> lineDec = line2.getPolylines();
+ for (int i = 0; i < lineOrig.size(); i++) {
+ List<S2Point> point1 = lineOrig.get(i).vertices();
+ List<S2Point> point2 = lineDec.get(i).vertices();
+ assertEquals("Vertex count", point1.size(), point2.size());
+ for (int v = 0; v < point1.size(); v++) {
+ assertPointEquals(point1.get(v), point2.get(v));
+ }
+ }
+ }
+
+ private static void checkPolygonShape(PolygonGeography poly1, PolygonGeography poly2) {
+ // may have multiple rings
+ assertEquals("Ring count", poly1.polygon.numLoops(), poly2.polygon.numLoops());
+
+ for (int li = 0; li < poly1.polygon.numLoops(); li++) {
+ S2Loop loop1 = poly1.polygon.loop(li);
+ S2Loop loop2 = poly2.polygon.loop(li);
+
+ assertEquals(loop1.numVertices(), loop2.numVertices());
+ // number of vertices in this loop
+ assertEquals(
+ String.format("Vertex count in loop[%d]", li), loop1.numVertices(), loop2.numVertices());
+
+ // compare each vertex
+ for (int vi = 0; vi < loop1.numVertices(); vi++) {
+ S2Point v1 = loop1.vertex(vi);
+ S2Point v2 = loop2.vertex(vi);
+ assertPointEquals(v1, v2);
+ }
+ }
+ }
+
+ private static void assertPointEquals(S2Point p1, S2Point p2) {
+ S2LatLng ll1 = S2LatLng.fromPoint(p1);
+ S2LatLng ll2 = S2LatLng.fromPoint(p2);
+ assertEquals("Latitude", ll1.latDegrees(), ll2.latDegrees(), EPS);
+ assertEquals("Longitude", ll1.lngDegrees(), ll2.lngDegrees(), EPS);
+ }
}
diff --git a/common/src/test/java/org/apache/sedona/common/S2Geography/WKBReaderTest.java b/common/src/test/java/org/apache/sedona/common/S2Geography/WKBReaderTest.java
new file mode 100644
index 0000000000..76a2086eeb
--- /dev/null
+++ b/common/src/test/java/org/apache/sedona/common/S2Geography/WKBReaderTest.java
@@ -0,0 +1,385 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sedona.common.S2Geography;
+
+import com.google.common.geometry.S2Point;
+import java.util.List;
+import org.junit.Assert;
+import org.junit.Test;
+import org.locationtech.jts.io.ParseException;
+
+public class WKBReaderTest {
+ @Test
+ public void PointGeographyTest() throws ParseException {
+ // WKB for POINT (30 10), little-endian
+ byte[] wkb =
+ new byte[] {
+ 0x01, // little endian
+ 0x01,
+ 0x00,
+ 0x00,
+ 0x00, // type = Point (1)
+ // X = 30.0
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x3E,
+ 0x40,
+ // Y = 10.0
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x24,
+ 0x40
+ };
+
+ WKBReader reader = new WKBReader();
+ S2Geography geo = reader.read(wkb);
+
+ // Kind should be POINT
+ Assert.assertEquals(S2Geography.GeographyKind.POINT, geo.kind);
+
+ // Extract the S2Point
+ S2Point p = geo.shape(0).chain(0).get(0);
+ Assert.assertNotNull(p);
+
+ // Convert to WKT via your TestHelper (or manually check coords)
+ String wkt = TestHelper.toPointWkt(p);
+ // The WKTWriter default formatting uses 6 decimal places
+ String expected = "POINT (30.000000 10.000000)";
+ Assert.assertEquals(expected, wkt);
+ }
+
+ @Test
+ public void PolylineGeographyTest() throws ParseException {
+ // WKB for LINESTRING (30 10, 12 42), little-endian
+ byte[] wkb =
+ new byte[] {
+ 0x01, // little endian
+ 0x02,
+ 0x00,
+ 0x00,
+ 0x00, // type = LineString (2)
+ 0x02,
+ 0x00,
+ 0x00,
+ 0x00, // num points = 2
+ // Point 1: X = 30.0, Y = 10.0
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x3E,
+ 0x40,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x24,
+ 0x40,
+ // Point 2: X = 12.0, Y = 42.0
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x28,
+ 0x40,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x45,
+ 0x40
+ };
+
+ WKBReader reader = new WKBReader();
+ S2Geography geo = reader.read(wkb);
+
+ // Expect a POLYLINE geometry
+ Assert.assertEquals(S2Geography.GeographyKind.POLYLINE, geo.kind);
+
+ // Extract the two S2Points
+ List<S2Point> pts = geo.shape(0).chain(0);
+ Assert.assertEquals(2, pts.size());
+
+ // Check their WKT
+ String wkt1 = TestHelper.toPointWkt(pts.get(0));
+ String wkt2 = TestHelper.toPointWkt(pts.get(1));
+ Assert.assertEquals("POINT (30.000000 10.000000)", wkt1);
+ Assert.assertEquals("POINT (12.000000 42.000000)", wkt2);
+ }
+
+ @Test
+ public void PolygonGeographyTest() throws ParseException {
+ // WKB for POLYGON ((35 10,45 45,15 40,10 20,35 10),(20 30,35 35,30 20,20 30)), little-endian
+ byte[] wkb =
+ new byte[] {
+ 0x01, // little endian
+ 0x03,
+ 0x00,
+ 0x00,
+ 0x00, // type = Polygon (3)
+ 0x02,
+ 0x00,
+ 0x00,
+ 0x00, // num rings = 2
+
+ // Ring 1: 5 points
+ 0x05,
+ 0x00,
+ 0x00,
+ 0x00,
+ // (35.0, 10.0)
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ (byte) 0x80,
+ 0x41,
+ 0x40,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x24,
+ 0x40,
+ // (45.0, 45.0)
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ (byte) 0x80,
+ 0x46,
+ 0x40,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ (byte) 0x80,
+ 0x46,
+ 0x40,
+ // (15.0, 40.0)
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x2E,
+ 0x40,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x44,
+ 0x40,
+ // (10.0, 20.0)
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x24,
+ 0x40,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x34,
+ 0x40,
+ // (35.0, 10.0) closing
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ (byte) 0x80,
+ 0x41,
+ 0x40,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x24,
+ 0x40,
+
+ // Ring 2: 4 points
+ 0x04,
+ 0x00,
+ 0x00,
+ 0x00,
+ // (20.0, 30.0)
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x34,
+ 0x40,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x3E,
+ 0x40,
+ // (35.0, 35.0)
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ (byte) 0x80,
+ 0x41,
+ 0x40,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ (byte) 0x80,
+ 0x41,
+ 0x40,
+ // (30.0, 20.0)
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x3E,
+ 0x40,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x34,
+ 0x40,
+ // (20.0, 30.0) closing
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x34,
+ 0x40,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x3E,
+ 0x40
+ };
+
+ WKBReader reader = new WKBReader();
+ S2Geography geo = reader.read(wkb);
+
+ // Expect a POLYGON geometry
+ Assert.assertEquals(S2Geography.GeographyKind.POLYGON, geo.kind);
+
+ // Outer ring: 4 points
+ List<S2Point> outer = geo.shape(0).chain(0);
+ Assert.assertEquals(4, outer.size());
+ Assert.assertEquals("POINT (35.000000 10.000000)", TestHelper.toPointWkt(outer.get(0)));
+ Assert.assertEquals("POINT (45.000000 45.000000)", TestHelper.toPointWkt(outer.get(1)));
+ Assert.assertEquals("POINT (15.000000 40.000000)", TestHelper.toPointWkt(outer.get(2)));
+ Assert.assertEquals("POINT (10.000000 20.000000)", TestHelper.toPointWkt(outer.get(3)));
+
+ // Hole ring: 3 points
+ List<S2Point> hole = geo.shape(0).chain(1);
+ Assert.assertEquals(3, hole.size());
+ Assert.assertEquals("POINT (20.000000 30.000000)", TestHelper.toPointWkt(hole.get(0)));
+ Assert.assertEquals("POINT (35.000000 35.000000)", TestHelper.toPointWkt(hole.get(1)));
+ Assert.assertEquals("POINT (30.000000 20.000000)", TestHelper.toPointWkt(hole.get(2)));
+ }
+
+ @Test
+ public void MultiPointTest() throws ParseException {
+ String hexStr =
+ "0104000000020000006901000000000000000000F03F000000000000F03F690100000000000000000000400000000000000040";
+ String wkt = "MULTIPOINT((1 1),(2 2))";
+ TestHelper.checkWKBGeography(hexStr, wkt);
+ }
+
+ @Test
+ public void MultiPolylineTest() throws ParseException {
+ String hexStr =
+ "0105000020E6100000020000000102000000020000000000000000000000000000000000F03F000000000000004000000000000008400102000000020000000000000000001040000000000000144000000000000018400000000000001C40";
+ String wkt = "MULTILINESTRING((0 1,2 3),(4 5,6 7))";
+ TestHelper.checkWKBGeography(hexStr, wkt);
+ }
+
+ @Test
+ public void MultiPolygonTest() throws ParseException {
+ String hexStr =
+ "0106000020E6100000020000000103000020E61000000200000005000000000000000000000000000000000000000000000000000000000000000000244000000000000024400000000000002440000000000000244000000000000000000000000000000000000000000000000005000000000000000000F03F000000000000F03F000000000000F03F0000000000002240000000000000224000000000000022400000000000002240000000000000F03F000000000000F03F000000000000F03F0103000020E6100000010000000500000000000000000022C0000000000000000000000000000022C00000000000002 [...]
+ String multiPolygonWkt =
+ "MULTIPOLYGON(((0 0,0 10,10 10,10 0,0 0),(1 1,1 9,9 9,9 1,1 1)),((-9 0,-9 10,-1 10,-1 0,-9 0)))";
+ TestHelper.checkWKBGeography(hexStr, multiPolygonWkt);
+ }
+
+ @Test
+ public void CollectionGeographyTest() throws ParseException {
+ String hexStr =
+ "0107000020E6100000090000000101000020E61000000000000000000000000000000000F03F0101000020E61000000000000000000000000000000000F03F0101000020E6100000000000000000004000000000000008400102000020E61000000200000000000000000000400000000000000840000000000000104000000000000014400102000020E6100000020000000000000000000000000000000000F03F000000000000004000000000000008400102000020E6100000020000000000000000001040000000000000144000000000000018400000000000001C400103000020E61000000200000005000000000 [...]
+ String geometryCollectionWkt =
+ "GEOMETRYCOLLECTION(POINT(0 1),POINT(0 1),POINT(2 3),LINESTRING(2 3,4 5),LINESTRING(0 1,2 3),LINESTRING(4 5,6 7),POLYGON((0 0,0 10,10 10,10 0,0 0),(1 1,1 9,9 9,9 1,1 1)),POLYGON((0 0,0 10,10 10,10 0,0 0),(1 1,1 9,9 9,9 1,1 1)),POLYGON((-9 0,-9 10,-1 10,-1 0,-9 0)))";
+ TestHelper.checkWKBGeography(hexStr, geometryCollectionWkt);
+ }
+
+ @Test
+ public void EmptyTest() throws ParseException {
+ TestHelper.checkWKBGeography("0101000000", "POINT EMPTY");
+ TestHelper.checkWKBGeography("010300000000000000", "POLYGON EMPTY");
+ TestHelper.checkWKBGeography("010200000000000000", "LINESTRING EMPTY");
+ }
+}
diff --git a/common/src/test/java/org/apache/sedona/common/S2Geography/WKBWriterTest.java b/common/src/test/java/org/apache/sedona/common/S2Geography/WKBWriterTest.java
new file mode 100644
index 0000000000..48b43d8a74
--- /dev/null
+++ b/common/src/test/java/org/apache/sedona/common/S2Geography/WKBWriterTest.java
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sedona.common.S2Geography;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.common.geometry.S2LatLng;
+import com.google.common.geometry.S2Point;
+import com.google.common.geometry.S2Polyline;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+import org.locationtech.jts.io.ByteOrderValues;
+import org.locationtech.jts.io.ParseException;
+
+public class WKBWriterTest {
+
+ @Test
+ public void PointGeographyToHexTest() throws IOException, ParseException {
+ // 1) create an S2Point at (lat=10, lon=30)
+ S2Point s2Pt = S2LatLng.fromDegrees(10.0, 30.0).toPoint();
+ SinglePointGeography inputGeo = new SinglePointGeography(s2Pt);
+
+ // 2) write it to 2D little‐endian WKB
+ WKBWriter writer = new WKBWriter(2, ByteOrderValues.LITTLE_ENDIAN);
+ byte[] wkb = writer.write(inputGeo);
+
+ // 3) read it back with WKBReader
+ WKBReader reader = new WKBReader();
+ PointGeography outputGeo = (PointGeography) reader.read(wkb);
+
+ // 4) extract the decoded S2Point + verify lat/lng round‐trip exactly
+ S2Point decoded = outputGeo.points.get(0);
+ S2LatLng decodedLL = S2LatLng.fromPoint(decoded);
+ assertEquals(10.0, decodedLL.latDegrees(), 1e-12);
+ assertEquals(30.0, decodedLL.lngDegrees(), 1e-12);
+ }
+
+ /** Same idea for a simple LINESTRING: hex-encode and compare. */
+ @Test
+ public void PolylineGeographyToHexTest() throws IOException, ParseException {
+ // 1) build a simple S2Polyline LINESTRING(30 10, 12 42)
+ List<S2Point> pts =
+ List.of(
+ S2LatLng.fromDegrees(10.0, 30.0).toPoint(), S2LatLng.fromDegrees(42.0, 12.0).toPoint());
+ SinglePolylineGeography inputLine = new SinglePolylineGeography(new S2Polyline(pts));
+
+ // 2) write it to 2D little‐endian WKB
+ WKBWriter writer = new WKBWriter(2, ByteOrderValues.LITTLE_ENDIAN);
+ byte[] wkb = writer.write(inputLine);
+
+ // 3) read it back
+ WKBReader reader = new WKBReader();
+ PolylineGeography outputLine = (PolylineGeography) reader.read(wkb);
+
+ // 4) verify point‐by‐point round‐trip
+ List<S2Polyline> polylines = outputLine.getPolylines();
+ List<S2Point> decoded = new ArrayList<>();
+ for (S2Polyline polyline : polylines) {
+ decoded.addAll(polyline.vertices());
+ }
+ assertEquals(pts.size(), decoded.size());
+ for (int i = 0; i < pts.size(); i++) {
+ S2LatLng origLL = S2LatLng.fromPoint(pts.get(i));
+ S2LatLng decLL = S2LatLng.fromPoint(decoded.get(i));
+ assertEquals(origLL.latDegrees(), decLL.latDegrees(), 1e-12);
+ assertEquals(origLL.lngDegrees(), decLL.lngDegrees(), 1e-12);
+ }
+ }
+
+ @Test
+ public void MultiPointTest() throws ParseException, IOException {
+ String wkt = "MULTIPOINT ((10 40), (40 30))";
+ WKTReader reader = new WKTReader();
+ S2Geography geo = reader.read(wkt);
+
+ WKBWriter writer = new WKBWriter(2, ByteOrderValues.LITTLE_ENDIAN);
+ byte[] wkb = writer.write(geo);
+ WKBReader readerWKB = new WKBReader();
+ S2Geography geoWKB = readerWKB.read(wkb);
+ assertEquals(0, TestHelper.compareTo(geo, geoWKB));
+ }
+
+ @Test
+ public void MultiPolylineTest() throws ParseException, IOException {
+ String wkt = "MULTILINESTRING((0 1,2 3),(4 5,6 7))";
+ WKTReader reader = new WKTReader();
+ S2Geography geo = reader.read(wkt);
+
+ WKBWriter writer = new WKBWriter(2, ByteOrderValues.LITTLE_ENDIAN);
+ byte[] wkb = writer.write(geo);
+ WKBReader readerWKB = new WKBReader();
+ S2Geography geoWKB = readerWKB.read(wkb);
+ assertEquals(0, TestHelper.compareTo(geo, geoWKB));
+ }
+
+ @Test
+ public void MultiPolygonTest() throws ParseException, IOException {
+ String wkt =
+ "MULTIPOLYGON(((0 0,0 10,10 10,10 0,0 0),(1 1,1 9,9 9,9 1,1 1)),((-9 0,-9 10,-1 10,-1 0,-9 0)))";
+ WKTReader reader = new WKTReader();
+ S2Geography geo = reader.read(wkt);
+
+ WKBWriter writer = new WKBWriter(2, ByteOrderValues.LITTLE_ENDIAN);
+ byte[] wkb = writer.write(geo);
+ WKBReader readerWKB = new WKBReader();
+ S2Geography geoWKB = readerWKB.read(wkb);
+ assertEquals(0, TestHelper.compareTo(geo, geoWKB));
+ }
+
+ @Test
+ public void CollectionTest() throws ParseException, IOException {
+ String wkt =
+ "GEOMETRYCOLLECTION(POINT(0 1),POINT(0 1),POINT(2 3),LINESTRING(2 3,4 5),LINESTRING(0 1,2 3),LINESTRING(4 5,6 7),POLYGON((0 0,0 10,10 10,10 0,0 0),(1 1,1 9,9 9,9 1,1 1)),POLYGON((0 0,0 10,10 10,10 0,0 0),(1 1,1 9,9 9,9 1,1 1)),POLYGON((-9 0,-9 10,-1 10,-1 0,-9 0)))";
+
+ WKTReader reader = new WKTReader();
+ S2Geography geo = reader.read(wkt);
+
+ WKBWriter writer = new WKBWriter(2, ByteOrderValues.LITTLE_ENDIAN);
+ byte[] wkb = writer.write(geo);
+ WKBReader readerWKB = new WKBReader();
+ S2Geography geoWKB = readerWKB.read(wkb);
+ assertEquals(0, TestHelper.compareTo(geo, geoWKB));
+ }
+
+ @Test
+ public void emptyTest() throws ParseException, IOException {
+ String wkt = "POINT EMPTY";
+
+ WKTReader reader = new WKTReader();
+ S2Geography geo = reader.read(wkt);
+
+ WKBWriter writer = new WKBWriter(2, ByteOrderValues.LITTLE_ENDIAN);
+ byte[] wkb = writer.write(geo);
+ WKBReader readerWKB = new WKBReader();
+ S2Geography geoWKB = readerWKB.read(wkb);
+ assertEquals(0, TestHelper.compareTo(geo, geoWKB));
+ }
+}
diff --git a/common/src/test/java/org/apache/sedona/common/S2Geography/WKTReadWriterTest.java b/common/src/test/java/org/apache/sedona/common/S2Geography/WKTReadWriterTest.java
new file mode 100644
index 0000000000..33291036d5
--- /dev/null
+++ b/common/src/test/java/org/apache/sedona/common/S2Geography/WKTReadWriterTest.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sedona.common.S2Geography;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+import org.locationtech.jts.geom.PrecisionModel;
+import org.locationtech.jts.io.ParseException;
+
+public class WKTReadWriterTest {
+
+ private final WKTReader reader = new WKTReader() {};
+
+ private String writeWithPrecision(S2Geography geom, PrecisionModel pm) {
+ WKTWriter w = new WKTWriter();
+ w.setPrecisionModel(pm);
+ return w.write(geom);
+ }
+
+ @Test
+ public void significantDigits_defaultAndSingle() throws ParseException {
+ S2Geography geog = reader.read("POINT (0 3.333333333333334)");
+ String wkt = new WKTWriter().write(geog);
+ // default ≈16 digits
+ assertEquals("POINT (0 3.3333333333333344)", wkt);
+
+ // single‐precision ≈6 digits
+ assertEquals(
+ "POINT (0 3.333333)",
+ writeWithPrecision(geog, new PrecisionModel(PrecisionModel.FLOATING_SINGLE)));
+ }
+
+ @Test
+ public void lineString_roundTrip() throws ParseException {
+ String wkt = "LINESTRING (30 10, 10 30, 40 40)";
+ S2Geography g = reader.read(wkt);
+ assertEquals(wkt, writeWithPrecision(g, new PrecisionModel(PrecisionModel.FIXED)));
+ }
+
+ @Test
+ public void multilineString_roundTrip() throws ParseException {
+ String wkt = "MULTILINESTRING ((30 10, 10 30, 40 40), (30 10, 20 30, 40 40))";
+ S2Geography g = reader.read(wkt);
+ assertEquals(wkt, writeWithPrecision(g, new PrecisionModel(PrecisionModel.FIXED)));
+ }
+
+ @Test
+ public void polygon_roundTrip_simpleAndWithHole() throws ParseException {
+ String simple = "POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))";
+ assertEquals(
+ simple, writeWithPrecision(reader.read(simple), new PrecisionModel(PrecisionModel.FIXED)));
+
+ String withHole =
+ "POLYGON ((35 10, 45 45, 15 40, 10 20, 35 10), " + "(20 30, 35 35, 30 20, 20 30))";
+ String expected =
+ "POLYGON ((35 10, 45 45, 15 40, 10 20, 35 10), " + "(30 20, 35 35, 20 30, 30 20))";
+ PolygonGeography sp = (PolygonGeography) reader.read(withHole);
+ assertEquals(
+ expected,
+ writeWithPrecision(reader.read(withHole), new PrecisionModel(PrecisionModel.FIXED)));
+ }
+
+ @Test
+ public void multiGeometries_roundTrip() throws ParseException {
+ assertEquals(
+ "MULTIPOINT ((10 40), (40 30), (20 20), (30 10))",
+ writeWithPrecision(
+ reader.read("MULTIPOINT ((10 40), (40 30), (20 20), (30 10))"),
+ new PrecisionModel(PrecisionModel.FIXED)));
+ assertEquals(
+ "MULTILINESTRING " + "((10 10, 20 20, 10 40), (40 40, 30 30, 40 20, 30 10))",
+ writeWithPrecision(
+ reader.read(
+ "MULTILINESTRING " + "((10 10, 20 20, 10 40), (40 40, 30 30, 40 20, 30 10))"),
+ new PrecisionModel(PrecisionModel.FIXED)));
+ assertEquals(
+ "MULTIPOLYGON "
+ + "(((30 20, 45 40, 10 40, 30 20)), "
+ + "((15 5, 40 10, 10 20, 5 10, 15 5)))",
+ writeWithPrecision(
+ reader.read(
+ "MULTIPOLYGON "
+ + "(((30 20, 45 40, 10 40, 30 20)), "
+ + "((15 5, 40 10, 10 20, 5 10, 15 5)))"),
+ new PrecisionModel(PrecisionModel.FIXED)));
+ }
+
+ @Test
+ public void collection_roundTrip() throws ParseException {
+ String wkt =
+ "GEOMETRYCOLLECTION ("
+ + "POINT (40 10), "
+ + "LINESTRING (10 10, 20 20, 10 40), "
+ + "POLYGON ((40 40, 20 45, 45 30, 40 40))"
+ + ")";
+ assertEquals(
+ wkt, writeWithPrecision(reader.read(wkt), new PrecisionModel(PrecisionModel.FIXED)));
+ }
+
+ @Test
+ public void empty_roundTrip() throws ParseException {
+ String point = "POINT EMPTY";
+ assertEquals(
+ point, writeWithPrecision(reader.read(point), new PrecisionModel(PrecisionModel.FIXED)));
+ String line = "LINESTRING EMPTY";
+ assertEquals(
+ line, writeWithPrecision(reader.read(line), new PrecisionModel(PrecisionModel.FIXED)));
+ String polygon = "POLYGON EMPTY";
+ assertEquals(
+ polygon,
+ writeWithPrecision(reader.read(polygon), new PrecisionModel(PrecisionModel.FIXED)));
+ String multipoint = "MULTIPOINT EMPTY";
+ assertEquals(
+ multipoint,
+ writeWithPrecision(reader.read(multipoint), new PrecisionModel(PrecisionModel.FIXED)));
+ String multiline = "MULTILINESTRING EMPTY";
+ assertEquals(
+ multiline,
+ writeWithPrecision(reader.read(multiline), new PrecisionModel(PrecisionModel.FIXED)));
+ String multipolygon = "MULTIPOLYGON EMPTY";
+ assertEquals(
+ multipolygon,
+ writeWithPrecision(reader.read(multipolygon), new PrecisionModel(PrecisionModel.FIXED)));
+ String collection = "GEOMETRYCOLLECTION EMPTY";
+ assertEquals(
+ collection,
+ writeWithPrecision(reader.read(collection), new PrecisionModel(PrecisionModel.FIXED)));
+ }
+}