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);
+ }
+}