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 &lt;Geometry Tagged Text&gt; 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 &lt;Geometry Tagged Text&gt; 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 &lt;Point Text&gt;.
+   * @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 &lt;LineString Text&gt;.
+   * @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 &lt;LineString Text&gt;.
+   * @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 &lt;MultiPoint Text&gt;.
+   * @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 &lt;Polygon Text&gt;.
+   * @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 &lt;MultiLineString Text&gt;.
+   * @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 &lt;MultiPolygon Text&gt;.
+   * @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 &lt;GeometryCollection Text&gt;.
+   * @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 &lt;= 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 &lt;Geometry Tagged Text&gt; 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 &lt;Geometry Tagged Text&gt; 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 &lt;Geometry Tagged Text&gt; 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 &lt;Point Tagged Text&gt; 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 &lt;LineString Tagged Text&gt; 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 &lt;Polygon Tagged Text&gt; 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 &lt;MultiPoint Tagged Text&gt; 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 &lt;MultiLineString Tagged Text&gt; 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 &lt;MultiPolygon Tagged Text&gt; 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 &lt;GeometryCollection Tagged Text&gt; 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 &lt;Polygon Text&gt; 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 &lt;MultiPoint Text&gt; 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 &lt;MultiLineString Text&gt; 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 &lt;MultiPolygon Text&gt; 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 &lt;GeometryCollectionText&gt; 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)));
+  }
+}