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 cf011021a1 [GH-1996] Create object of S2Geography, and implement 
Point/Polyline/PolygonGeography with its encoder/decoder (#1992)
cf011021a1 is described below

commit cf011021a106871a2f7816ae59ded509eff4e320
Author: Zhuocheng Shang <122398181+zhuochengsh...@users.noreply.github.com>
AuthorDate: Tue Jul 1 07:50:49 2025 -0700

    [GH-1996] Create object of S2Geography, and implement 
Point/Polyline/PolygonGeography with its encoder/decoder (#1992)
    
    * 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
    
    * resolve minor issue with PolygonGeography
---
 common/pom.xml                                     |  13 +-
 .../sedona/common/S2Geography/EncodeOptions.java   |  65 ++++++
 .../sedona/common/S2Geography/EncodeTag.java       | 163 ++++++++++++++
 .../sedona/common/S2Geography/PointGeography.java  | 242 +++++++++++++++++++++
 .../common/S2Geography/PolygonGeography.java       | 103 +++++++++
 .../common/S2Geography/PolylineGeography.java      | 144 ++++++++++++
 .../sedona/common/S2Geography/S2Geography.java     | 214 ++++++++++++++++++
 .../common/S2Geography/PointGeographyTest.java     | 181 +++++++++++++++
 .../common/S2Geography/PolygonGeographyTest.java   |  45 ++++
 .../common/S2Geography/PolylineGeographyTest.java  |  79 +++++++
 .../sedona/common/S2Geography/TestHelper.java      | 151 +++++++++++++
 11 files changed, 1398 insertions(+), 2 deletions(-)

diff --git a/common/pom.xml b/common/pom.xml
index 8728a08bf7..b5127e0e00 100644
--- a/common/pom.xml
+++ b/common/pom.xml
@@ -81,9 +81,13 @@
             <groupId>org.locationtech.spatial4j</groupId>
             <artifactId>spatial4j</artifactId>
         </dependency>
+        <!-- org.datasyslab:s2-geometry-library is a fork of 
com.google.geometry:s2-geometry-library-->
+        <!-- as implementation requirements of apache sedona issue link: -->
+        <!-- https://github.com/apache/sedona/issues/1996 -->
         <dependency>
-            <groupId>com.google.geometry</groupId>
-            <artifactId>s2-geometry</artifactId>
+            <groupId>org.datasyslab</groupId>
+            <artifactId>s2-geometry-library</artifactId>
+            <version>20250620-rc1</version>
         </dependency>
         <dependency>
             <groupId>com.uber</groupId>
@@ -159,6 +163,7 @@
                                     
<include>it.geosolutions.jaiext.jiffle:*</include>
                                     <include>org.antlr:*</include>
                                     <include>org.codehaus.janino:*</include>
+                                    
<include>org.datasyslab:s2-geometry-library</include>
                                 </includes>
                             </artifactSet>
                             <relocations>
@@ -177,6 +182,10 @@
                                     <pattern>org.codehaus</pattern>
                                     
<shadedPattern>org.apache.sedona.shaded.codehaus</shadedPattern>
                                 </relocation>
+                                <relocation>
+                                    
<pattern>com.google.common.geometry</pattern>
+                                    
<shadedPattern>org.apache.sedona.shaded.s2</shadedPattern>
+                                </relocation>
                             </relocations>
                             <filters>
                                 <!--  filter to address "Invalid signature 
file" issue - see http://stackoverflow.com/a/6743609/589215 -->
diff --git 
a/common/src/main/java/org/apache/sedona/common/S2Geography/EncodeOptions.java 
b/common/src/main/java/org/apache/sedona/common/S2Geography/EncodeOptions.java
new file mode 100644
index 0000000000..6c255e7ef4
--- /dev/null
+++ 
b/common/src/main/java/org/apache/sedona/common/S2Geography/EncodeOptions.java
@@ -0,0 +1,65 @@
+/*
+ * 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;
+
+public class EncodeOptions {
+  /** FAST writes raw doubles; COMPACT snaps vertices to cell centers. */
+  public enum CodingHint {
+    FAST,
+    COMPACT
+  }
+
+  /** Default: FAST. */
+  private CodingHint codingHint = CodingHint.FAST;
+
+  /** If true, convert “hard” shapes into lazy‐decodable variants. */
+  private boolean enableLazyDecode = false;
+
+  /** If true, prefix the payload with the cell‐union covering. */
+  private boolean includeCovering = false;
+
+  public EncodeOptions() {}
+
+  /** Control FAST vs. COMPACT encoding. */
+  public void setCodingHint(CodingHint hint) {
+    this.codingHint = hint;
+  }
+
+  public CodingHint getCodingHint() {
+    return codingHint;
+  }
+
+  /** Enable or disable lazy‐decode conversions. */
+  public void setEnableLazyDecode(boolean enable) {
+    this.enableLazyDecode = enable;
+  }
+
+  public boolean isEnableLazyDecode() {
+    return enableLazyDecode;
+  }
+
+  /** Include or omit the cell‐union covering prefix. */
+  public void setIncludeCovering(boolean include) {
+    this.includeCovering = include;
+  }
+
+  public boolean isIncludeCovering() {
+    return includeCovering;
+  }
+}
diff --git 
a/common/src/main/java/org/apache/sedona/common/S2Geography/EncodeTag.java 
b/common/src/main/java/org/apache/sedona/common/S2Geography/EncodeTag.java
new file mode 100644
index 0000000000..a139965372
--- /dev/null
+++ b/common/src/main/java/org/apache/sedona/common/S2Geography/EncodeTag.java
@@ -0,0 +1,163 @@
+/*
+ * 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.esotericsoftware.kryo.io.Input;
+import com.esotericsoftware.kryo.io.Output;
+import com.esotericsoftware.kryo.io.UnsafeInput;
+import com.google.common.geometry.S2CellId;
+import java.io.*;
+import java.util.List;
+import org.apache.sedona.common.S2Geography.S2Geography.GeographyKind;
+
+/**
+ * A 4 byte prefix for encoded geographies. Builds a 5-byte header (EncodeTag) 
containing 1 byte:
+ * kind 1 byte: flags 1 byte: coveringSize 1 byte: reserved (must be 0)
+ */
+public class EncodeTag {
+  /**
+   * Subclass of S2Geography whose decode() method will be invoked. Encoded 
using a single unsigned
+   * byte (represented as an int in Java, range 0–255).
+   */
+  private GeographyKind kind = GeographyKind.UNINITIALIZED;
+  /**
+   * Flags for encoding metadata. one flag {@code kFlagEmpty} is supported, 
which is set if and only
+   * if the geography contains zero shapes. second flag {@code FlagCompact}, 
which is set if user
+   * set COMPACT encoding type
+   */
+  private byte flags = 0;
+  // ——— Bit‐masks for our one‐byte flags field ———————————————————
+  /** set if geography has zero shapes */
+  public static final byte FLAG_EMPTY = 1 << 0;
+  /** set if using COMPACT coding; if clear, we’ll treat as FAST */
+  public static final byte FLAG_COMPACT = 1 << 1;
+  // bits 2–7 are still unused (formerly “reserved”)
+  /**
+   * Number of S2CellId entries that follow this tag. A value of zero (i.e., 
an empty covering)
+   * means no covering was written, but this does not imply that the geography 
itself is empty.
+   */
+  private byte coveringSize = 0;
+  /** Reserved byte for future use. Must be set to 0. */
+  private byte reserved = 0;
+
+  // ——— Write the 4-byte tag header ——————————————————————————————————————
+  public EncodeTag() {}
+
+  public EncodeTag(EncodeOptions opts) {
+    if (opts.getCodingHint() == EncodeOptions.CodingHint.COMPACT) {
+      flags |= FLAG_COMPACT;
+    }
+  }
+  /** Write exactly 4 bytes: [kind|flags|coveringSize|reserved]. */
+  public void encode(Output out) throws IOException {
+    out.writeByte(kind.getKind());
+    out.writeByte(flags);
+    out.writeByte(coveringSize);
+    out.writeByte(reserved);
+  }
+  // ——— Read it back ————————————————————————————————————————————————
+
+  /** Reads exactly 4 bytes (in the same order) from the stream. */
+  public static EncodeTag decode(Input in) throws IOException {
+    EncodeTag tag = new EncodeTag();
+    tag.kind = GeographyKind.fromKind(in.readByte());
+    tag.flags = in.readByte();
+    tag.coveringSize = in.readByte();
+    tag.reserved = in.readByte();
+    if (tag.reserved != 0)
+      throw new IOException("Reserved header byte must be 0, was " + 
tag.reserved);
+    return tag;
+  }
+
+  // ——— Helpers for the optional covering list —————————————————————————
+
+  /** Read coveringSize many cell-ids and add them to cellIds. */
+  public void decodeCovering(UnsafeInput in, List<S2CellId> cellIds) throws 
IOException {
+    int count = coveringSize & 0xFF;
+    for (int i = 0; i < count; i++) {
+      long id = in.readLong();
+      cellIds.add(new S2CellId(id));
+    }
+  }
+
+  /** Skip over coveringSize many cell-ids in the stream. */
+  public void skipCovering(UnsafeInput in) throws IOException {
+    int count = coveringSize & 0xFF;
+    for (int i = 0; i < count; i++) {
+      in.readLong();
+    }
+  }
+
+  /** Ensure we didn’t accidentally write a non-zero reserved byte. */
+  public void validate() {
+    if (reserved != 0) {
+      throw new IllegalStateException("EncodeTag.reserved must be 0, was " + 
(reserved & 0xFF));
+    }
+  }
+
+  // ——— Getters / setters ——————————————————————————————————————————
+
+  public GeographyKind getKind() {
+    return this.kind;
+  }
+
+  public void setKind(GeographyKind kind) {
+    this.kind = kind;
+  }
+
+  public byte getFlags() {
+    return flags;
+  }
+
+  public void setFlags(byte flags) {
+    this.flags = flags;
+  }
+
+  public byte getCoveringSize() {
+    return coveringSize;
+  }
+
+  public void setCoveringSize(byte size) {
+    this.coveringSize = size;
+  }
+
+  /** mark or unmark the EMPTY flag */
+  public void setEmpty(boolean empty) {
+    if (empty) flags |= FLAG_EMPTY;
+    else flags &= ~FLAG_EMPTY;
+  }
+
+  /** choose COMPACT (true) or FAST (false) */
+  public void setCompact(boolean compact) {
+    if (compact) flags |= FLAG_COMPACT;
+    else flags &= ~FLAG_COMPACT;
+  }
+
+  public boolean isEmpty() {
+    return (flags & FLAG_EMPTY) != 0;
+  }
+
+  public boolean isCompact() {
+    return (flags & FLAG_COMPACT) != 0;
+  }
+
+  public boolean isFast() {
+    return !isCompact();
+  }
+}
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
new file mode 100644
index 0000000000..17d7a3b53c
--- /dev/null
+++ 
b/common/src/main/java/org/apache/sedona/common/S2Geography/PointGeography.java
@@ -0,0 +1,242 @@
+/*
+ * 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.esotericsoftware.kryo.io.Input;
+import com.esotericsoftware.kryo.io.Output;
+import com.esotericsoftware.kryo.io.UnsafeInput;
+import com.esotericsoftware.kryo.io.UnsafeOutput;
+import com.google.common.geometry.*;
+import com.google.common.geometry.PrimitiveArrays.Bytes;
+import java.io.*;
+import java.util.*;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class PointGeography extends S2Geography {
+  private static final Logger logger = 
LoggerFactory.getLogger(PointGeography.class.getName());
+
+  private static final int BUFFER_SIZE = 4 * 1024;
+
+  public final List<S2Point> points = new ArrayList<>();
+
+  /** Constructs an empty PointGeography. */
+  public PointGeography() {
+    super(GeographyKind.POINT);
+  }
+
+  /** Constructs especially for CELL_CENTER */
+  private PointGeography(GeographyKind kind, S2Point point) {
+    super(kind); // can be POINT or CELL_CENTER
+    points.add(point);
+  }
+
+  /** Constructs a single-point geography. */
+  public PointGeography(S2Point point) {
+    this();
+    points.add(point);
+  }
+
+  /** Constructs from a list of points. */
+  public PointGeography(List<S2Point> pts) {
+    this();
+    points.addAll(pts);
+  }
+
+  @Override
+  public int dimension() {
+    return points.isEmpty() ? -1 : 0;
+  }
+
+  @Override
+  public int numShapes() {
+    return points.isEmpty() ? 0 : 1;
+  }
+
+  @Override
+  public S2Shape shape(int id) {
+    return S2Point.Shape.fromList(points);
+  }
+
+  @Override
+  public S2Region region() {
+    if (points.isEmpty()) {
+      return S2Cap.empty();
+    } else if (points.size() == 1) {
+      return new S2PointRegion(points.get(0));
+    } else {
+      // Union of all point regions
+      Collection<S2Region> pointRegionCollection = new ArrayList<>();
+      for (S2Point p : points) {
+        pointRegionCollection.add(new S2PointRegion(p));
+      }
+      return new S2RegionUnion(pointRegionCollection);
+    }
+  }
+
+  @Override
+  public void getCellUnionBound(List<S2CellId> cellIds) {
+    if (points.size() < 10) {
+      // For small point sets, cover each point individually
+      for (S2Point p : points) {
+        cellIds.add(S2CellId.fromPoint(p));
+      }
+    } else {
+      // Fallback to the default covering logic in S2Geography
+      super.getCellUnionBound(cellIds);
+    }
+  }
+
+  /** Returns an immutable view of the points. */
+  public List<S2Point> getPoints() {
+    // List.copyOf makes an unmodifiable copy under the hood
+    return List.copyOf(points);
+  }
+
+  // -------------------------------------------------------
+  // EncodeTagged / DecodeTagged
+  // -------------------------------------------------------
+
+  @Override
+  public void encodeTagged(OutputStream os, EncodeOptions opts) throws 
IOException {
+    UnsafeOutput out = new UnsafeOutput(os, BUFFER_SIZE);
+    if (points.size() == 1 && opts.getCodingHint() == 
EncodeOptions.CodingHint.COMPACT) {
+      // Optimized encoding which only uses covering to represent the point
+      S2CellId cid = S2CellId.fromPoint(points.get(0));
+      // Only encode this for very high levels: because the covering *is* the
+      // representation, we will have a very loose covering if the level is 
low.
+      // Level 23 has a cell size of ~1 meter
+      // (http://s2geometry.io/resources/s2cell_statistics)
+      if (cid.level() >= 23) {
+        EncodeTag tag = new EncodeTag();
+        tag.setKind(GeographyKind.CELL_CENTER);
+        tag.setCompact(true);
+        tag.setCoveringSize((byte) 1);
+        tag.encode(out);
+        out.writeLong(cid.id());
+        out.flush();
+        return;
+      }
+    }
+    // In other cases, fallback to the default encodeTagged implementation:
+    super.encodeTagged(out, opts);
+  }
+
+  @Override
+  protected void encode(UnsafeOutput out, EncodeOptions opts) throws 
IOException {
+    // now the *payload* must go into its own buffer:
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    Output tmpOut = new Output(baos);
+
+    // Encode point payload using selected hint
+    S2Point.Shape shp = S2Point.Shape.fromList(points);
+    switch (opts.getCodingHint()) {
+      case FAST:
+        S2Point.Shape.FAST_CODER.encode(shp, tmpOut);
+        break;
+      case COMPACT:
+        S2Point.Shape.COMPACT_CODER.encode(shp, tmpOut);
+    }
+    tmpOut.flush();
+
+    // grab exactly those bytes:
+    byte[] payload = baos.toByteArray();
+
+    // 4) length-prefix + payload
+    // use writeInt(len, false) so it's exactly 4 bytes
+    out.writeInt(payload.length, /* optimizePositive= */ false);
+    out.writeBytes(payload);
+
+    out.flush();
+  }
+
+  /** This is what decodeTagged() actually calls */
+  public static PointGeography decode(Input in, EncodeTag tag) throws 
IOException {
+    // cast to UnsafeInput—will work if you always pass a Kryo-backed stream
+    if (!(in instanceof UnsafeInput)) {
+      throw new IllegalArgumentException("Expected UnsafeInput");
+    }
+    return decode((UnsafeInput) in, tag);
+  }
+
+  public static PointGeography decode(UnsafeInput in, EncodeTag tag) throws 
IOException {
+    PointGeography geo = new PointGeography();
+
+    // EMPTY
+    if ((tag.getFlags() & EncodeTag.FLAG_EMPTY) != 0) {
+      logger.warn("Decoded empty PointGeography.");
+      return geo;
+    }
+
+    // Optimized 1-point COMPACT situation
+    if (tag.getKind() == GeographyKind.CELL_CENTER) {
+      long id = in.readLong();
+      geo = new PointGeography(new S2CellId(id).toPoint());
+      logger.info("Decoded compact single-point geography via cell center.");
+      return geo;
+    }
+
+    // skip cover
+    tag.skipCovering(in);
+
+    // The S2 Coder interface of Java makes it hard to decode data using 
streams,
+    // we can write an integer indicating the total length of the encoded 
point before the actual
+    // payload in encode.
+    // We can read the length and read the entire payload into a byte array, 
then call the decode
+    // function of S2 Coder.
+    // TODO: This results in in-compatible encoding format with the C++ 
implementation,
+    // but we can do this for now until we need to exchange data with some 
native components.
+    // 1) read our 4-byte length prefix
+    int length = in.readInt(/* optimizePositive= */ false);
+    if (length < 0) {
+      throw new IOException("Invalid payload length: " + length);
+    }
+
+    // 2) read exactly that many bytes
+    byte[] payload = new byte[length];
+    in.readBytes(payload, 0, length);
+
+    // 3) hand *only* those bytes to S2‐Coder via Bytes adapter
+    Bytes bytes =
+        new Bytes() {
+          @Override
+          public long length() {
+            return payload.length;
+          }
+
+          @Override
+          public byte get(long i) {
+            return payload[(int) i];
+          }
+        };
+    PrimitiveArrays.Cursor cursor = bytes.cursor();
+
+    // pick the right decoder
+    List<S2Point> pts;
+    if (tag.isCompact()) {
+      pts = S2Point.Shape.COMPACT_CODER.decode(bytes, cursor);
+    } else {
+      pts = S2Point.Shape.FAST_CODER.decode(bytes, cursor);
+    }
+
+    geo.points.addAll(pts);
+    return geo;
+  }
+}
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
new file mode 100644
index 0000000000..1927566c27
--- /dev/null
+++ 
b/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java
@@ -0,0 +1,103 @@
+/*
+ * 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.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.List;
+import java.util.logging.Logger;
+
+public class PolygonGeography extends S2Geography {
+  private static final Logger logger = 
Logger.getLogger(PolygonGeography.class.getName());
+
+  public final S2Polygon polygon;
+
+  public PolygonGeography() {
+    super(GeographyKind.POLYGON);
+    this.polygon = new S2Polygon();
+  }
+
+  public PolygonGeography(S2Polygon polygon) {
+    super(GeographyKind.POLYGON);
+    this.polygon = polygon;
+  }
+
+  @Override
+  public int dimension() {
+    return 2;
+  }
+
+  @Override
+  public int numShapes() {
+    return polygon.isEmpty() ? 0 : 1;
+  }
+
+  @Override
+  public S2Shape shape(int id) {
+    assert polygon != null;
+    return polygon.shape();
+  }
+
+  @Override
+  public S2Region region() {
+    return this.polygon;
+  }
+
+  @Override
+  public void getCellUnionBound(List<S2CellId> cellIds) {
+    super.getCellUnionBound(cellIds);
+  }
+
+  @Override
+  public void encode(UnsafeOutput out, EncodeOptions opts) throws IOException {
+    // Encode polygon
+    polygon.encode(out);
+    out.flush();
+  }
+
+  /** This is what decodeTagged() actually calls */
+  public static PolygonGeography decode(Input in, EncodeTag tag) throws 
IOException {
+    // cast to UnsafeInput—will work if you always pass a Kryo-backed stream
+    if (!(in instanceof UnsafeInput)) {
+      throw new IllegalArgumentException("Expected UnsafeInput");
+    }
+    return decode((UnsafeInput) in, tag);
+  }
+
+  public static PolygonGeography decode(UnsafeInput in, EncodeTag tag) throws 
IOException {
+    PolygonGeography geo = new PolygonGeography();
+
+    // EMPTY
+    if ((tag.getFlags() & EncodeTag.FLAG_EMPTY) != 0) {
+      logger.fine("Decoded empty PolygonGeography.");
+      return geo;
+    }
+
+    // 2) Skip past any covering cell-IDs written by encodeTagged
+    tag.skipCovering(in);
+
+    S2Polygon poly = S2Polygon.decode(in);
+    geo = new PolygonGeography(poly);
+
+    return geo;
+  }
+}
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
new file mode 100644
index 0000000000..ad8088222d
--- /dev/null
+++ 
b/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java
@@ -0,0 +1,144 @@
+/*
+ * 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.esotericsoftware.kryo.io.Input;
+import com.esotericsoftware.kryo.io.UnsafeInput;
+import com.esotericsoftware.kryo.io.UnsafeOutput;
+import com.google.common.collect.ImmutableList;
+import com.google.common.geometry.*;
+import java.io.*;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.logging.Logger;
+
+/** A Geography representing zero or more polylines using S2Polyline. */
+public class PolylineGeography extends S2Geography {
+  private static final Logger logger = 
Logger.getLogger(PolylineGeography.class.getName());
+
+  public final List<S2Polyline> polylines;
+
+  private static int sizeofInt() {
+    return Integer.BYTES;
+  }
+
+  public PolylineGeography() {
+    super(GeographyKind.POLYLINE);
+    this.polylines = new ArrayList<>();
+  }
+
+  public PolylineGeography(S2Polyline polyline) {
+    super(GeographyKind.POLYLINE);
+    this.polylines = new ArrayList<>();
+    this.polylines.add(polyline);
+  }
+
+  public PolylineGeography(List<S2Polyline> polylines) {
+    super(GeographyKind.POLYLINE);
+    this.polylines = new ArrayList<>(polylines);
+  }
+
+  @Override
+  public int dimension() {
+    return polylines.isEmpty() ? -1 : 1;
+  }
+
+  @Override
+  public int numShapes() {
+    return polylines.size();
+  }
+
+  @Override
+  public S2Shape shape(int id) {
+    return polylines.get(id);
+  }
+
+  @Override
+  public S2Region region() {
+    Collection<S2Region> polylineRegionCollection = new ArrayList<>();
+    polylineRegionCollection.addAll(polylines);
+    return new S2RegionUnion(polylineRegionCollection);
+  }
+
+  @Override
+  public void getCellUnionBound(List<S2CellId> cellIds) {
+    // Fallback to default Geography logic via shape index region
+    super.getCellUnionBound(cellIds);
+  }
+
+  public List<S2Polyline> getPolylines() {
+    return ImmutableList.copyOf(polylines);
+  }
+
+  @Override
+  public void encode(UnsafeOutput out, EncodeOptions opts) throws IOException {
+    // 1) Write number of polylines as a 4-byte Kryo int
+    out.writeInt(polylines.size());
+
+    // 2) Encode point payload using selected hint
+    boolean useFast = opts.getCodingHint() == EncodeOptions.CodingHint.FAST;
+    for (S2Polyline pl : polylines) {
+      if (useFast) {
+        S2Polyline.FAST_CODER.encode(pl, out);
+      } else {
+        S2Polyline.COMPACT_CODER.encode(pl, out);
+      }
+    }
+    out.flush();
+  }
+
+  /** This is what decodeTagged() actually calls */
+  public static PolylineGeography decode(Input in, EncodeTag tag) throws 
IOException {
+    // cast to UnsafeInput—will work if you always pass a Kryo-backed stream
+    if (!(in instanceof UnsafeInput)) {
+      throw new IllegalArgumentException("Expected UnsafeInput");
+    }
+    return decode((UnsafeInput) in, tag);
+  }
+
+  public static PolylineGeography decode(UnsafeInput in, EncodeTag tag) throws 
IOException {
+    // 1) Instantiate an empty geography
+    PolylineGeography geo = new PolylineGeography();
+
+    // EMPTY
+    if ((tag.getFlags() & EncodeTag.FLAG_EMPTY) != 0) {
+      logger.fine("Decoded empty PointGeography.");
+      return geo;
+    }
+
+    // 2) Skip past any covering cell-IDs written by encodeTagged
+    tag.skipCovering(in);
+
+    // 3) Ensure we have at least 4 bytes for the count
+    if (in.available() < Integer.BYTES) {
+      throw new IOException("PolylineGeography.decodeTagged error: 
insufficient header bytes");
+    }
+
+    // 5) Read the number of polylines (4-byte)
+    int count = in.readInt();
+
+    // 6) For each polyline, read its block and let 
S2Polyline.decode(InputStream) do the rest
+    for (int i = 0; i < count; i++) {
+      S2Polyline pl = S2Polyline.decode(in);
+      geo.polylines.add(pl);
+    }
+    return geo;
+  }
+}
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
new file mode 100644
index 0000000000..274a041a54
--- /dev/null
+++ b/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java
@@ -0,0 +1,214 @@
+/*
+ * 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.esotericsoftware.kryo.io.UnsafeInput;
+import com.esotericsoftware.kryo.io.UnsafeOutput;
+import com.google.common.geometry.*;
+import java.io.*;
+import java.util.ArrayList;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * An abstract class represent S2Geography. Has 6 subtypes of geography: 
POINT, POLYLINE, POLYGON,
+ * GEOGRAPHY_COLLECTION, SHAPE_INDEX, ENCODED_SHAPE_INDEX.
+ */
+public abstract class S2Geography {
+  private static final Logger logger = 
LoggerFactory.getLogger(S2Geography.class.getName());
+
+  private static final int BUFFER_SIZE = 4 * 1024;
+
+  protected final GeographyKind kind;
+
+  protected S2Geography(GeographyKind kind) {
+    this.kind = kind;
+  }
+
+  public enum GeographyKind {
+    UNINITIALIZED(0),
+    POINT(1),
+    POLYLINE(2),
+    POLYGON(3),
+    GEOGRAPHY_COLLECTION(4),
+    SHAPE_INDEX(5),
+    ENCODED_SHAPE_INDEX(6),
+    CELL_CENTER(7);
+
+    private final int kind;
+
+    GeographyKind(int kind) {
+      this.kind = kind;
+    }
+
+    /** Returns the integer tag for this kind. */
+    public int getKind() {
+      return kind;
+    }
+    /**
+     * Look up the enum by its integer tag.
+     *
+     * @throws IllegalArgumentException if no matching kind exists.
+     */
+    public static GeographyKind fromKind(int kind) {
+      for (GeographyKind k : values()) {
+        if (k.getKind() == kind) return k;
+      }
+      throw new IllegalArgumentException("Unknown GeographyKind: " + kind);
+    }
+  }
+  /**
+   * @return 0, 1, or 2 if all Shape()s that are returned will have the same 
dimension (i.e., they
+   *     are all points, all lines, or all polygons).
+   */
+  public abstract int dimension();
+
+  /**
+   * Usage of checking all shapes in side collection geography
+   *
+   * @return
+   */
+  protected final int computeDimensionFromShapes() {
+    if (numShapes() == 0) return -1;
+    int dim = shape(0).dimension();
+    for (int i = 1; i < numShapes(); ++i) {
+      if (dim != shape(i).dimension()) return -1;
+    }
+    return dim;
+  }
+
+  /**
+   * @return The number of S2Shape objects needed to represent this Geography
+   */
+  public abstract int numShapes();
+
+  /**
+   * Returns the given S2Shape (where 0 <= id < num_shapes()). The caller 
retains ownership of the
+   * S2Shape but the data pointed to by the object requires that the 
underlying Geography outlives
+   * the returned object.
+   *
+   * @param id (where 0 <= id < num_shapes())
+   * @return the given S2Shape
+   */
+  public abstract S2Shape shape(int id);
+
+  /**
+   * Returns an S2Region that represents the object. The caller retains 
ownership of the S2Region
+   * but the data pointed to by the object requires that the underlying 
Geography outlives the
+   * returned object.
+   *
+   * @return S2Region
+   */
+  public abstract S2Region region();
+
+  /**
+   * Adds an unnormalized set of S2CellIDs to `cell_ids`. This is intended to 
be faster than using
+   * Region().GetCovering() directly and to return a small number of cells 
that can be used to
+   * compute a possible intersection quickly.
+   */
+  public void getCellUnionBound(List<S2CellId> cellIds) {
+    // Build a shape index of all shapes in this geography
+    S2ShapeIndex index = new S2ShapeIndex();
+    for (int i = 0; i < numShapes(); i++) {
+      index.add(shape(i));
+    }
+    // Create a region from the index and delegate covering
+    S2ShapeIndexRegion region = new S2ShapeIndexRegion(index);
+    region.getCellUnionBound(cellIds);
+  }
+
+  // ─── Encoding / decoding machinery 
────────────────────────────────────────────
+  /**
+   * Serialize this geography to an encoder. This does not include any 
encapsulating information
+   * (e.g., which geography type or flags). Encode this geography into a 
stream as: 1) a 5-byte
+   * EncodeTag header (see EncodeTag encode / decode) 2) coveringSize × 8-byte 
cell-ids 3) the raw
+   * shape payload (point/polyline/polygon) via the built-in coder
+   *
+   * @param opts CodingHint.FAST / CodingHint.COMPACT / Include or omit the 
cell‐union covering
+   *     prefix
+   */
+  public void encodeTagged(OutputStream os, EncodeOptions opts) throws 
IOException {
+    UnsafeOutput out = new UnsafeOutput(os, BUFFER_SIZE);
+    EncodeTag tag = new EncodeTag(opts);
+    List<S2CellId> cover = new ArrayList<>();
+
+    // EMPTY
+    if (this.numShapes() == 0) {
+      tag.setKind(GeographyKind.fromKind(this.kind.kind));
+      tag.setFlags((byte) (tag.getFlags() | EncodeTag.FLAG_EMPTY));
+      tag.setCoveringSize((byte) 0);
+      tag.encode(out);
+      out.flush();
+      return;
+    }
+
+    // 1) Get covering if needed
+    if (opts.isIncludeCovering()) {
+      getCellUnionBound(cover);
+      if (cover.size() > 256) {
+        cover.clear();
+        logger.warn("Covering size too large (> 256) — clear Covering");
+      }
+    }
+
+    // 2) Write tag header
+    tag.setKind(GeographyKind.fromKind(this.kind.kind));
+    tag.setCoveringSize((byte) cover.size());
+    tag.encode(out);
+
+    // Encode the covering
+    for (S2CellId c2 : cover) {
+      out.writeLong(c2.id());
+    }
+
+    // 3) Write the geography
+    this.encode(out, opts);
+    out.flush();
+  }
+
+  public S2Geography decodeTagged(InputStream is) throws IOException {
+    // wrap ONCE
+    UnsafeInput kryoIn = new UnsafeInput(is, BUFFER_SIZE);
+    EncodeTag topTag = EncodeTag.decode(kryoIn);
+    // 1) decode the tag
+    return S2Geography.decode(kryoIn, topTag);
+  }
+
+  public static S2Geography decode(UnsafeInput in, EncodeTag tag) throws 
IOException {
+    // 2) dispatch to subclass's decode method according to tag.kind
+    switch (tag.getKind()) {
+      case CELL_CENTER:
+      case POINT:
+        return PointGeography.decode(in, tag);
+      case POLYLINE:
+        return PolylineGeography.decode(in, tag);
+      case POLYGON:
+        return PolygonGeography.decode(in, tag);
+        //      case GEOGRAPHY_COLLECTION:
+        //        return GeographyCollection.decode(in, tag);
+        //      case SHAPE_INDEX:
+        //        return EncodedShapeIndexGeography.decode(in, tag);
+      default:
+        throw new IOException("Unsupported GeographyKind for decoding: " + 
tag.getKind());
+    }
+  }
+
+  protected abstract void encode(UnsafeOutput os, EncodeOptions opts) throws 
IOException;
+}
diff --git 
a/common/src/test/java/org/apache/sedona/common/S2Geography/PointGeographyTest.java
 
b/common/src/test/java/org/apache/sedona/common/S2Geography/PointGeographyTest.java
new file mode 100644
index 0000000000..49173417e8
--- /dev/null
+++ 
b/common/src/test/java/org/apache/sedona/common/S2Geography/PointGeographyTest.java
@@ -0,0 +1,181 @@
+/*
+ * 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 static org.junit.Assert.assertTrue;
+
+import com.google.common.geometry.*;
+import java.io.*;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+
+public class PointGeographyTest {
+  @Test
+  public void testEncodeTag() throws IOException {
+    // 1) Create an empty geography
+    PointGeography geog = new PointGeography();
+    assertEquals(S2Geography.GeographyKind.POINT, geog.kind);
+    assertEquals(0, geog.numShapes());
+    // Java returns -1 for no shapes; if yours returns 0, adjust accordingly
+    assertEquals(-1, geog.dimension());
+    assertTrue(geog.getPoints().isEmpty());
+
+    // 2) Encode into a ByteArrayOutputStream
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    geog.encodeTagged(baos, new EncodeOptions());
+    byte[] data = baos.toByteArray();
+    assertEquals(4, data.length);
+
+    // 2) Create a single-point geography at lat=45°, lng=-64°
+    S2Point pt = S2LatLng.fromDegrees(45, -64).toPoint();
+    PointGeography geog2 = new PointGeography(pt);
+    assertEquals(1, geog2.numShapes());
+    assertEquals(0, geog2.dimension());
+    List<S2Point> originalPts = geog2.getPoints();
+    assertEquals(1, originalPts.size());
+    assertEquals(pt, originalPts.get(0));
+
+    // 3) EncodeTagged
+    ByteArrayOutputStream baos2 = new ByteArrayOutputStream();
+    geog2.encodeTagged(baos2, new EncodeOptions());
+    byte[] data2 = baos2.toByteArray();
+    // should be >4 bytes (header+payload)
+    assertTrue(data2.length > 4);
+  }
+
+  @Test
+  public void testEmptyPointEncodeDecode() throws IOException {
+    // 1) Create an empty geography
+    PointGeography geog = new PointGeography();
+    TestHelper.assertRoundTrip(geog, new EncodeOptions());
+  }
+
+  @Test
+  public void testEncodedPoint() throws IOException {
+    // 1) Create a single-point geography at lat=45°, lng=-64°
+    S2Point pt = S2LatLng.fromDegrees(45, -64).toPoint();
+    PointGeography geog = new PointGeography(pt);
+    TestHelper.assertRoundTrip(geog, new EncodeOptions());
+  }
+
+  @Test
+  public void testEncodedSnappedPoint() throws IOException {
+    // 1) Build the original point and its snapped-to-cell-center version
+    S2Point pt = S2LatLng.fromDegrees(45, -64).toPoint();
+    S2CellId cellId = S2CellId.fromPoint(pt);
+    S2Point ptSnapped = cellId.toPoint();
+
+    // 2) EncodeTagged in COMPACT mode
+    PointGeography geog = new PointGeography(ptSnapped);
+    EncodeOptions opts = new EncodeOptions();
+    opts.setCodingHint(EncodeOptions.CodingHint.COMPACT);
+    TestHelper.assertRoundTrip(geog, opts);
+  }
+
+  @Test
+  public void testEncodedListPoints() throws IOException {
+    // 1) Build two points
+    S2Point pt1 = S2LatLng.fromDegrees(45, -64).toPoint();
+    S2Point pt2 = S2LatLng.fromDegrees(70, -40).toPoint();
+
+    // 2) Encode both points
+    PointGeography geog = new PointGeography(List.of(pt1, pt2));
+    EncodeOptions opts = new EncodeOptions();
+    opts.setCodingHint(EncodeOptions.CodingHint.COMPACT);
+    TestHelper.assertRoundTrip(geog, opts);
+  }
+
+  @Test
+  public void testPointCoveringEnabled() throws IOException {
+    // single point geography
+    S2Point pt = S2LatLng.fromDegrees(45, -64).toPoint();
+    PointGeography geo = new PointGeography(pt);
+    EncodeOptions opts = new EncodeOptions();
+    opts.setIncludeCovering(true);
+
+    // should write a non-zero coveringSize
+    TestHelper.assertCovering(geo, opts);
+  }
+
+  @Test
+  public void testPointCoveringDisabled() throws IOException {
+    // single point geography
+    S2Point pt = S2LatLng.fromDegrees(45, -64).toPoint();
+    PointGeography geo = new PointGeography(pt);
+    EncodeOptions opts = new EncodeOptions();
+    opts.setIncludeCovering(false);
+
+    // should write coveringSize == 0
+    TestHelper.assertCovering(geo, opts);
+  }
+
+  @Test
+  public void testSmallPointUnionCovering() throws IOException {
+    // fewer than 10 points: each point should produce one cell
+    List<S2Point> pts = new ArrayList<>();
+    for (int i = 0; i < 5; i++) {
+      pts.add(S2LatLng.fromDegrees(i * 10, i * 5).toPoint());
+    }
+    PointGeography geo = new PointGeography(pts);
+    List<S2CellId> cells = new ArrayList<>();
+    geo.getCellUnionBound(cells);
+    assertEquals("Should cover each point individually", pts.size(), 
cells.size());
+    // ensure each cell's center matches the original point upon decoding
+    for (int i = 0; i < pts.size(); i++) {
+      S2CellId center = new S2CellId(cells.get(i).id());
+      assertEquals("Cell center should round-trip point", 
S2CellId.fromPoint(pts.get(i)), center);
+    }
+  }
+
+  @Test
+  public void testLargePointUnionCovering() {
+    // 1) Build 100 distinct points
+    List<S2Point> pts = new ArrayList<>();
+    for (int i = 0; i < 100; i++) {
+      double lat = i * 0.5 - 25;
+      double lng = (i * 3.6) - 180;
+      pts.add(S2LatLng.fromDegrees(lat, lng).toPoint());
+    }
+
+    // 2) Create your geography
+    PointGeography geo = new PointGeography(pts);
+
+    // 3) Ask it for its cell-union bound
+    List<S2CellId> cover = new ArrayList<>();
+    geo.getCellUnionBound(cover);
+
+    // 4) Check the size is non-zero (or == some expected value)
+    assertTrue("Covering size should be > 0", cover.size() > 0);
+
+    // 5) Verify *every* input point lies in at least one covering cell
+    for (S2Point p : pts) {
+      boolean covered = false;
+      for (S2CellId cid : cover) {
+        S2Cell cell = new S2Cell(cid);
+        if (cell.contains(p)) {
+          covered = true;
+          break;
+        }
+      }
+      assertTrue("Point " + p + " was not covered by any cell", covered);
+    }
+  }
+}
diff --git 
a/common/src/test/java/org/apache/sedona/common/S2Geography/PolygonGeographyTest.java
 
b/common/src/test/java/org/apache/sedona/common/S2Geography/PolygonGeographyTest.java
new file mode 100644
index 0000000000..5a1ace7976
--- /dev/null
+++ 
b/common/src/test/java/org/apache/sedona/common/S2Geography/PolygonGeographyTest.java
@@ -0,0 +1,45 @@
+/*
+ * 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.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+
+public class PolygonGeographyTest {
+  @Test
+  public void testEncodedPolygon() throws IOException {
+    S2Point pt = S2LatLng.fromDegrees(45, -64).toPoint();
+    S2Point pt_mid = S2LatLng.fromDegrees(45, 0).toPoint();
+    S2Point pt_end = S2LatLng.fromDegrees(0, 0).toPoint();
+    // Build a single polygon and wrap in geography
+    List<S2Point> points = new ArrayList<>();
+    points.add(pt);
+    points.add(pt_mid);
+    points.add(pt_end);
+    points.add(pt);
+    S2Loop polyline = new S2Loop(points);
+    S2Polygon poly = new S2Polygon(polyline);
+    PolygonGeography geo = new PolygonGeography(poly);
+
+    TestHelper.assertRoundTrip(geo, new EncodeOptions());
+  }
+}
diff --git 
a/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java
 
b/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java
new file mode 100644
index 0000000000..3a3a1fea9a
--- /dev/null
+++ 
b/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.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;
+
+public class PolylineGeographyTest {
+  @Test
+  public void testEncodedPolyline() throws IOException {
+    // Create two points
+    S2Point ptStart = S2LatLng.fromDegrees(45, -64).toPoint();
+    S2Point ptEnd = S2LatLng.fromDegrees(0, 0).toPoint();
+    // Build a single polyline and wrap in geography
+    List<S2Point> points = new ArrayList<>();
+    points.add(ptStart);
+    points.add(ptEnd);
+    S2Polyline polyline = new S2Polyline(points);
+    PolylineGeography geog = new PolylineGeography(polyline);
+    TestHelper.assertRoundTrip(geog, new EncodeOptions());
+  }
+
+  @Test
+  public void testEncodedMultiPolyline() throws IOException {
+    // create multiple polylines
+    S2Point a = S2LatLng.fromDegrees(45, -64).toPoint();
+    S2Point b = S2LatLng.fromDegrees(0, 0).toPoint();
+    S2Point c = S2LatLng.fromDegrees(-30, 20).toPoint();
+    S2Point d = S2LatLng.fromDegrees(10, -10).toPoint();
+
+    S2Polyline poly1 = new S2Polyline(List.of(a, b));
+    S2Polyline poly2 = new S2Polyline(List.of(c, d));
+
+    // 2) Wrap both in a single geography
+    PolylineGeography geog = new PolylineGeography(List.of(poly1, poly2));
+    TestHelper.assertRoundTrip(geog, new EncodeOptions());
+  }
+
+  @Test
+  public void testEncodedMultiPolylineHint() throws IOException {
+    // create multiple polylines
+    S2Point a = S2LatLng.fromDegrees(45, -64).toPoint();
+    S2Point b = S2LatLng.fromDegrees(0, 0).toPoint();
+    S2Point c = S2LatLng.fromDegrees(-30, 20).toPoint();
+    S2Point d = S2LatLng.fromDegrees(10, -10).toPoint();
+
+    S2Polyline poly1 = new S2Polyline(List.of(a, b));
+    S2Polyline poly2 = new S2Polyline(List.of(c, d));
+
+    // 2) Wrap both in a single geography
+    PolylineGeography geog = new PolylineGeography(List.of(poly1, poly2));
+
+    // 3) Encode to bytes
+    EncodeOptions encodeOptions = new EncodeOptions();
+    encodeOptions.setCodingHint(EncodeOptions.CodingHint.COMPACT);
+    TestHelper.assertRoundTrip(geog, encodeOptions);
+  }
+}
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
new file mode 100644
index 0000000000..5449584859
--- /dev/null
+++ b/common/src/test/java/org/apache/sedona/common/S2Geography/TestHelper.java
@@ -0,0 +1,151 @@
+/*
+ * 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 static org.junit.Assert.assertTrue;
+
+import com.esotericsoftware.kryo.io.UnsafeInput;
+import com.google.common.geometry.*;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.List;
+
+public class TestHelper {
+
+  public static void assertRoundTrip(S2Geography original, EncodeOptions opts) 
throws IOException {
+    // 1) Encode to bytes
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    original.encodeTagged(baos, opts);
+    byte[] data = baos.toByteArray();
+
+    // 2) Decode back
+    ByteArrayInputStream in = new ByteArrayInputStream(data);
+    S2Geography decoded = original.decodeTagged(in);
+
+    // 3) Compare kind, shapes, dimension
+    assertEquals("Kind should round-trip", original.kind, decoded.kind);
+    assertEquals("Shape count should round-trip", original.numShapes(), 
decoded.numShapes());
+    assertEquals("Dimension should round-trip", original.dimension(), 
decoded.dimension());
+
+    // 4) Geometry-specific checks + region containment of each vertex
+    if (original instanceof PointGeography && decoded instanceof 
PointGeography) {
+      List<S2Point> ptsOrig = ((PointGeography) original).getPoints();
+      List<S2Point> ptsDec = ((PointGeography) decoded).getPoints();
+      assertEquals("Point list size", ptsOrig.size(), ptsDec.size());
+      assertEquals("Point coordinates", ptsOrig, ptsDec);
+      ptsOrig.forEach(
+          p -> assertTrue("Region should contain point " + p, 
decoded.region().contains(p)));
+
+    } else if (original instanceof PolylineGeography && decoded instanceof 
PolylineGeography) {
+      List<S2Polyline> a = ((PolylineGeography) original).getPolylines();
+      List<S2Polyline> b = ((PolylineGeography) decoded).getPolylines();
+      assertEquals("Polyline list size mismatch", a.size(), b.size());
+      for (int i = 0; i < a.size(); i++) {
+        S2Polyline pOrig = a.get(i);
+        S2Polyline pDec = b.get(i);
+        assertEquals(
+            "Vertex count mismatch in polyline[" + i + "]",
+            pOrig.numVertices(),
+            pDec.numVertices());
+        for (int v = 0; v < pOrig.numVertices(); v++) {
+          assertEquals(
+              "Vertex coordinate mismatch at polyline[" + i + "] vertex[" + v 
+ "]",
+              pOrig.vertex(v),
+              pDec.vertex(v));
+        }
+      }
+
+    } else if (original instanceof PolygonGeography && decoded instanceof 
PolygonGeography) {
+      PolygonGeography a = (PolygonGeography) original;
+      PolygonGeography b = (PolygonGeography) decoded;
+
+      S2Polygon pgOrig = a.polygon;
+      S2Polygon pgDec = b.polygon;
+      assertEquals(
+          "Loop count mismatch in polygon[" + 1 + "]", pgOrig.numLoops(), 
pgDec.numLoops());
+      S2Loop loopOrig = pgOrig.loop(0);
+      S2Loop loopDec = pgDec.loop(0);
+      assertEquals(
+          "Vertex count mismatch in loop[" + 1 + "] of polygon[" + 1 + "]",
+          loopOrig.numVertices(),
+          loopDec.numVertices());
+      for (int v = 0; v < loopOrig.numVertices(); v++) {
+        assertEquals(
+            "Vertex mismatch at polygon[" + 1 + "] loop[" + 1 + "] vertex[" + 
v + "]",
+            loopOrig.vertex(v),
+            loopDec.vertex(v));
+      }
+    }
+  }
+
+  /**
+   * Asserts that the EncodeTag for the given geography honors the 
includeCovering option; if
+   * includeCovering==true, coveringSize should be >0, otherwise it must be 
zero.
+   */
+  public static void assertCovering(S2Geography original, EncodeOptions opts) 
throws IOException {
+    // encode and read only the tag
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    original.encodeTagged(baos, opts);
+    UnsafeInput in = new UnsafeInput(new 
ByteArrayInputStream(baos.toByteArray()));
+    EncodeTag tag = EncodeTag.decode(in);
+    int cov = tag.getCoveringSize() & 0xFF;
+    if (opts.isIncludeCovering()) {
+      assertTrue("Expected coveringSize>0 when includeCovering=true, got " + 
cov, cov > 0);
+    } else {
+      assertEquals("Expected coveringSize==0 when includeCovering=false", 0, 
cov);
+    }
+  }
+
+  /** Converts an S2Point into a 6-decimal-place WKT POINT string. */
+  public static String toPointWkt(S2Point p) {
+    S2LatLng ll = new S2LatLng(p);
+    return String.format("POINT (%.6f %.6f)", ll.lng().degrees(), 
ll.lat().degrees());
+  }
+  /** Converts an S2Polyline into a 0-decimal-place WKT LINESTRING string. */
+  public static String toPolylineWkt(S2Polyline pl) {
+    StringBuilder sb = new StringBuilder("LINESTRING (");
+    for (int i = 0; i < pl.numVertices(); i++) {
+      S2LatLng ll = new S2LatLng(pl.vertex(i));
+      if (i > 0) sb.append(", ");
+      sb.append(String.format("%.0f %.0f", ll.lng().degrees(), 
ll.lat().degrees()));
+    }
+    sb.append(")");
+    return sb.toString();
+  }
+  /** Converts an S2Polygon (single-loop) into a 0-decimal-place WKT POLYGON 
string. */
+  public static String toPolygonWkt(S2Polygon polygon) {
+    // Assumes a single outer loop
+    S2Loop loop = polygon.loop(0);
+    StringBuilder sb = new StringBuilder("POLYGON ((");
+    int n = loop.numVertices();
+    for (int i = 0; i < n; i++) {
+      S2LatLng ll = new S2LatLng(loop.vertex(i));
+      if (i > 0) sb.append(", ");
+      sb.append(String.format("%.0f %.0f", ll.lng().degrees(), 
ll.lat().degrees()));
+    }
+    // close the ring by repeating the first vertex
+    S2LatLng first = new S2LatLng(loop.vertex(0));
+    sb.append(", ")
+        .append(String.format("%.0f %.0f", first.lng().degrees(), 
first.lat().degrees()));
+    sb.append("))");
+    return sb.toString();
+  }
+}

Reply via email to