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