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 29bfc9932d [GH-2028] Implementations of GeographyCollection & 
ShapeIndexGeography on S2Geography (#2027)
29bfc9932d is described below

commit 29bfc9932de94fd3d3d22b6553380a8b15846409
Author: Zhuocheng Shang <[email protected]>
AuthorDate: Wed Jul 9 15:50:38 2025 -0700

    [GH-2028] Implementations of GeographyCollection & ShapeIndexGeography on 
S2Geography (#2027)
    
    * 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
    
    * change addIndex return type to void
    
    * minor changes for GeographyCollection encode
    
    * clear format
---
 .../sedona/common/S2Geography/EncodeOptions.java   |   5 +
 .../S2Geography/EncodedShapeIndexGeography.java    | 140 ++++++++++++++++++++
 .../common/S2Geography/GeographyCollection.java    | 147 +++++++++++++++++++++
 .../sedona/common/S2Geography/GeographyIndex.java  | 115 ++++++++++++++++
 .../common/S2Geography/PolygonGeography.java       |   1 -
 .../sedona/common/S2Geography/S2Geography.java     |   8 +-
 .../common/S2Geography/ShapeIndexGeography.java    | 110 +++++++++++++++
 .../S2Geography/GeographyCollectionTest.java       | 119 +++++++++++++++++
 .../S2Geography/ShapeIndexGeographyTest.java       |  98 ++++++++++++++
 9 files changed, 738 insertions(+), 5 deletions(-)

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
index 6c255e7ef4..fed37e9e96 100644
--- 
a/common/src/main/java/org/apache/sedona/common/S2Geography/EncodeOptions.java
+++ 
b/common/src/main/java/org/apache/sedona/common/S2Geography/EncodeOptions.java
@@ -36,6 +36,11 @@ public class EncodeOptions {
 
   public EncodeOptions() {}
 
+  public EncodeOptions(EncodeOptions other) {
+    this.codingHint = other.codingHint;
+    this.enableLazyDecode = other.enableLazyDecode;
+    this.includeCovering = other.includeCovering;
+  }
   /** Control FAST vs. COMPACT encoding. */
   public void setCodingHint(CodingHint hint) {
     this.codingHint = hint;
diff --git 
a/common/src/main/java/org/apache/sedona/common/S2Geography/EncodedShapeIndexGeography.java
 
b/common/src/main/java/org/apache/sedona/common/S2Geography/EncodedShapeIndexGeography.java
new file mode 100644
index 0000000000..53a0b67f5b
--- /dev/null
+++ 
b/common/src/main/java/org/apache/sedona/common/S2Geography/EncodedShapeIndexGeography.java
@@ -0,0 +1,140 @@
+/*
+ * 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.ArrayList;
+import java.util.List;
+import java.util.logging.Logger;
+
+public class EncodedShapeIndexGeography extends S2Geography {
+  private static final Logger logger = 
Logger.getLogger(EncodedShapeIndexGeography.class.getName());
+
+  public S2ShapeIndex shapeIndex;
+
+  /** Build an empty ShapeIndexGeography. */
+  public EncodedShapeIndexGeography() {
+    super(GeographyKind.ENCODED_SHAPE_INDEX);
+    this.shapeIndex = new S2ShapeIndex();
+  }
+
+  @Override
+  public int dimension() {
+    return -1;
+  }
+
+  @Override
+  public int numShapes() {
+    return shapeIndex.getShapes().size();
+  }
+
+  @Override
+  public S2Shape shape(int id) {
+    return shapeIndex.getShapes().get(id);
+  }
+
+  @Override
+  public S2Region region() {
+    return new S2ShapeIndexRegion(shapeIndex);
+  }
+
+  /**
+   * Index every S2Shape from the given Geography.
+   *
+   * @return the last shapeId assigned.
+   */
+  public int addIndex(S2Geography geog) {
+    int lastId = -1;
+    for (int i = 0, n = geog.numShapes(); i < n; i++) {
+      shapeIndex.add(geog.shape(i));
+      // since add() appends to the end, its index is size-1
+      lastId = shapeIndex.getShapes().size() - 1;
+    }
+    return lastId;
+  }
+
+  /** Add one raw shape into the index, return its new ID */
+  public int addIndex(S2Shape shape) {
+    shapeIndex.add(shape);
+    return shapeIndex.getShapes().size() - 1;
+  }
+
+  @Override
+  protected void encode(UnsafeOutput os, EncodeOptions opts) throws 
IOException {
+    throw new IOException("Encode() not implemented for 
EncodedShapeIndexGeography()");
+  }
+  // decode
+  /** This is what decodeTagged() actually calls */
+  public static EncodedShapeIndexGeography 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 EncodedShapeIndexGeography decode(UnsafeInput in, EncodeTag 
tag)
+      throws IOException {
+    EncodedShapeIndexGeography encodedShapeIndexGeography = new 
EncodedShapeIndexGeography();
+
+    // EMPTY
+    if ((tag.getFlags() & EncodeTag.FLAG_EMPTY) != 0) {
+      logger.fine("Decoded empty EncodedShapeIndexGeography.");
+      return encodedShapeIndexGeography;
+    }
+
+    // 2) Skip past any covering cell-IDs written by encodeTagged
+    tag.skipCovering(in);
+
+    int length = in.readInt();
+    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
+    PrimitiveArrays.Bytes bytes =
+        new PrimitiveArrays.Bytes() {
+          @Override
+          public long length() {
+            return payload.length;
+          }
+
+          @Override
+          public byte get(long i) {
+            return payload[(int) i];
+          }
+        };
+
+    List<S2Shape> s2Shapes = new ArrayList<>();
+    if (tag.isCompact()) {
+      s2Shapes = VectorCoder.COMPACT_SHAPE.decode(bytes);
+    } else {
+      s2Shapes = VectorCoder.FAST_SHAPE.decode(bytes);
+    }
+    for (S2Shape shape : s2Shapes) encodedShapeIndexGeography.addIndex(shape);
+    return encodedShapeIndexGeography;
+  }
+}
diff --git 
a/common/src/main/java/org/apache/sedona/common/S2Geography/GeographyCollection.java
 
b/common/src/main/java/org/apache/sedona/common/S2Geography/GeographyCollection.java
new file mode 100644
index 0000000000..e18e42651d
--- /dev/null
+++ 
b/common/src/main/java/org/apache/sedona/common/S2Geography/GeographyCollection.java
@@ -0,0 +1,147 @@
+/*
+ * 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.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 wrapping zero or more Geography objects, representing a 
GEOMETRYCOLLECTION. */
+public class GeographyCollection extends S2Geography {
+  private static final Logger logger = 
Logger.getLogger(GeographyCollection.class.getName());
+
+  public final List<S2Geography> features;
+  public final List<Integer> numShapesList;
+  public int totalShapes;
+
+  /** Constructs an empty GeographyCollection. */
+  public GeographyCollection() {
+    super(GeographyKind.GEOGRAPHY_COLLECTION);
+    this.features = new ArrayList<>();
+    this.numShapesList = new ArrayList<>();
+    this.totalShapes = 0;
+  }
+
+  /** Wraps existing Geography features. */
+  public GeographyCollection(List<S2Geography> features) {
+    super(GeographyKind.GEOGRAPHY_COLLECTION);
+    this.features = new ArrayList<>(features);
+    this.numShapesList = new ArrayList<>();
+    this.totalShapes = 0;
+    countShapes();
+  }
+
+  @Override
+  public int dimension() {
+    // Mixed or empty → return -1; uniform → return 0,1,2
+    return computeDimensionFromShapes();
+  }
+
+  @Override
+  public int numShapes() {
+    return totalShapes;
+  }
+
+  @Override
+  public S2Shape shape(int id) {
+    int sum = 0;
+    for (int i = 0; i < features.size(); i++) {
+      int n = numShapesList.get(i);
+      sum += n;
+      if (id < sum) {
+        // index within this feature
+        return features.get(i).shape(id - (sum - n));
+      }
+    }
+    throw new IllegalArgumentException("Shape id out of bounds: " + id);
+  }
+
+  @Override
+  public S2Region region() {
+    Collection<S2Region> regs = new ArrayList<>();
+    for (S2Geography geo : features) {
+      regs.add(geo.region());
+    }
+    return new S2RegionUnion(regs);
+  }
+
+  /** Returns an immutable copy of the features list. */
+  public List<S2Geography> getFeatures() {
+    return ImmutableList.copyOf(features);
+  }
+
+  @Override
+  public void encode(UnsafeOutput out, EncodeOptions opts) throws IOException {
+    // Top-level collection encodes its size and then each child with tagging
+    // Never include coverings for children (only a top-level concept
+    EncodeOptions childOptions = new EncodeOptions(opts);
+    childOptions.setIncludeCovering(false);
+    out.writeInt(features.size());
+    for (S2Geography feature : features) {
+      feature.encodeTagged(out, opts);
+    }
+    out.flush();
+  }
+
+  /** Decodes a GeographyCollection from a tagged input stream. */
+  public static GeographyCollection decode(UnsafeInput in, EncodeTag tag) 
throws IOException {
+    GeographyCollection geo = new GeographyCollection();
+
+    // Handle EMPTY flag
+    if ((tag.getFlags() & EncodeTag.FLAG_EMPTY) != 0) {
+      logger.fine("Decoded empty GeographyCollection.");
+      return geo;
+    }
+
+    // Skip any covering data
+    tag.skipCovering(in);
+
+    // 3) Ensure we have at least 4 bytes for the count
+    if (in.available() < Integer.BYTES) {
+      throw new IOException("GeographyCollection.decodeTagged error: 
insufficient header bytes");
+    }
+
+    // Read feature count
+    int n = in.readInt();
+    for (int i = 0; i < n; i++) {
+      tag = EncodeTag.decode(in);
+      // avoid creating new stream, directly call S2Geography.decode
+      S2Geography feature = S2Geography.decode(in, tag);
+      geo.features.add(feature);
+    }
+    geo.countShapes();
+    return geo;
+  }
+
+  private void countShapes() {
+    numShapesList.clear();
+    totalShapes = 0;
+    for (S2Geography geo : features) {
+      int n = geo.numShapes();
+      numShapesList.add(n);
+      totalShapes += n;
+    }
+  }
+}
diff --git 
a/common/src/main/java/org/apache/sedona/common/S2Geography/GeographyIndex.java 
b/common/src/main/java/org/apache/sedona/common/S2Geography/GeographyIndex.java
new file mode 100644
index 0000000000..9203426fe2
--- /dev/null
+++ 
b/common/src/main/java/org/apache/sedona/common/S2Geography/GeographyIndex.java
@@ -0,0 +1,115 @@
+/*
+ * 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.S2CellId;
+import com.google.common.geometry.S2Iterator;
+import com.google.common.geometry.S2ShapeIndex;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+
+public class GeographyIndex {
+  private final S2ShapeIndex index;
+  private List<Integer> values;
+
+  public GeographyIndex() {
+    this(new S2ShapeIndex.Options());
+  }
+
+  public GeographyIndex(S2ShapeIndex.Options options) {
+    this.index = new S2ShapeIndex(options);
+    this.values = new ArrayList<>(); // list of shape id
+  }
+
+  public void add(S2Geography geog, int value) {
+    for (int i = 0; i < geog.numShapes(); i++) {
+      index.add(geog.shape(i));
+      int shapeId = index.getShapes().size();
+      values.add(shapeId, value);
+    }
+  }
+  /** Returns the stored value for a given shape ID. */
+  public int value(int shapeId) {
+    return values.get(shapeId);
+  }
+
+  /** Provides read-only access to the underlying S2ShapeIndex. */
+  public S2ShapeIndex getShapeIndex() {
+    return index;
+  }
+
+  /** Iterator to query indexed shapes by S2CellId coverings. */
+  public static class Iterator {
+    private final GeographyIndex parent;
+    private final S2Iterator.ListIterator<S2ShapeIndex.Cell> iterator;
+
+    public Iterator(GeographyIndex index) {
+      this.parent = index;
+      this.iterator = index.getShapeIndex().iterator();
+    }
+
+    /** Query all cell IDs in the covering list, collecting associated values. 
*/
+    public void query(Iterable<S2CellId> covering, Set<Integer> result) {
+      for (S2CellId cell : covering) {
+        query(cell, result);
+      }
+    }
+
+    /** Query a single cell ID, adding any intersecting values to result. */
+    public void query(S2CellId cellId, Set<Integer> result) {
+      S2ShapeIndex.CellRelation relation = iterator.locate(cellId);
+      if (relation == S2ShapeIndex.CellRelation.INDEXED) {
+        S2ShapeIndex.Cell cell = iterator.entry();
+        List<S2ShapeIndex.S2ClippedShape> clippedShape = cell.clippedShapes();
+        for (int k = 0; k < clippedShape.size(); k++) {
+          int sid = cell.clipped(k).shapeId();
+          result.add(parent.value(sid));
+        }
+      } else if (relation == S2ShapeIndex.CellRelation.SUBDIVIDED) {
+        // Promising! the index has a child cell of iterator_.id()
+        // (at which iterator_ is now positioned). Keep iterating until the
+        // iterator is done OR we're no longer at a child cell of
+        // iterator_.id(). The ordering of the iterator isn't guaranteed
+        // anywhere in the documentation; however, this ordering would be
+        // consistent with that of a Normalized S2CellUnion.
+        while (!iterator.done() && cellId.contains(iterator.id())) {
+          S2ShapeIndex.Cell cell = iterator.entry();
+          List<S2ShapeIndex.S2ClippedShape> clippedShape = 
cell.clippedShapes();
+          for (int k = 0; k < clippedShape.size(); k++) {
+            int sid = cell.clipped(k).shapeId();
+            result.add(parent.value(sid));
+          }
+          iterator.next();
+        }
+      }
+      // DISJOINT: nothing to add
+    }
+  }
+
+  private static int[] expand(int[] array, int newSize) {
+    if (array == null) {
+      return new int[newSize];
+    } else if (array.length < newSize) {
+      return Arrays.copyOf(array, newSize);
+    }
+    return array;
+  }
+}
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 1927566c27..2dccbf05d4 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
@@ -69,7 +69,6 @@ public class PolygonGeography extends S2Geography {
 
   @Override
   public void encode(UnsafeOutput out, EncodeOptions opts) throws IOException {
-    // Encode polygon
     polygon.encode(out);
     out.flush();
   }
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 274a041a54..567dc3036b 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
@@ -201,10 +201,10 @@ public abstract class S2Geography {
         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);
+      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());
     }
diff --git 
a/common/src/main/java/org/apache/sedona/common/S2Geography/ShapeIndexGeography.java
 
b/common/src/main/java/org/apache/sedona/common/S2Geography/ShapeIndexGeography.java
new file mode 100644
index 0000000000..297b277a65
--- /dev/null
+++ 
b/common/src/main/java/org/apache/sedona/common/S2Geography/ShapeIndexGeography.java
@@ -0,0 +1,110 @@
+/*
+ * 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.Output;
+import com.esotericsoftware.kryo.io.UnsafeInput;
+import com.esotericsoftware.kryo.io.UnsafeOutput;
+import com.google.common.geometry.*;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+public class ShapeIndexGeography extends S2Geography {
+  public S2ShapeIndex shapeIndex;
+
+  /** Build an empty ShapeIndexGeography. */
+  public ShapeIndexGeography() {
+    super(GeographyKind.SHAPE_INDEX);
+    this.shapeIndex = new S2ShapeIndex();
+  }
+
+  /** Build and immediately add one Geography. */
+  public ShapeIndexGeography(S2Geography geog) {
+    super(GeographyKind.SHAPE_INDEX);
+    this.shapeIndex = new S2ShapeIndex();
+    addIndex(geog);
+  }
+
+  /** Create a ShapeIndexGeography with a custom max-edges-per-cell. */
+  public ShapeIndexGeography(int maxEdgesPerCell) {
+    super(GeographyKind.SHAPE_INDEX);
+    S2ShapeIndex.Options options = new S2ShapeIndex.Options();
+    options.setMaxEdgesPerCell(maxEdgesPerCell);
+    this.shapeIndex = new S2ShapeIndex(options);
+  }
+
+  @Override
+  public int dimension() {
+    return -1;
+  }
+
+  @Override
+  public int numShapes() {
+    return shapeIndex.getShapes().size();
+  }
+
+  @Override
+  public S2Shape shape(int id) {
+    S2Shape raw = shapeIndex.getShapes().get(id);
+    return raw;
+  }
+
+  @Override
+  public S2Region region() {
+    return new S2ShapeIndexRegion(shapeIndex);
+  }
+  /** Index every S2Shape from the given Geography. */
+  public void addIndex(S2Geography geog) {
+    for (int i = 0, n = geog.numShapes(); i < n; i++) {
+      shapeIndex.add(geog.shape(i));
+    }
+  }
+
+  // encode
+  @Override
+  public void encode(UnsafeOutput out, EncodeOptions opts) throws IOException {
+    // 0) Prepare a temporary output for the payload
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    Output tmpOut = new Output(baos);
+    switch (opts.getCodingHint()) {
+      case FAST:
+        VectorCoder.FAST_SHAPE.encode(shapeIndex.getShapes(), tmpOut);
+        break;
+      case COMPACT:
+        VectorCoder.COMPACT_SHAPE.encode(shapeIndex.getShapes(), tmpOut);
+    }
+    // 2) Finish payload
+    tmpOut.flush();
+    byte[] payload = baos.toByteArray();
+
+    // 3) Write length-prefix + payload to the real out
+    out.writeInt(payload.length, /* optimizePositive= */ false);
+    out.writeBytes(payload);
+
+    // 4) Encode the index’s quadtree structure and flush
+    S2ShapeIndexCoder.INSTANCE.encode(shapeIndex, out);
+    out.flush();
+  }
+
+  // decode
+  /** This is what decodeTagged() actually calls */
+  public static ShapeIndexGeography decode(UnsafeInput in, EncodeTag tag) 
throws IOException {
+    throw new IOException("Decode() not implemented for 
ShapeIndexGeography()");
+  }
+}
diff --git 
a/common/src/test/java/org/apache/sedona/common/S2Geography/GeographyCollectionTest.java
 
b/common/src/test/java/org/apache/sedona/common/S2Geography/GeographyCollectionTest.java
new file mode 100644
index 0000000000..927d5ee110
--- /dev/null
+++ 
b/common/src/test/java/org/apache/sedona/common/S2Geography/GeographyCollectionTest.java
@@ -0,0 +1,119 @@
+/*
+ * 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 junit.framework.TestCase.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import com.google.common.geometry.*;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Test;
+
+public class GeographyCollectionTest {
+  @Test
+  public void testEmptyCollection() {
+    GeographyCollection empty = new GeographyCollection();
+    assertEquals("Empty collection should have zero shapes", 0, 
empty.numShapes());
+    assertEquals("Empty collection dimension", -1, empty.dimension());
+    List<S2CellId> cover = new ArrayList<>();
+    empty.getCellUnionBound(cover);
+    assertTrue("Covering of empty should be empty", cover.isEmpty());
+  }
+
+  @Test
+  public void testSingleFeature() {
+    // Single point geography
+    S2Point p = S2LatLng.fromDegrees(10, 20).toPoint();
+    PointGeography pointGeo = new PointGeography(Arrays.asList(p));
+    List<S2Geography> features = Arrays.<S2Geography>asList(pointGeo);
+    GeographyCollection coll = new GeographyCollection(features);
+
+    assertEquals("Collection numShapes", 1, coll.numShapes());
+    assertEquals("Collection dimension for single point", 0, coll.dimension());
+    // shape(0) should return first shape
+    assertNotNull("shape(0) not null", coll.shape(0));
+    // region should contain the point
+    S2Region region = coll.region();
+    assertTrue("Region should contain point", region.contains(p));
+  }
+
+  @Test
+  public void testMultipleFeaturesAndMixedDimension() {
+    // Two point features (dimension 0)
+    S2Point p1 = S2LatLng.fromDegrees(0, 0).toPoint();
+    S2Point p2 = S2LatLng.fromDegrees(1, 1).toPoint();
+    PointGeography pg1 = new PointGeography(Arrays.asList(p1));
+    PointGeography pg2 = new PointGeography(Arrays.asList(p2));
+
+    // One polyline feature (dimension 1)
+    List<S2Point> linePts = new ArrayList<>();
+    S2Point ptStart = S2LatLng.fromDegrees(2, 2).toPoint();
+    S2Point ptEnd = S2LatLng.fromDegrees(3, 3).toPoint();
+    linePts.add(ptStart);
+    linePts.add(ptEnd);
+    S2Polyline polyline = new S2Polyline(linePts);
+    PolylineGeography polyGeo = new PolylineGeography(polyline);
+
+    List<S2Geography> features = Arrays.<S2Geography>asList(pg1, pg2, polyGeo);
+    GeographyCollection coll = new GeographyCollection(features);
+
+    assertEquals("Collection numShapes", 3, coll.numShapes());
+    assertEquals("Mixed dimensions should return -1", -1, coll.dimension());
+    // test proper dispatch of shape ids
+    assertNotNull(coll.shape(2));
+  }
+
+  @Test // todo: issue with buffer underflow of encode decode geography kind 
differences
+  public void testEncodeDecodeRoundTrip() throws IOException {
+    // prepare collection with two point geos
+    S2Point p1 = S2LatLng.fromDegrees(-10, -20).toPoint();
+    S2Point p2 = S2LatLng.fromDegrees(30, 40).toPoint();
+    PointGeography pg1 = new PointGeography(Arrays.asList(p1));
+    PointGeography pg2 = new PointGeography(Arrays.asList(p2));
+    GeographyCollection original = new 
GeographyCollection(List.<S2Geography>of(pg1, pg2));
+
+    // encode/decode round trip via TestHelper
+    EncodeOptions opts = new EncodeOptions();
+    TestHelper.assertRoundTrip(original, opts);
+  }
+
+  @Test
+  public void testGetCellUnionBoundNonEmpty() {
+    // collection of two distinct points
+    S2Point a = S2LatLng.fromDegrees(0, 0).toPoint();
+    S2Point b = S2LatLng.fromDegrees(10, 10).toPoint();
+    PointGeography pg1 = new PointGeography(List.of(a));
+    PointGeography pg2 = new PointGeography(List.of(b));
+    GeographyCollection coll = new 
GeographyCollection(Arrays.<S2Geography>asList(pg1, pg2));
+
+    List<S2CellId> cover = new ArrayList<>();
+    coll.getCellUnionBound(cover);
+    assertTrue("Covering should not be empty for non-empty collection", 
cover.size() > 0);
+    // ensure each cell covers at least one input point
+    for (S2Point p : Arrays.asList(a, b)) {
+      boolean covered =
+          cover.stream().anyMatch(cid -> new 
com.google.common.geometry.S2Cell(cid).contains(p));
+      assertTrue("Each point should be covered", covered);
+    }
+  }
+}
diff --git 
a/common/src/test/java/org/apache/sedona/common/S2Geography/ShapeIndexGeographyTest.java
 
b/common/src/test/java/org/apache/sedona/common/S2Geography/ShapeIndexGeographyTest.java
new file mode 100644
index 0000000000..e5409ea454
--- /dev/null
+++ 
b/common/src/test/java/org/apache/sedona/common/S2Geography/ShapeIndexGeographyTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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 junit.framework.TestCase.assertEquals;
+
+import com.google.common.geometry.*;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Arrays;
+import org.junit.Test;
+
+public class ShapeIndexGeographyTest {
+  @Test
+  public void testEncodedShapeIndexGeographyRoundTrip() throws IOException {
+    // 1) Build three S2Point landmarks.
+    S2Point pt = S2LatLng.fromDegrees(45, -64).toPoint();
+    S2Point ptMid = S2LatLng.fromDegrees(45, 0).toPoint();
+    S2Point ptEnd = S2LatLng.fromDegrees(0, 0).toPoint();
+
+    PointGeography pointGeography = new PointGeography(pt);
+    S2Polyline polyline = new S2Polyline(Arrays.asList(pt, ptEnd));
+    PolylineGeography lineGeog = new PolylineGeography(polyline);
+
+    S2Loop loop = new S2Loop(Arrays.asList(pt, ptMid, ptEnd));
+    S2Polygon polygon = new S2Polygon(loop);
+    PolygonGeography polygonGeog = new PolygonGeography(polygon);
+
+    // 3) Index them in a ShapeIndexGeography
+    ShapeIndexGeography geog = new ShapeIndexGeography();
+    geog.addIndex(pointGeography);
+    geog.addIndex(lineGeog);
+    geog.addIndex(polygonGeog);
+
+    // 4) Encode
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    geog.encodeTagged(baos, new EncodeOptions());
+
+    byte[] data = baos.toByteArray();
+    ByteArrayInputStream in = new ByteArrayInputStream(data);
+    S2Geography roundtrip = geog.decodeTagged(in);
+
+    // 6) Verify the kind and count
+    assertEquals(S2Geography.GeographyKind.ENCODED_SHAPE_INDEX, 
roundtrip.kind);
+    assertEquals(3, roundtrip.numShapes());
+
+    // 6) Cast back to shape-index geography
+    EncodedShapeIndexGeography idxGeo = (EncodedShapeIndexGeography) roundtrip;
+
+    S2Shape.MutableEdge mutableEdge = new S2Shape.MutableEdge();
+
+    // ---- Point shape ----
+    S2Shape ptShape = idxGeo.shape(0);
+    assertEquals(1, ptShape.numEdges());
+    // A single “edge” for a point: v0 should equal the original point
+    ptShape.getEdge(0, mutableEdge);
+    assertEquals(pt, mutableEdge.a);
+
+    // ---- Line shape ----
+    S2Shape lineShape = idxGeo.shape(1);
+    assertEquals(1, lineShape.numEdges());
+    lineShape.getEdge(0, mutableEdge);
+    assertEquals(pt, mutableEdge.a);
+    assertEquals(ptEnd, mutableEdge.b);
+
+    // ---- Polygon shape ----
+    S2Shape polyShape = idxGeo.shape(2);
+    assertEquals(3, polyShape.numEdges());
+    polyShape.getEdge(0, mutableEdge);
+    assertEquals(pt, mutableEdge.a);
+    assertEquals(ptMid, mutableEdge.b);
+
+    polyShape.getEdge(1, mutableEdge);
+    assertEquals(ptMid, mutableEdge.a);
+    assertEquals(ptEnd, mutableEdge.b);
+
+    polyShape.getEdge(2, mutableEdge);
+    assertEquals(ptEnd, mutableEdge.a);
+    assertEquals(pt, mutableEdge.b);
+  }
+}

Reply via email to