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 <[email protected]>
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();
+ }
+}