From a9796da6adc457bd0e031ac45f880a90a97ce3dc Mon Sep 17 00:00:00 2001 From: Zhuocheng Shang <122398181+ZhuochengShang@users.noreply.github.com> Date: Fri, 20 Jun 2025 09:14:49 -0700 Subject: [PATCH 01/14] Create object of S2Geography, and implement PoinGeography with its encoder/decoder --- .../S2Geography/CountingPointVectorCoder.java | 110 +++++++++ .../common/S2Geography/EncodeOptions.java | 65 ++++++ .../sedona/common/S2Geography/EncodeTag.java | 112 ++++++++++ .../EncodedShapeIndexGeography.java | 25 +++ .../common/S2Geography/PointGeography.java | 168 ++++++++++++++ .../sedona/common/S2Geography/PointShape.java | 117 ++++++++++ .../common/S2Geography/PointShapeCoders.java | 48 ++++ .../common/S2Geography/S2Geography.java | 208 ++++++++++++++++++ .../S2Geography/PointGeographyTest.java | 189 ++++++++++++++++ 9 files changed, 1042 insertions(+) create mode 100644 common/src/main/java/org/apache/sedona/common/S2Geography/CountingPointVectorCoder.java create mode 100644 common/src/main/java/org/apache/sedona/common/S2Geography/EncodeOptions.java create mode 100644 common/src/main/java/org/apache/sedona/common/S2Geography/EncodeTag.java create mode 100644 common/src/main/java/org/apache/sedona/common/S2Geography/EncodedShapeIndexGeography.java create mode 100644 common/src/main/java/org/apache/sedona/common/S2Geography/PointGeography.java create mode 100644 common/src/main/java/org/apache/sedona/common/S2Geography/PointShape.java create mode 100644 common/src/main/java/org/apache/sedona/common/S2Geography/PointShapeCoders.java create mode 100644 common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java create mode 100644 common/src/test/java/org/apache/sedona/common/S2Geography/PointGeographyTest.java diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/CountingPointVectorCoder.java b/common/src/main/java/org/apache/sedona/common/S2Geography/CountingPointVectorCoder.java new file mode 100644 index 00000000000..9bb16fa8e20 --- /dev/null +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/CountingPointVectorCoder.java @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.sedona.common.S2Geography; + +import com.google.common.geometry.PrimitiveArrays; +import com.google.common.geometry.S2Coder; +import com.google.common.geometry.S2Point; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +public abstract class CountingPointVectorCoder implements S2Coder { + public static final CountingPointVectorCoder INSTANCE = + new CountingPointVectorCoder() { + @Override + public S2Point.Shape decode(PrimitiveArrays.Bytes data, PrimitiveArrays.Cursor cursor) { + return null; + } + }; + + private CountingPointVectorCoder() {} + + @Override + public void encode(S2Point.Shape shape, OutputStream out) throws IOException { + // varint count + writeVarInt(out, shape.numEdges()); + // raw doubles + for (int i = 0; i < shape.numEdges(); i++) { + S2Point p = shape.get(i); + writeDouble(out, p.getX()); + writeDouble(out, p.getY()); + writeDouble(out, p.getZ()); + } + } + + // unused by us: + public S2Point.Shape decode(byte[] data, int offset, int length) { + throw new UnsupportedOperationException(); + } + + public List decode(InputStream in) throws IOException { + long n = readVarInt(in); + List pts = new ArrayList<>((int) n); + for (int i = 0; i < n; i++) { + double x = readDouble(in); + double y = readDouble(in); + double z = readDouble(in); + pts.add(new S2Point(x, y, z)); + } + return pts; + } + + private static void writeVarInt(OutputStream out, long v) throws IOException { + while (true) { + int bits = (int) (v & 0x7F); // grab low 7 bits + v >>>= 7; + if (v != 0) out.write(bits | 0x80); // yes: set continuation bit + else { + out.write(bits); + break; + } + } + } + // [varint(count=N)] [point_0.x][point_0.y][point_0.z] … [point_{N-1}.x][…] + private static long readVarInt(InputStream in) throws IOException { + long res = 0; + int shift = 0; + while (true) { + int b = in.read(); + if (b < 0) throw new IOException("EOF varint"); + res |= (long) (b & 0x7F) << shift; + if ((b & 0x80) == 0) break; + shift += 7; + } + return res; + } + + private static void writeDouble(OutputStream out, double x) throws IOException { + long bits = Double.doubleToLongBits(x); + for (int i = 0; i < 8; i++) out.write((int) (bits >> (8 * i)) & 0xFF); + } + + private static double readDouble(InputStream in) throws IOException { + long bits = 0; + for (int i = 0; i < 8; i++) { + int b = in.read(); + if (b < 0) throw new IOException("EOF double"); + bits |= (long) (b & 0xFF) << (8 * i); + } + return Double.longBitsToDouble(bits); + } +} 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 00000000000..6c255e7ef4c --- /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 00000000000..9fecd93154c --- /dev/null +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/EncodeTag.java @@ -0,0 +1,112 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.sedona.common.S2Geography; + +import com.google.common.geometry.S2CellId; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.List; +import org.apache.sedona.common.S2Geography.S2Geography.GeographyKind; + +public class EncodeTag { + private GeographyKind kind = GeographyKind.UNINITIALIZED; + private byte flags = 0; + private byte coveringSize = 0; + private byte reserved = 0; + + /** If set, geography has zero shapes. */ + public static final byte FLAG_EMPTY = 1; + + public EncodeTag() {} + + // ——— Write the 4-byte tag header —————————————————————————————————————— + + /** Write exactly 4 bytes: [kind|flags|coveringSize|reserved]. */ + public void encode(DataOutputStream out) throws IOException { + out.writeByte(kind.getKind()); + out.writeByte(flags); + out.writeByte(coveringSize); + out.writeByte(reserved); // <-- this makes it 4 bytes + } + // ——— Read it back ———————————————————————————————————————————————— + + /** Reads exactly 4 bytes (in the same order) from the stream. */ + public static EncodeTag decode(DataInputStream in) throws IOException { + EncodeTag tag = new EncodeTag(); + tag.kind = GeographyKind.fromKind(in.readUnsignedByte()); + tag.flags = (byte) in.readUnsignedByte(); + tag.coveringSize = (byte) in.readUnsignedByte(); + int r = in.readUnsignedByte(); + if (r != 0) throw new IOException("Reserved header byte must be 0, was " + r); + return tag; + } + + // ——— Helpers for the optional covering list ————————————————————————— + + /** Read coveringSize many cell-ids and add them to cellIds. */ + public void decodeCovering(DataInputStream in, List 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(DataInputStream 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 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; + } +} diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/EncodedShapeIndexGeography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/EncodedShapeIndexGeography.java new file mode 100644 index 00000000000..5760985c14e --- /dev/null +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/EncodedShapeIndexGeography.java @@ -0,0 +1,25 @@ +/* + * 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 EncodedShapeIndexGeography extends S2Geography { + public EncodedShapeIndexGeography(GeographyKind geographyKind) { + super(geographyKind); + } +} 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 00000000000..9638b93a5a3 --- /dev/null +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PointGeography.java @@ -0,0 +1,168 @@ +/* + * 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.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +public class PointGeography extends S2Geography { + // Underlying list of points + private final List points = new ArrayList<>(); + + /** Constructs an empty PointGeography. */ + public PointGeography() { + super(GeographyKind.POINT); + } + + /** Constructs a single-point geography. */ + public PointGeography(S2Point point) { + this(); + points.add(point); + } + + /** Constructs from a list of points. */ + public PointGeography(List pts) { + this(); + points.addAll(pts); + } + + @Override + public int dimension() { + // Points are 0-dimensional (or -1 if empty) + return points.isEmpty() ? -1 : 0; + } + + @Override + public int numShapes() { + // Represent all points as a single composite shape + return points.isEmpty() ? 0 : 1; + } + + @Override + public S2Shape shape(int id) { + if (numShapes() == 0) { + throw new IllegalStateException("No shapes in empty PointGeography"); + } + if (id != 0) { + throw new IllegalArgumentException("Shape id out of bounds: " + id); + } + return new PointShape(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 pointRegionCollection = new ArrayList<>(); + for (S2Point p : points) { + pointRegionCollection.add(new S2PointRegion(p)); + } + S2RegionUnion union = new S2RegionUnion(pointRegionCollection); + return union; + } + } + + @Override + public void getCellUnionBound(List 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 getPoints() { + return Collections.unmodifiableList(points); + } + + // ------------------------------------------------------- + // EncodeTagged / DecodeTagged + // ------------------------------------------------------- + + @Override + public void encodeTagged(OutputStream os, EncodeOptions opts) throws IOException { + DataOutputStream out = new DataOutputStream(os); + + // CELL_CENTER path + if (points.size() == 1 && opts.getCodingHint() == EncodeOptions.CodingHint.COMPACT) { + S2CellId cid = S2CellId.fromPoint(points.get(0)); + if (cid.level() >= 23) { + out.writeByte(S2Geography.GeographyKind.CELL_CENTER.getKind()); + out.writeByte(0); + out.writeByte(1); + out.writeByte(0); + out.writeLong(cid.id()); + return; + } + } + + // EMPTY path + if (points.isEmpty()) { + EncodeTag tag = new EncodeTag(); + tag.setKind(GeographyKind.POINT); + tag.setFlags((byte) (tag.getFlags() | EncodeTag.FLAG_EMPTY)); + tag.setCoveringSize((byte) 0); + tag.encode(out); + return; + } + + // header POINT + List cover = new ArrayList<>(); + EncodeTag tag = new EncodeTag(); + tag.setKind(GeographyKind.POINT); + if (opts.isIncludeCovering()) getCellUnionBound(cover); + tag.setCoveringSize((byte) cover.size()); + tag.encode(out); + for (var c2 : cover) out.writeLong(c2.id()); + + // payload + S2Point.Shape shp = S2Point.Shape.fromList(points); + if (opts.getCodingHint() == EncodeOptions.CodingHint.FAST) { + CountingPointVectorCoder.INSTANCE.encode(shp, out); + } else { + PointShapeCoders.COMPACT.encode(shp, out); + } + } + + public static PointGeography decodeTagged(DataInputStream in, EncodeTag tag) throws IOException { + PointGeography geo = new PointGeography(); + // EMPTY? + if ((tag.getFlags() & EncodeTag.FLAG_EMPTY) != 0) { + return geo; + } + + // RAW-vector (FAST) decode: varint count + nPoints × (double x,y,z) + // FULL-FAST: varint count + n×(x,y,z) + geo.points.addAll(CountingPointVectorCoder.INSTANCE.decode(in)); + return geo; + } +} diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/PointShape.java b/common/src/main/java/org/apache/sedona/common/S2Geography/PointShape.java new file mode 100644 index 00000000000..359ccc75d27 --- /dev/null +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PointShape.java @@ -0,0 +1,117 @@ +/* + * 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 com.google.common.geometry.S2Edge; +import com.google.common.geometry.S2Point; +import com.google.common.geometry.S2Shape; +import java.util.List; + +/** + * Java equivalent of the C++ S2PointVectorShape: each point is a degenerate edge (start==end), one + * chain per point. + */ +/** A degenerate S2Shape that represents exactly one point. */ +public final class PointShape implements S2Shape { + private final List point; + + public PointShape(List point) { + this.point = point; + } + + public int num_points() { + return point.size(); + } + + @Override + public int numEdges() { + return num_points(); + } + + @Override + public void getEdge(int index, MutableEdge result) { + if (index != 0) { + throw new IndexOutOfBoundsException("PointShape has exactly one edge"); + } + result.set(point.get(index), point.get(index)); + } + + public S2Edge edge(int e) { + return new S2Edge(point.get(e), point.get(e)); + } + + @Override + public int dimension() { + return 0; + } + + @Override + public boolean hasInterior() { + return false; + } + + @Override + public boolean containsOrigin() { + return false; + } + + @Override + public ReferencePoint getReferencePoint() { + // hasInterior=false, contained()=false + return ReferencePoint.create(point.get(0), false); + } + + @Override + public int numChains() { + return 1; + } + + @Override + public int getChainStart(int chainId) { + if (chainId != 0) { + throw new IndexOutOfBoundsException("PointShape has exactly one chain"); + } + return 0; + } + + @Override + public int getChainLength(int chainId) { + if (chainId != 0) { + throw new IndexOutOfBoundsException("PointShape has exactly one chain"); + } + return 1; + } + + @Override + public void getChainEdge(int chainId, int offset, MutableEdge result) { + if (chainId != 0 || offset != 0) { + throw new IndexOutOfBoundsException("PointShape chainId and offset must both be 0"); + } + result.set(point.get(chainId), point.get(chainId)); + } + + @Override + public S2Point getChainVertex(int chainId, int offset) { + if (chainId != 0 || offset != 0) { + throw new IndexOutOfBoundsException("PointShape chainId and offset must both be 0"); + } + return point.get(chainId); + } +} diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/PointShapeCoders.java b/common/src/main/java/org/apache/sedona/common/S2Geography/PointShapeCoders.java new file mode 100644 index 00000000000..f0ced6152e0 --- /dev/null +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PointShapeCoders.java @@ -0,0 +1,48 @@ +/* + * 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.S2Coder; +import com.google.common.geometry.S2Point; +import java.lang.reflect.Field; + +public class PointShapeCoders { + public static final S2Coder FAST; + public static final S2Coder COMPACT; + + static { + try { + // Locate the nested Coder class + Class coderClass = Class.forName("com.google.common.geometry.S2Point$Shape$Coder"); + + // Grab the private static fields + Field fastField = coderClass.getDeclaredField("FAST"); + fastField.setAccessible(true); + FAST = (S2Coder) fastField.get(null); + + Field compactField = coderClass.getDeclaredField("COMPACT"); + compactField.setAccessible(true); + COMPACT = (S2Coder) compactField.get(null); + } catch (Exception e) { + throw new RuntimeException("Unable to access S2Point.Shape.Coder fields", e); + } + } + + private PointShapeCoders() {} +} 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 00000000000..b799caf9c5b --- /dev/null +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java @@ -0,0 +1,208 @@ +/* + * 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.apache.sedona.common.S2Geography.S2Geography.GeographyKind.POINT; + +import com.google.common.geometry.*; +import java.io.*; +import java.util.ArrayList; +import java.util.List; + +/** + * An abstract class represent S2Geography. Has 6 subtypes of geography: POINT, POLYLINE, POLYGON, + * GEOGRAPHY_COLLECTION, SHAPE_INDEX, ENCODED_SHAPE_INDEX. + */ +public class S2Geography { + 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 int dimension() { + 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 int numShapes() { + return 0; + } + + /** + * 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 S2Shape shape(int id) { + return null; + } + + /** + * 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 S2Region region() { + return null; + } + + /** + * 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 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 4-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 options CodingHint.FAST / CodingHint.COMPACT + */ + public void encodeTagged(OutputStream outStream, EncodeOptions options) throws IOException { + DataOutputStream out = new DataOutputStream(outStream); + + // 1) build + write tag header + EncodeTag tag = new EncodeTag(); + tag.setKind(kind); + if (numShapes() == 0) { + tag.setFlags((byte) (tag.getFlags() | EncodeTag.FLAG_EMPTY)); + } + // compute covering if requested + List cover = new ArrayList<>(); + if (options.isIncludeCovering()) { + getCellUnionBound(cover); + } + tag.setCoveringSize((byte) cover.size()); + tag.encode(out); + + // 2) write each cell-id + for (S2CellId cid : cover) { + out.writeLong(cid.id()); + } + + // 3) encode by GeographyKind + switch (kind) { + case POINT: + ((PointGeography) this).encodeTagged(out, options); + break; + + // TODO: handle POLYLINE, POLYGON, etc. + default: + throw new IllegalArgumentException("encodeTagged not implemented for kind=" + kind); + } + + out.flush(); + } + + /** + * Reads a tagged geography from the stream (header + covering + payload). Dispatches to the right + * subclass decoder. + */ + public static S2Geography decodeTagged(DataInputStream is) throws IOException { + DataInputStream in = new DataInputStream(is); + + // 1) Read the full 4-byte header: + int kindVal = in.readUnsignedByte(); + byte flags = (byte) in.readUnsignedByte(); + int coverCount = in.readUnsignedByte(); + int reserved = in.readUnsignedByte(); + if (reserved != 0) { + throw new IOException("Reserved header byte must be 0, was " + reserved); + } + + GeographyKind kind = GeographyKind.fromKind(kindVal); + EncodeTag tag = new EncodeTag(); + tag.setKind(kind); + tag.setFlags(flags); + tag.setCoveringSize((byte) coverCount); + + // 2) If CELL_CENTER, read exactly one ID *as* payload, and return immediately: + if (kind == GeographyKind.CELL_CENTER) { + long id = in.readLong(); + return new PointGeography(new S2CellId(id).toPoint()); + } + + // 2) Skip any covering IDs + for (int i = 0; i < coverCount; i++) { + in.readLong(); + } + + // 3) Dispatch to the payload decoder + switch (kind) { + case POINT: + return PointGeography.decodeTagged(in, tag); + // … + default: + throw new IllegalArgumentException("Unsupported kind " + kind); + } + } +} 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 00000000000..f317ca1a5d7 --- /dev/null +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PointGeographyTest.java @@ -0,0 +1,189 @@ +/* + * 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()); + + 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(); + System.out.println(pt.toString()); + PointGeography geog2 = new PointGeography(pt); + assertEquals(1, geog2.numShapes()); + assertEquals(0, geog2.dimension()); + List originalPts = geog2.getPoints(); + assertEquals(1, originalPts.size()); + assertEquals(pt, originalPts.get(0)); + + // 2) 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 { + // --- C++ TEST: EmptyPoint --- + // 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) EncodeTagged + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + geog.encodeTagged(baos, new EncodeOptions()); + byte[] data = baos.toByteArray(); + + // 3) DecodeTagged + DataInputStream din = new DataInputStream(new ByteArrayInputStream(data)); + // 3b) Now delegate to the dispatch method that takes (DataInputStream, EncodeTag) + S2Geography decoded = S2Geography.decodeTagged(din); + + assertTrue(decoded instanceof PointGeography); + PointGeography round = (PointGeography) decoded; + assertTrue(round.getPoints().isEmpty()); + + // 4) region() should be an empty cap + S2Region region = round.region(); + assertTrue(region instanceof S2Cap); + assertTrue(((S2Cap) region).isEmpty()); + } + + @Test + public void testEncodedPoint() throws IOException { + // 1) Create a single-point geography at lat=45°, lng=-64° + S2Point pt = S2LatLng.fromDegrees(45, -64).toPoint(); + System.out.println(pt.toString()); + PointGeography geog = new PointGeography(pt); + assertEquals(1, geog.numShapes()); + assertEquals(0, geog.dimension()); + List originalPts = geog.getPoints(); + assertEquals(1, originalPts.size()); + assertEquals(pt, originalPts.get(0)); + + // 2) EncodeTagged + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + geog.encodeTagged(baos, new EncodeOptions()); + byte[] data = baos.toByteArray(); + + // 3) DecodeTagged + DataInputStream din = new DataInputStream(new ByteArrayInputStream(data)); + S2Geography decoded = S2Geography.decodeTagged(din); + assertTrue(decoded instanceof PointGeography); + PointGeography round = (PointGeography) decoded; + + // 4) Verify everything round-tripped + assertEquals(1, round.numShapes()); + assertEquals(0, round.dimension()); + List roundPts = round.getPoints(); + assertEquals(1, roundPts.size()); + assertEquals(pt, roundPts.get(0)); + + System.out.println(roundPts.toString()); + // 1) Get the point and turn it into WKT with 6 decimal places: + S2Point p = round.getPoints().get(0); + S2LatLng ll = new S2LatLng(p); + String wkt = + String.format( + "POINT (%.6f %.6f)", + ll.lng().degrees(), // longitude first + ll.lat().degrees() // then latitude + ); + System.out.println(wkt); + assertEquals("POINT (-64.000000 45.000000)", wkt); + // 5) region() should contain exactly that point + S2Region region = round.region(); + // Single-point region can be represented as a tiny cap containing only pt + assertTrue(region.contains(pt)); + } + + @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(); + System.out.println(ptSnapped.toString()); + + // 2) EncodeTagged in COMPACT mode + PointGeography geog = new PointGeography(ptSnapped); + EncodeOptions opts = new EncodeOptions(); + opts.setCodingHint(EncodeOptions.CodingHint.COMPACT); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + geog.encodeTagged(baos, opts); + byte[] data = baos.toByteArray(); + + // Exactly 4-byte header + 8-byte cell-id + assertEquals(12, data.length); + + // 3) Peek at the tag + covering + DataInputStream in1 = new DataInputStream(new ByteArrayInputStream(data)); + + // Read and decode the 4-byte header + EncodeTag tag = EncodeTag.decode(in1); + assertEquals(S2Geography.GeographyKind.CELL_CENTER, tag.getKind()); + assertEquals(1, tag.getCoveringSize() & 0xFF); + + // Read the single cell in the covering + List cover = new ArrayList<>(); + tag.decodeCovering(in1, cover); + assertEquals(1, cover.size()); + // Covering must contain the original unsnapped cell + assertEquals(cellId, cover.get(0)); + + // 4) Now round-trip the entire geography + PointGeography round = + (PointGeography) + S2Geography.decodeTagged(new DataInputStream(new ByteArrayInputStream(data))); + System.out.println(round.getPoints().toString()); + + // It should still be a single‐point geography + assertEquals(1, round.getPoints().size()); + // And the point should be exactly the snapped cell‐center + assertEquals(ptSnapped, round.getPoints().get(0)); + } +} From 49a648459c6d098ba44e8e73e35b0fad60be4512 Mon Sep 17 00:00:00 2001 From: Zhuocheng Shang <122398181+ZhuochengShang@users.noreply.github.com> Date: Fri, 20 Jun 2025 14:21:27 -0700 Subject: [PATCH 02/14] Add POLYLINE implementation on S2Geography --- .../S2Geography/CountingPointVectorCoder.java | 2 +- .../common/S2Geography/PolylineGeography.java | 145 ++++++++++++++++++ .../common/S2Geography/S2Geography.java | 14 +- .../S2Geography/PolylineGeographyTest.java | 118 ++++++++++++++ 4 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java create mode 100644 common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/CountingPointVectorCoder.java b/common/src/main/java/org/apache/sedona/common/S2Geography/CountingPointVectorCoder.java index 9bb16fa8e20..91cbed420c2 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/CountingPointVectorCoder.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/CountingPointVectorCoder.java @@ -80,7 +80,7 @@ private static void writeVarInt(OutputStream out, long v) throws IOException { } } // [varint(count=N)] [point_0.x][point_0.y][point_0.z] … [point_{N-1}.x][…] - private static long readVarInt(InputStream in) throws IOException { + static long readVarInt(InputStream in) throws IOException { long res = 0; int shift = 0; while (true) { 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 00000000000..6855a27b357 --- /dev/null +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java @@ -0,0 +1,145 @@ +/* + * 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.collect.ImmutableList; +import com.google.common.geometry.*; +import com.google.common.io.LittleEndianDataOutputStream; + +import java.io.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** A Geography representing zero or more polylines using S2Polyline. */ +public class PolylineGeography extends S2Geography { + private final List 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 polylines) { + super(GeographyKind.POLYLINE); + this.polylines = new ArrayList<>(polylines); + } + + @Override + public int dimension() { + return 1; + } + + @Override + public int numShapes() { + return polylines.size(); + } + + @Override + public S2Shape shape(int id) { + return polylines.get(id); + } + + @Override + public S2Region region() { + Collection polylineRegionCollection = new ArrayList<>(); + polylineRegionCollection.addAll(polylines); + S2RegionUnion union = new S2RegionUnion(polylineRegionCollection); + return union; + } + + @Override + public void getCellUnionBound(List cellIds) { + // Fallback to default Geography logic via shape index region + super.getCellUnionBound(cellIds); + } + + public List getPolylines() { + return ImmutableList.copyOf(polylines); + } + + @Override + public void encodeTagged(OutputStream os, EncodeOptions opts) throws IOException { + // Wrap your stream in a little-endian DataOutput + DataOutputStream leOut = new DataOutputStream(os); + // 1) Write tag header (unchanged) using leOut.writeByte(...) + EncodeTag tag = new EncodeTag(); + tag.setKind(GeographyKind.POLYLINE); + // … include flags / covering … + tag.encode(leOut); + + // 2) Serialize any covering cells (if you have them) + List cover = new ArrayList<>(); + if (opts.isIncludeCovering()) getCellUnionBound(cover); + for (S2CellId cid : cover) { + leOut.writeLong(cid.id()); + } + + // 3) **Critical**: write the number of polylines in little-endian + leOut.writeInt(polylines.size()); + + // 4) Delegate each polyline’s payload (which itself writes little-endian) + for (S2Polyline pl : polylines) { + pl.encode(leOut); + } + + leOut.flush(); + } + + public static PolylineGeography decodeTagged(DataInputStream in, EncodeTag tag) throws IOException { + // 1) Instantiate an empty geography + PolylineGeography geo = new PolylineGeography(); + + // EMPTY? + if ((tag.getFlags() & EncodeTag.FLAG_EMPTY) != 0) { + return geo; + } + + // 3) Skip past any covering cell-IDs written by encodeTagged + tag.skipCovering(in); + + // 4) 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 little-endian int) + int count = in.readInt(); + + // 6) Loop and decode each polyline + for (int i = 0; i < count; i++) { + // This will read the version byte, then dispatch to decodeLossless or decodeCompressed + 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 index b799caf9c5b..7cb7777225f 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java @@ -116,7 +116,16 @@ public S2Region region() { * 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 cellIds) {} + public void getCellUnionBound(List 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 ──────────────────────────────────────────── /** @@ -200,7 +209,8 @@ public static S2Geography decodeTagged(DataInputStream is) throws IOException { switch (kind) { case POINT: return PointGeography.decodeTagged(in, tag); - // … + case POLYLINE: + return PolylineGeography.decodeTagged(in, tag); default: throw new IllegalArgumentException("Unsupported kind " + kind); } 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 00000000000..c951c3ded9a --- /dev/null +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java @@ -0,0 +1,118 @@ +/* + * 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 org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +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(); + + // Prepare encoder output + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + // Build a single polyline and wrap in geography + List points = new ArrayList<>(); + points.add(ptStart); + points.add(ptEnd); + S2Polyline polyline = new S2Polyline(points); + PolylineGeography geog = new PolylineGeography(polyline); + + // Encode the geography with tagging + geog.encodeTagged(baos, new EncodeOptions()); + + // Decode from the bytes + byte[] encodedBytes = baos.toByteArray(); + DataInputStream dis = new DataInputStream(new ByteArrayInputStream(encodedBytes)); + S2Geography roundtrip = S2Geography.decodeTagged(dis); + + // Verify kind + assertEquals(S2Geography.GeographyKind.POLYLINE, roundtrip.kind); + System.out.println(roundtrip.toString()); + S2Polyline pl = (S2Polyline) roundtrip.shape(0); + StringBuilder sb = new StringBuilder("LINESTRING ("); + for (int i = 0; i < pl.numVertices(); i++) { + S2Point p = pl.vertex(i); + S2LatLng ll = new S2LatLng(p); + if (i > 0) sb.append(", "); + // WKT is “lon lat” + sb.append(String.format("%.0f %.0f", ll.lng().degrees(), ll.lat().degrees())); + } + sb.append(")"); + System.out.println(sb.toString()); + // Verify WKT representation (assuming toWkt() is implemented) + assertEquals("LINESTRING (-64 45, 0 0)", sb.toString()); + + // Downcast and inspect internal polylines + assertTrue(roundtrip instanceof PolylineGeography); + PolylineGeography rtTyped = (PolylineGeography) roundtrip; + assertEquals(1, rtTyped.getPolylines().size()); + S2Polyline decodedPolyline = rtTyped.getPolylines().get(0); + + // Compare geometry equality + assertTrue(decodedPolyline.equals(polyline)); + } + + @Test + public void testEncodedMultiPolyline() throws IOException { + + 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 + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + geog.encodeTagged(baos, new EncodeOptions()); + + // 4) Decode back + DataInputStream dis = new DataInputStream(new ByteArrayInputStream(baos.toByteArray())); + S2Geography decoded = S2Geography.decodeTagged(dis); + + // 5) Verify it’s a PolylineGeography with two members + assertTrue(decoded instanceof PolylineGeography); + PolylineGeography pg = (PolylineGeography) decoded; + assertEquals(2, pg.getPolylines().size()); + + // 6) Check each one matches the original + assertTrue(pg.getPolylines().get(0).equals(poly1)); + assertTrue(pg.getPolylines().get(1).equals(poly2)); + } +} From 42a703d6e87dc0d7f0a7ce185d723581bd360431 Mon Sep 17 00:00:00 2001 From: Zhuocheng Shang <122398181+ZhuochengShang@users.noreply.github.com> Date: Fri, 20 Jun 2025 15:53:19 -0700 Subject: [PATCH 03/14] Add POLYGON implements on S2Geography --- .../common/S2Geography/PolygonGeography.java | 132 ++++++++++++++++++ .../common/S2Geography/S2Geography.java | 2 + .../S2Geography/PolygonGeographyTest.java | 73 ++++++++++ 3 files changed, 207 insertions(+) create mode 100644 common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java create mode 100644 common/src/test/java/org/apache/sedona/common/S2Geography/PolygonGeographyTest.java 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 00000000000..2655eba09e7 --- /dev/null +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java @@ -0,0 +1,132 @@ +/* + * 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.collect.ImmutableList; +import com.google.common.geometry.*; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class PolygonGeography extends S2Geography { + private final List polygons; + + public PolygonGeography() { + super(GeographyKind.POLYGON); + this.polygons = new ArrayList<>(); + } + + public PolygonGeography(S2Polygon polygon) { + super(GeographyKind.POLYGON); + this.polygons = new ArrayList<>(); + this.polygons.add(polygon); + } + + public PolygonGeography(List polygons) { + super(GeographyKind.POLYGON); + this.polygons = new ArrayList<>(polygons); + } + + @Override + public int dimension() { + return 2; + } + + @Override + public int numShapes() { + return polygons.size(); + } + + @Override + public S2Shape shape(int id) { + return polygons.get(id).shape(); + } + + @Override + public S2Region region() { + Collection regionCollection = new ArrayList<>(polygons); + return new S2RegionUnion(regionCollection); + } + + @Override + public void getCellUnionBound(List cellIds) { + super.getCellUnionBound(cellIds); + } + + public List getPolygons() { + return ImmutableList.copyOf(polygons); + } + + @Override + public void encodeTagged(OutputStream os, EncodeOptions opts) throws IOException { + DataOutputStream leOut = new DataOutputStream(os); + // 1) Write tag header + EncodeTag tag = new EncodeTag(); + tag.setKind(GeographyKind.POLYGON); + tag.encode(leOut); + + // 2) Write covering cells if requested + if (opts.isIncludeCovering()) { + List cover = new ArrayList<>(); + getCellUnionBound(cover); + for (S2CellId cid : cover) { + leOut.writeLong(cid.id()); + } + } + + // 3) Write number of polygons + leOut.writeInt(polygons.size()); + + // 4) Encode each polygon + for (S2Polygon poly : polygons) { + poly.encode(leOut); + } + leOut.flush(); + } + + public static PolygonGeography decodeTagged(DataInputStream in, EncodeTag tag) throws IOException { + PolygonGeography geo = new PolygonGeography(); + + // EMPTY? + if ((tag.getFlags() & EncodeTag.FLAG_EMPTY) != 0) { + return geo; + } + + // Skip any covering cells + tag.skipCovering(in); + + // Read polygon count + if (in.available() < Integer.BYTES) { + throw new IOException("PolygonGeography.decodeTagged error: insufficient header bytes"); + } + int count = in.readInt(); + + // Decode each polygon + for (int i = 0; i < count; i++) { + S2Polygon poly = S2Polygon.decode(in); + geo.polygons.add(poly); + } + 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 index 7cb7777225f..97b682eb0a6 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java @@ -211,6 +211,8 @@ public static S2Geography decodeTagged(DataInputStream is) throws IOException { return PointGeography.decodeTagged(in, tag); case POLYLINE: return PolylineGeography.decodeTagged(in, tag); + case POLYGON: + return PolygonGeography.decodeTagged(in, tag); default: throw new IllegalArgumentException("Unsupported kind " + kind); } 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 00000000000..4eed47d124c --- /dev/null +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PolygonGeographyTest.java @@ -0,0 +1,73 @@ +package org.apache.sedona.common.S2Geography; + +import com.google.common.geometry.*; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +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(); + + // Prepare encoder output + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + // Build a single polygon and wrap in geography + List 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); + System.out.println(poly.toString()); + PolygonGeography pg = new PolygonGeography(poly); + + // Encode the geography with tagging + pg.encodeTagged(baos, new EncodeOptions()); + + // Decode from the bytes + byte[] encodedBytes = baos.toByteArray(); + DataInputStream dis = new DataInputStream(new ByteArrayInputStream(encodedBytes)); + S2Geography roundtrip = S2Geography.decodeTagged(dis); + + // Verify kind + assertEquals(S2Geography.GeographyKind.POLYGON, roundtrip.kind); + System.out.println(roundtrip.toString()); + // Extract polygon and build WKT string + // Extract decoded polygon + assertEquals(1, pg.getPolygons().size()); + + S2Polygon pl = pg.getPolygons().get(0); + // Reconstruct WKT from first loop + S2Loop loop = pl.loop(0); + StringBuilder sb = new StringBuilder("POLYGON (("); + for (int i = 0; i < loop.numVertices(); i++) { + if (i > 0) sb.append(", "); + S2LatLng ll = new S2LatLng(loop.vertex(i)); + sb.append(String.format("%.0f %.0f", ll.lng().degrees(), ll.lat().degrees())); + } + sb.append("))"); + // Build a simple triangle polygon: POLYGON ((-64 45, 0 45, 0 0, -64 45)) + String wkt = "POLYGON ((-64 45, 0 45, 0 0, -64 45))"; + assertEquals(wkt, sb.toString()); + assertTrue(roundtrip instanceof PolygonGeography); + PolygonGeography rtTyped = (PolygonGeography) roundtrip; + assertEquals(1, rtTyped.getPolygons().size()); + S2Polygon decodedPolygon = rtTyped.getPolygons().get(0); + + // Compare geometry equality + assertTrue(decodedPolygon.equals(poly)); + + } +} From 02993ed8738c55bf995b51865b7f781640005a7e Mon Sep 17 00:00:00 2001 From: Zhuocheng Shang <122398181+ZhuochengShang@users.noreply.github.com> Date: Fri, 20 Jun 2025 15:53:55 -0700 Subject: [PATCH 04/14] Match coding style --- .../common/S2Geography/PolygonGeography.java | 4 +- .../common/S2Geography/PolylineGeography.java | 50 +++--- .../common/S2Geography/S2Geography.java | 6 +- .../S2Geography/PolygonGeographyTest.java | 126 +++++++------ .../S2Geography/PolylineGeographyTest.java | 169 +++++++++--------- 5 files changed, 184 insertions(+), 171 deletions(-) diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java index 2655eba09e7..9a753df5790 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java @@ -20,7 +20,6 @@ import com.google.common.collect.ImmutableList; import com.google.common.geometry.*; - import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; @@ -105,7 +104,8 @@ public void encodeTagged(OutputStream os, EncodeOptions opts) throws IOException leOut.flush(); } - public static PolygonGeography decodeTagged(DataInputStream in, EncodeTag tag) throws IOException { + public static PolygonGeography decodeTagged(DataInputStream in, EncodeTag tag) + throws IOException { PolygonGeography geo = new PolygonGeography(); // EMPTY? 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 index 6855a27b357..990377724d6 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java @@ -20,8 +20,6 @@ import com.google.common.collect.ImmutableList; import com.google.common.geometry.*; -import com.google.common.io.LittleEndianDataOutputStream; - import java.io.*; import java.util.ArrayList; import java.util.Collection; @@ -112,34 +110,34 @@ public void encodeTagged(OutputStream os, EncodeOptions opts) throws IOException leOut.flush(); } - public static PolylineGeography decodeTagged(DataInputStream in, EncodeTag tag) throws IOException { - // 1) Instantiate an empty geography - PolylineGeography geo = new PolylineGeography(); - - // EMPTY? - if ((tag.getFlags() & EncodeTag.FLAG_EMPTY) != 0) { - return geo; - } + public static PolylineGeography decodeTagged(DataInputStream in, EncodeTag tag) + throws IOException { + // 1) Instantiate an empty geography + PolylineGeography geo = new PolylineGeography(); - // 3) Skip past any covering cell-IDs written by encodeTagged - tag.skipCovering(in); + // EMPTY? + if ((tag.getFlags() & EncodeTag.FLAG_EMPTY) != 0) { + return geo; + } - // 4) Ensure we have at least 4 bytes for the count - if (in.available() < Integer.BYTES) { - throw new IOException( - "PolylineGeography.decodeTagged error: insufficient header bytes"); - } + // 3) Skip past any covering cell-IDs written by encodeTagged + tag.skipCovering(in); - //5) Read the number of polylines (4-byte little-endian int) - int count = in.readInt(); + // 4) Ensure we have at least 4 bytes for the count + if (in.available() < Integer.BYTES) { + throw new IOException("PolylineGeography.decodeTagged error: insufficient header bytes"); + } - // 6) Loop and decode each polyline - for (int i = 0; i < count; i++) { - // This will read the version byte, then dispatch to decodeLossless or decodeCompressed - S2Polyline pl = S2Polyline.decode(in); - geo.polylines.add(pl); - } + // 5) Read the number of polylines (4-byte little-endian int) + int count = in.readInt(); - return geo; + // 6) Loop and decode each polyline + for (int i = 0; i < count; i++) { + // This will read the version byte, then dispatch to decodeLossless or decodeCompressed + 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 index 97b682eb0a6..b27bcf7a7d6 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java @@ -209,10 +209,10 @@ public static S2Geography decodeTagged(DataInputStream is) throws IOException { switch (kind) { case POINT: return PointGeography.decodeTagged(in, tag); - case POLYLINE: - return PolylineGeography.decodeTagged(in, tag); + case POLYLINE: + return PolylineGeography.decodeTagged(in, tag); case POLYGON: - return PolygonGeography.decodeTagged(in, tag); + return PolygonGeography.decodeTagged(in, tag); default: throw new IllegalArgumentException("Unsupported kind " + kind); } 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 index 4eed47d124c..e8baa30d563 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/PolygonGeographyTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PolygonGeographyTest.java @@ -1,73 +1,89 @@ +/* + * 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 org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import com.google.common.geometry.*; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.IOException; import java.util.ArrayList; import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; +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(); + @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(); - // Prepare encoder output - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - // Build a single polygon and wrap in geography - List 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); - System.out.println(poly.toString()); - PolygonGeography pg = new PolygonGeography(poly); + // Prepare encoder output + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + // Build a single polygon and wrap in geography + List 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); + System.out.println(poly.toString()); + PolygonGeography pg = new PolygonGeography(poly); - // Encode the geography with tagging - pg.encodeTagged(baos, new EncodeOptions()); + // Encode the geography with tagging + pg.encodeTagged(baos, new EncodeOptions()); - // Decode from the bytes - byte[] encodedBytes = baos.toByteArray(); - DataInputStream dis = new DataInputStream(new ByteArrayInputStream(encodedBytes)); - S2Geography roundtrip = S2Geography.decodeTagged(dis); + // Decode from the bytes + byte[] encodedBytes = baos.toByteArray(); + DataInputStream dis = new DataInputStream(new ByteArrayInputStream(encodedBytes)); + S2Geography roundtrip = S2Geography.decodeTagged(dis); - // Verify kind - assertEquals(S2Geography.GeographyKind.POLYGON, roundtrip.kind); - System.out.println(roundtrip.toString()); - // Extract polygon and build WKT string - // Extract decoded polygon - assertEquals(1, pg.getPolygons().size()); - - S2Polygon pl = pg.getPolygons().get(0); - // Reconstruct WKT from first loop - S2Loop loop = pl.loop(0); - StringBuilder sb = new StringBuilder("POLYGON (("); - for (int i = 0; i < loop.numVertices(); i++) { - if (i > 0) sb.append(", "); - S2LatLng ll = new S2LatLng(loop.vertex(i)); - sb.append(String.format("%.0f %.0f", ll.lng().degrees(), ll.lat().degrees())); - } - sb.append("))"); - // Build a simple triangle polygon: POLYGON ((-64 45, 0 45, 0 0, -64 45)) - String wkt = "POLYGON ((-64 45, 0 45, 0 0, -64 45))"; - assertEquals(wkt, sb.toString()); - assertTrue(roundtrip instanceof PolygonGeography); - PolygonGeography rtTyped = (PolygonGeography) roundtrip; - assertEquals(1, rtTyped.getPolygons().size()); - S2Polygon decodedPolygon = rtTyped.getPolygons().get(0); - - // Compare geometry equality - assertTrue(decodedPolygon.equals(poly)); + // Verify kind + assertEquals(S2Geography.GeographyKind.POLYGON, roundtrip.kind); + System.out.println(roundtrip.toString()); + // Extract polygon and build WKT string + // Extract decoded polygon + assertEquals(1, pg.getPolygons().size()); + S2Polygon pl = pg.getPolygons().get(0); + // Reconstruct WKT from first loop + S2Loop loop = pl.loop(0); + StringBuilder sb = new StringBuilder("POLYGON (("); + for (int i = 0; i < loop.numVertices(); i++) { + if (i > 0) sb.append(", "); + S2LatLng ll = new S2LatLng(loop.vertex(i)); + sb.append(String.format("%.0f %.0f", ll.lng().degrees(), ll.lat().degrees())); } + sb.append("))"); + // Build a simple triangle polygon: POLYGON ((-64 45, 0 45, 0 0, -64 45)) + String wkt = "POLYGON ((-64 45, 0 45, 0 0, -64 45))"; + assertEquals(wkt, sb.toString()); + assertTrue(roundtrip instanceof PolygonGeography); + PolygonGeography rtTyped = (PolygonGeography) roundtrip; + assertEquals(1, rtTyped.getPolygons().size()); + S2Polygon decodedPolygon = rtTyped.getPolygons().get(0); + + // Compare geometry equality + assertTrue(decodedPolygon.equals(poly)); + } } 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 index c951c3ded9a..daeac9673af 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java @@ -18,101 +18,100 @@ */ package org.apache.sedona.common.S2Geography; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + import com.google.common.geometry.S2LatLng; import com.google.common.geometry.S2Point; import com.google.common.geometry.S2Polyline; -import org.junit.Test; - import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.IOException; import java.util.ArrayList; import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; +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(); - - // Prepare encoder output - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - // Build a single polyline and wrap in geography - List points = new ArrayList<>(); - points.add(ptStart); - points.add(ptEnd); - S2Polyline polyline = new S2Polyline(points); - PolylineGeography geog = new PolylineGeography(polyline); - - // Encode the geography with tagging - geog.encodeTagged(baos, new EncodeOptions()); - - // Decode from the bytes - byte[] encodedBytes = baos.toByteArray(); - DataInputStream dis = new DataInputStream(new ByteArrayInputStream(encodedBytes)); - S2Geography roundtrip = S2Geography.decodeTagged(dis); - - // Verify kind - assertEquals(S2Geography.GeographyKind.POLYLINE, roundtrip.kind); - System.out.println(roundtrip.toString()); - S2Polyline pl = (S2Polyline) roundtrip.shape(0); - StringBuilder sb = new StringBuilder("LINESTRING ("); - for (int i = 0; i < pl.numVertices(); i++) { - S2Point p = pl.vertex(i); - S2LatLng ll = new S2LatLng(p); - if (i > 0) sb.append(", "); - // WKT is “lon lat” - sb.append(String.format("%.0f %.0f", ll.lng().degrees(), ll.lat().degrees())); - } - sb.append(")"); - System.out.println(sb.toString()); - // Verify WKT representation (assuming toWkt() is implemented) - assertEquals("LINESTRING (-64 45, 0 0)", sb.toString()); - - // Downcast and inspect internal polylines - assertTrue(roundtrip instanceof PolylineGeography); - PolylineGeography rtTyped = (PolylineGeography) roundtrip; - assertEquals(1, rtTyped.getPolylines().size()); - S2Polyline decodedPolyline = rtTyped.getPolylines().get(0); - - // Compare geometry equality - assertTrue(decodedPolyline.equals(polyline)); - } - - @Test - public void testEncodedMultiPolyline() throws IOException { - - 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 - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - geog.encodeTagged(baos, new EncodeOptions()); - - // 4) Decode back - DataInputStream dis = new DataInputStream(new ByteArrayInputStream(baos.toByteArray())); - S2Geography decoded = S2Geography.decodeTagged(dis); - - // 5) Verify it’s a PolylineGeography with two members - assertTrue(decoded instanceof PolylineGeography); - PolylineGeography pg = (PolylineGeography) decoded; - assertEquals(2, pg.getPolylines().size()); - - // 6) Check each one matches the original - assertTrue(pg.getPolylines().get(0).equals(poly1)); - assertTrue(pg.getPolylines().get(1).equals(poly2)); + @Test + public void testEncodedPolyline() throws IOException { + // Create two points + S2Point ptStart = S2LatLng.fromDegrees(45, -64).toPoint(); + S2Point ptEnd = S2LatLng.fromDegrees(0, 0).toPoint(); + + // Prepare encoder output + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + // Build a single polyline and wrap in geography + List points = new ArrayList<>(); + points.add(ptStart); + points.add(ptEnd); + S2Polyline polyline = new S2Polyline(points); + PolylineGeography geog = new PolylineGeography(polyline); + + // Encode the geography with tagging + geog.encodeTagged(baos, new EncodeOptions()); + + // Decode from the bytes + byte[] encodedBytes = baos.toByteArray(); + DataInputStream dis = new DataInputStream(new ByteArrayInputStream(encodedBytes)); + S2Geography roundtrip = S2Geography.decodeTagged(dis); + + // Verify kind + assertEquals(S2Geography.GeographyKind.POLYLINE, roundtrip.kind); + System.out.println(roundtrip.toString()); + S2Polyline pl = (S2Polyline) roundtrip.shape(0); + StringBuilder sb = new StringBuilder("LINESTRING ("); + for (int i = 0; i < pl.numVertices(); i++) { + S2Point p = pl.vertex(i); + S2LatLng ll = new S2LatLng(p); + if (i > 0) sb.append(", "); + // WKT is “lon lat” + sb.append(String.format("%.0f %.0f", ll.lng().degrees(), ll.lat().degrees())); } + sb.append(")"); + System.out.println(sb.toString()); + // Verify WKT representation (assuming toWkt() is implemented) + assertEquals("LINESTRING (-64 45, 0 0)", sb.toString()); + + // Downcast and inspect internal polylines + assertTrue(roundtrip instanceof PolylineGeography); + PolylineGeography rtTyped = (PolylineGeography) roundtrip; + assertEquals(1, rtTyped.getPolylines().size()); + S2Polyline decodedPolyline = rtTyped.getPolylines().get(0); + + // Compare geometry equality + assertTrue(decodedPolyline.equals(polyline)); + } + + @Test + public void testEncodedMultiPolyline() throws IOException { + + 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 + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + geog.encodeTagged(baos, new EncodeOptions()); + + // 4) Decode back + DataInputStream dis = new DataInputStream(new ByteArrayInputStream(baos.toByteArray())); + S2Geography decoded = S2Geography.decodeTagged(dis); + + // 5) Verify it’s a PolylineGeography with two members + assertTrue(decoded instanceof PolylineGeography); + PolylineGeography pg = (PolylineGeography) decoded; + assertEquals(2, pg.getPolylines().size()); + + // 6) Check each one matches the original + assertTrue(pg.getPolylines().get(0).equals(poly1)); + assertTrue(pg.getPolylines().get(1).equals(poly2)); + } } From 32df1c3b00c79581c3a6acdf777e64f31ab2f2d9 Mon Sep 17 00:00:00 2001 From: Zhuocheng Shang <122398181+ZhuochengShang@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:15:40 -0700 Subject: [PATCH 05/14] "Apply Spotless formatting to PolylineGeographyTest" --- .../apache/sedona/common/S2Geography/PolylineGeographyTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index daeac9673af..bdb0f494148 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java @@ -85,7 +85,7 @@ public void testEncodedPolyline() throws IOException { @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(); From 479f6115b4ed0cb70b135965a6d70233f05bb1bc Mon Sep 17 00:00:00 2001 From: Zhuocheng Shang <122398181+ZhuochengShang@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:08:58 -0700 Subject: [PATCH 06/14] Redesign of S2Geography - Import org.datasyslab s2-geometry-library - Clean up S2Geography abstract design - Update Encode/Decode inside each kind of geography --- common/pom.xml | 20 ++- .../S2Geography/CountingPointVectorCoder.java | 110 ---------------- .../sedona/common/S2Geography/EncodeTag.java | 43 +++++- .../common/S2Geography/PointGeography.java | 123 ++++++++++++------ .../sedona/common/S2Geography/PointShape.java | 117 ----------------- .../common/S2Geography/PointShapeCoders.java | 48 ------- .../common/S2Geography/PolygonGeography.java | 45 ++++--- .../common/S2Geography/PolylineGeography.java | 50 ++++--- .../common/S2Geography/S2Geography.java | 113 +++------------- .../S2Geography/PointGeographyTest.java | 65 +++++---- .../S2Geography/PolygonGeographyTest.java | 10 +- .../S2Geography/PolylineGeographyTest.java | 8 +- 12 files changed, 265 insertions(+), 487 deletions(-) delete mode 100644 common/src/main/java/org/apache/sedona/common/S2Geography/CountingPointVectorCoder.java delete mode 100644 common/src/main/java/org/apache/sedona/common/S2Geography/PointShape.java delete mode 100644 common/src/main/java/org/apache/sedona/common/S2Geography/PointShapeCoders.java diff --git a/common/pom.xml b/common/pom.xml index 8728a08bf72..da90411b44e 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -81,9 +81,14 @@ org.locationtech.spatial4j spatial4j + + + + - com.google.geometry - s2-geometry + org.datasyslab + s2-geometry-library + 20250620-rc1 com.uber @@ -109,6 +114,12 @@ edu.ucar cdm-core + + org.apache.commons + commons-collections4 + 4.4 + compile + src/main/java @@ -159,6 +170,7 @@ it.geosolutions.jaiext.jiffle:* org.antlr:* org.codehaus.janino:* + org.datasyslab:s2-geometry-library @@ -177,6 +189,10 @@ org.codehaus org.apache.sedona.shaded.codehaus + + com.google.common.geometry; + org.apache.sedona.shaded.s2 + diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/CountingPointVectorCoder.java b/common/src/main/java/org/apache/sedona/common/S2Geography/CountingPointVectorCoder.java deleted file mode 100644 index 91cbed420c2..00000000000 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/CountingPointVectorCoder.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - * 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.PrimitiveArrays; -import com.google.common.geometry.S2Coder; -import com.google.common.geometry.S2Point; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.util.ArrayList; -import java.util.List; - -public abstract class CountingPointVectorCoder implements S2Coder { - public static final CountingPointVectorCoder INSTANCE = - new CountingPointVectorCoder() { - @Override - public S2Point.Shape decode(PrimitiveArrays.Bytes data, PrimitiveArrays.Cursor cursor) { - return null; - } - }; - - private CountingPointVectorCoder() {} - - @Override - public void encode(S2Point.Shape shape, OutputStream out) throws IOException { - // varint count - writeVarInt(out, shape.numEdges()); - // raw doubles - for (int i = 0; i < shape.numEdges(); i++) { - S2Point p = shape.get(i); - writeDouble(out, p.getX()); - writeDouble(out, p.getY()); - writeDouble(out, p.getZ()); - } - } - - // unused by us: - public S2Point.Shape decode(byte[] data, int offset, int length) { - throw new UnsupportedOperationException(); - } - - public List decode(InputStream in) throws IOException { - long n = readVarInt(in); - List pts = new ArrayList<>((int) n); - for (int i = 0; i < n; i++) { - double x = readDouble(in); - double y = readDouble(in); - double z = readDouble(in); - pts.add(new S2Point(x, y, z)); - } - return pts; - } - - private static void writeVarInt(OutputStream out, long v) throws IOException { - while (true) { - int bits = (int) (v & 0x7F); // grab low 7 bits - v >>>= 7; - if (v != 0) out.write(bits | 0x80); // yes: set continuation bit - else { - out.write(bits); - break; - } - } - } - // [varint(count=N)] [point_0.x][point_0.y][point_0.z] … [point_{N-1}.x][…] - static long readVarInt(InputStream in) throws IOException { - long res = 0; - int shift = 0; - while (true) { - int b = in.read(); - if (b < 0) throw new IOException("EOF varint"); - res |= (long) (b & 0x7F) << shift; - if ((b & 0x80) == 0) break; - shift += 7; - } - return res; - } - - private static void writeDouble(OutputStream out, double x) throws IOException { - long bits = Double.doubleToLongBits(x); - for (int i = 0; i < 8; i++) out.write((int) (bits >> (8 * i)) & 0xFF); - } - - private static double readDouble(InputStream in) throws IOException { - long bits = 0; - for (int i = 0; i < 8; i++) { - int b = in.read(); - if (b < 0) throw new IOException("EOF double"); - bits |= (long) (b & 0xFF) << (8 * i); - } - return Double.longBitsToDouble(bits); - } -} 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 index 9fecd93154c..893bf97d860 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/EncodeTag.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/EncodeTag.java @@ -25,17 +25,41 @@ import java.util.List; import org.apache.sedona.common.S2Geography.S2Geography.GeographyKind; +/** + * A 4 byte prefix for encoded geographies. 4 bytes is essential so that German-style strings store + * these bytes in their prefix (i.e., don't have to load any auxiliary buffers to inspect this + * information). + */ 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. Currently, only {@code kFlagEmpty} is supported, which is set if + * and only if the geography contains zero shapes. + */ private byte flags = 0; + /** + * 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; /** If set, geography has zero shapes. */ public static final byte FLAG_EMPTY = 1; + private byte encodeType = 1; // fast: 1 ; compact: 2 + public EncodeTag() {} + public EncodeTag(EncodeOptions opts) { + this.encodeType = (byte) (opts.getCodingHint() == EncodeOptions.CodingHint.FAST ? 1 : 2); + } + // ——— Write the 4-byte tag header —————————————————————————————————————— /** Write exactly 4 bytes: [kind|flags|coveringSize|reserved]. */ @@ -43,7 +67,8 @@ public void encode(DataOutputStream out) throws IOException { out.writeByte(kind.getKind()); out.writeByte(flags); out.writeByte(coveringSize); - out.writeByte(reserved); // <-- this makes it 4 bytes + out.writeByte(reserved); + out.writeByte(encodeType); } // ——— Read it back ———————————————————————————————————————————————— @@ -53,8 +78,10 @@ public static EncodeTag decode(DataInputStream in) throws IOException { tag.kind = GeographyKind.fromKind(in.readUnsignedByte()); tag.flags = (byte) in.readUnsignedByte(); tag.coveringSize = (byte) in.readUnsignedByte(); - int r = in.readUnsignedByte(); - if (r != 0) throw new IOException("Reserved header byte must be 0, was " + r); + tag.reserved = (byte) in.readUnsignedByte(); + if (tag.reserved != 0) + throw new IOException("Reserved header byte must be 0, was " + tag.reserved); + tag.encodeType = in.readByte(); return tag; } @@ -87,7 +114,7 @@ public void validate() { // ——— Getters / setters —————————————————————————————————————————— public GeographyKind getKind() { - return kind; + return this.kind; } public void setKind(GeographyKind kind) { @@ -102,6 +129,14 @@ public void setFlags(byte flags) { this.flags = flags; } + public void setEncodeType(byte type) { + this.encodeType = type; + } + + public byte getEncodeType() { + return encodeType; + } + public byte getCoveringSize() { return coveringSize; } 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 index 9638b93a5a3..283350d1110 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PointGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PointGeography.java @@ -19,14 +19,16 @@ package org.apache.sedona.common.S2Geography; import com.google.common.geometry.*; +import com.google.common.geometry.PrimitiveArrays.Bytes; +import com.google.common.geometry.PrimitiveArrays.Cursor; import java.io.*; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; +import java.util.*; import java.util.List; +import java.util.logging.Logger; public class PointGeography extends S2Geography { - // Underlying list of points + private static final Logger logger = Logger.getLogger(PointGeography.class.getName()); + private final List points = new ArrayList<>(); /** Constructs an empty PointGeography. */ @@ -34,6 +36,12 @@ 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(); @@ -48,25 +56,17 @@ public PointGeography(List pts) { @Override public int dimension() { - // Points are 0-dimensional (or -1 if empty) return points.isEmpty() ? -1 : 0; } @Override public int numShapes() { - // Represent all points as a single composite shape return points.isEmpty() ? 0 : 1; } @Override public S2Shape shape(int id) { - if (numShapes() == 0) { - throw new IllegalStateException("No shapes in empty PointGeography"); - } - if (id != 0) { - throw new IllegalArgumentException("Shape id out of bounds: " + id); - } - return new PointShape(points); + return S2Point.Shape.fromList(points); } @Override @@ -81,8 +81,7 @@ public S2Region region() { for (S2Point p : points) { pointRegionCollection.add(new S2PointRegion(p)); } - S2RegionUnion union = new S2RegionUnion(pointRegionCollection); - return union; + return new S2RegionUnion(pointRegionCollection); } } @@ -109,25 +108,12 @@ public List getPoints() { // ------------------------------------------------------- @Override - public void encodeTagged(OutputStream os, EncodeOptions opts) throws IOException { + public void encode(OutputStream os, EncodeOptions opts) throws IOException { DataOutputStream out = new DataOutputStream(os); - // CELL_CENTER path - if (points.size() == 1 && opts.getCodingHint() == EncodeOptions.CodingHint.COMPACT) { - S2CellId cid = S2CellId.fromPoint(points.get(0)); - if (cid.level() >= 23) { - out.writeByte(S2Geography.GeographyKind.CELL_CENTER.getKind()); - out.writeByte(0); - out.writeByte(1); - out.writeByte(0); - out.writeLong(cid.id()); - return; - } - } - - // EMPTY path + // EMPTY if (points.isEmpty()) { - EncodeTag tag = new EncodeTag(); + EncodeTag tag = new EncodeTag(opts); tag.setKind(GeographyKind.POINT); tag.setFlags((byte) (tag.getFlags() | EncodeTag.FLAG_EMPTY)); tag.setCoveringSize((byte) 0); @@ -135,34 +121,85 @@ public void encodeTagged(OutputStream os, EncodeOptions opts) throws IOException return; } - // header POINT + if (points.size() == 1 && opts.getCodingHint() == EncodeOptions.CodingHint.COMPACT) { + S2CellId cid = S2CellId.fromPoint(points.get(0)); + if (cid.level() >= 23) { + out.writeByte(GeographyKind.CELL_CENTER.getKind()); + out.writeByte(0); // POINT kind + out.writeByte(1); // flag + out.writeByte(0); // coveringSize + out.writeByte(2); // COMPACT encode type + out.writeLong(cid.id()); + return; + } + } + + // Compute covering if requested List cover = new ArrayList<>(); - EncodeTag tag = new EncodeTag(); + if (opts.isIncludeCovering()) { + getCellUnionBound(cover); + if (cover.size() > 255) { + cover.clear(); + logger.warning("Covering size too large (> 255) — clear Covering"); + } + } + // Write tag and covering + EncodeTag tag = new EncodeTag(opts); tag.setKind(GeographyKind.POINT); - if (opts.isIncludeCovering()) getCellUnionBound(cover); tag.setCoveringSize((byte) cover.size()); tag.encode(out); - for (var c2 : cover) out.writeLong(c2.id()); + for (S2CellId c2 : cover) { + out.writeLong(c2.id()); + } - // payload + // Encode point payload using selected hint S2Point.Shape shp = S2Point.Shape.fromList(points); if (opts.getCodingHint() == EncodeOptions.CodingHint.FAST) { - CountingPointVectorCoder.INSTANCE.encode(shp, out); + S2Point.Shape.FAST_CODER.encode(shp, out); } else { - PointShapeCoders.COMPACT.encode(shp, out); + S2Point.Shape.COMPACT_CODER.encode(shp, out); } } - public static PointGeography decodeTagged(DataInputStream in, EncodeTag tag) throws IOException { + public static PointGeography decode(DataInputStream in) throws IOException { + EncodeTag tag = new EncodeTag(); + tag = EncodeTag.decode(in); + int coverSize = tag.getCoveringSize() & 0xFF; + PointGeography geo = new PointGeography(); - // EMPTY? + // EMPTY if ((tag.getFlags() & EncodeTag.FLAG_EMPTY) != 0) { + logger.fine("Decoded empty PointGeography."); + return geo; + } + + // Optimized 1-point COMPACT situation + if (tag.getKind() == GeographyKind.CELL_CENTER && tag.getEncodeType() == 2) { + long id = in.readLong(); + geo = new PointGeography(new S2CellId(id).toPoint()); + logger.fine("Decoded compact single-point geography via cell center."); return geo; } - // RAW-vector (FAST) decode: varint count + nPoints × (double x,y,z) - // FULL-FAST: varint count + n×(x,y,z) - geo.points.addAll(CountingPointVectorCoder.INSTANCE.decode(in)); + // skip cover + for (int i = 0; i < coverSize; i++) { + in.readLong(); + } + + // Read remaining bytes into buffer + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int len; + while ((len = in.read(buffer)) != -1) { + baos.write(buffer, 0, len); + } + + PrimitiveArrays.Bytes bytes = Bytes.fromByteArray(baos.toByteArray()); + Cursor cursor = bytes.cursor(); + List points = new ArrayList<>(); + if (tag.getEncodeType() == 1) points = S2PointVectorCoder.FAST.decode(bytes, cursor); + else if (tag.getEncodeType() == 2) points = S2PointVectorCoder.COMPACT.decode(bytes, cursor); + geo.points.addAll(points); return geo; } } diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/PointShape.java b/common/src/main/java/org/apache/sedona/common/S2Geography/PointShape.java deleted file mode 100644 index 359ccc75d27..00000000000 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PointShape.java +++ /dev/null @@ -1,117 +0,0 @@ -/* - * 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 com.google.common.geometry.S2Edge; -import com.google.common.geometry.S2Point; -import com.google.common.geometry.S2Shape; -import java.util.List; - -/** - * Java equivalent of the C++ S2PointVectorShape: each point is a degenerate edge (start==end), one - * chain per point. - */ -/** A degenerate S2Shape that represents exactly one point. */ -public final class PointShape implements S2Shape { - private final List point; - - public PointShape(List point) { - this.point = point; - } - - public int num_points() { - return point.size(); - } - - @Override - public int numEdges() { - return num_points(); - } - - @Override - public void getEdge(int index, MutableEdge result) { - if (index != 0) { - throw new IndexOutOfBoundsException("PointShape has exactly one edge"); - } - result.set(point.get(index), point.get(index)); - } - - public S2Edge edge(int e) { - return new S2Edge(point.get(e), point.get(e)); - } - - @Override - public int dimension() { - return 0; - } - - @Override - public boolean hasInterior() { - return false; - } - - @Override - public boolean containsOrigin() { - return false; - } - - @Override - public ReferencePoint getReferencePoint() { - // hasInterior=false, contained()=false - return ReferencePoint.create(point.get(0), false); - } - - @Override - public int numChains() { - return 1; - } - - @Override - public int getChainStart(int chainId) { - if (chainId != 0) { - throw new IndexOutOfBoundsException("PointShape has exactly one chain"); - } - return 0; - } - - @Override - public int getChainLength(int chainId) { - if (chainId != 0) { - throw new IndexOutOfBoundsException("PointShape has exactly one chain"); - } - return 1; - } - - @Override - public void getChainEdge(int chainId, int offset, MutableEdge result) { - if (chainId != 0 || offset != 0) { - throw new IndexOutOfBoundsException("PointShape chainId and offset must both be 0"); - } - result.set(point.get(chainId), point.get(chainId)); - } - - @Override - public S2Point getChainVertex(int chainId, int offset) { - if (chainId != 0 || offset != 0) { - throw new IndexOutOfBoundsException("PointShape chainId and offset must both be 0"); - } - return point.get(chainId); - } -} diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/PointShapeCoders.java b/common/src/main/java/org/apache/sedona/common/S2Geography/PointShapeCoders.java deleted file mode 100644 index f0ced6152e0..00000000000 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PointShapeCoders.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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.S2Coder; -import com.google.common.geometry.S2Point; -import java.lang.reflect.Field; - -public class PointShapeCoders { - public static final S2Coder FAST; - public static final S2Coder COMPACT; - - static { - try { - // Locate the nested Coder class - Class coderClass = Class.forName("com.google.common.geometry.S2Point$Shape$Coder"); - - // Grab the private static fields - Field fastField = coderClass.getDeclaredField("FAST"); - fastField.setAccessible(true); - FAST = (S2Coder) fastField.get(null); - - Field compactField = coderClass.getDeclaredField("COMPACT"); - compactField.setAccessible(true); - COMPACT = (S2Coder) compactField.get(null); - } catch (Exception e) { - throw new RuntimeException("Unable to access S2Point.Shape.Coder fields", e); - } - } - - private PointShapeCoders() {} -} diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java index 9a753df5790..f69c0076cc9 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java @@ -27,8 +27,11 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.logging.Logger; public class PolygonGeography extends S2Geography { + private static final Logger logger = Logger.getLogger(PolygonGeography.class.getName()); + private final List polygons; public PolygonGeography() { @@ -49,7 +52,7 @@ public PolygonGeography(List polygons) { @Override public int dimension() { - return 2; + return polygons.isEmpty() ? -1 : 2; } @Override @@ -78,38 +81,46 @@ public List getPolygons() { } @Override - public void encodeTagged(OutputStream os, EncodeOptions opts) throws IOException { - DataOutputStream leOut = new DataOutputStream(os); - // 1) Write tag header - EncodeTag tag = new EncodeTag(); - tag.setKind(GeographyKind.POLYGON); - tag.encode(leOut); + public void encode(OutputStream os, EncodeOptions opts) throws IOException { + DataOutputStream out = new DataOutputStream(os); - // 2) Write covering cells if requested + // 1) Serialize any covering cells (if you have them) + List cover = new ArrayList<>(); if (opts.isIncludeCovering()) { - List cover = new ArrayList<>(); getCellUnionBound(cover); - for (S2CellId cid : cover) { - leOut.writeLong(cid.id()); + if (cover.size() > 255) { + cover.clear(); + logger.warning("Covering size too large (> 255) — clear Covering"); } } + // 2) Write tag header (unchanged) using leOut.writeByte(...) + EncodeTag tag = new EncodeTag(opts); + tag.setKind(GeographyKind.POLYGON); + tag.setCoveringSize((byte) cover.size()); + tag.encode(out); + for (S2CellId c2 : cover) { + out.writeLong(c2.id()); + } + // 3) Write number of polygons - leOut.writeInt(polygons.size()); + out.writeInt(polygons.size()); // 4) Encode each polygon for (S2Polygon poly : polygons) { - poly.encode(leOut); + poly.encode(out); } - leOut.flush(); + out.flush(); } - public static PolygonGeography decodeTagged(DataInputStream in, EncodeTag tag) - throws IOException { + public static PolygonGeography decode(DataInputStream in) throws IOException { PolygonGeography geo = new PolygonGeography(); - // EMPTY? + EncodeTag tag = new EncodeTag(); + tag = EncodeTag.decode(in); + // EMPTY if ((tag.getFlags() & EncodeTag.FLAG_EMPTY) != 0) { + logger.fine("Decoded empty PolygonGeography."); 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 index 990377724d6..d1b2815ff44 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java @@ -24,9 +24,12 @@ 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()); + private final List polylines; private static int sizeofInt() { @@ -51,7 +54,7 @@ public PolylineGeography(List polylines) { @Override public int dimension() { - return 1; + return polylines.isEmpty() ? -1 : 1; } @Override @@ -83,40 +86,49 @@ public List getPolylines() { } @Override - public void encodeTagged(OutputStream os, EncodeOptions opts) throws IOException { + public void encode(OutputStream os, EncodeOptions opts) throws IOException { // Wrap your stream in a little-endian DataOutput - DataOutputStream leOut = new DataOutputStream(os); - // 1) Write tag header (unchanged) using leOut.writeByte(...) - EncodeTag tag = new EncodeTag(); - tag.setKind(GeographyKind.POLYLINE); - // … include flags / covering … - tag.encode(leOut); + DataOutputStream out = new DataOutputStream(os); - // 2) Serialize any covering cells (if you have them) + // 1) Serialize any covering cells (if you have them) List cover = new ArrayList<>(); - if (opts.isIncludeCovering()) getCellUnionBound(cover); - for (S2CellId cid : cover) { - leOut.writeLong(cid.id()); + if (opts.isIncludeCovering()) { + getCellUnionBound(cover); + if (cover.size() > 255) { + cover.clear(); + logger.warning("Covering size too large (> 255) — clear Covering"); + } + } + + // 2) Write tag header (unchanged) using leOut.writeByte(...) + EncodeTag tag = new EncodeTag(opts); + tag.setKind(GeographyKind.POLYLINE); + tag.setCoveringSize((byte) cover.size()); + tag.encode(out); + for (S2CellId c2 : cover) { + out.writeLong(c2.id()); } // 3) **Critical**: write the number of polylines in little-endian - leOut.writeInt(polylines.size()); + out.writeInt(polylines.size()); + // Encode point payload using selected hint // 4) Delegate each polyline’s payload (which itself writes little-endian) for (S2Polyline pl : polylines) { - pl.encode(leOut); + pl.encode(out); } - - leOut.flush(); + out.flush(); } - public static PolylineGeography decodeTagged(DataInputStream in, EncodeTag tag) - throws IOException { + public static PolylineGeography decode(DataInputStream in) throws IOException { // 1) Instantiate an empty geography PolylineGeography geo = new PolylineGeography(); - // EMPTY? + EncodeTag tag = new EncodeTag(); + tag = EncodeTag.decode(in); + // EMPTY if ((tag.getFlags() & EncodeTag.FLAG_EMPTY) != 0) { + logger.fine("Decoded empty PointGeography."); 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 index b27bcf7a7d6..680ff6788b9 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java @@ -18,18 +18,15 @@ */ package org.apache.sedona.common.S2Geography; -import static org.apache.sedona.common.S2Geography.S2Geography.GeographyKind.POINT; - import com.google.common.geometry.*; import java.io.*; -import java.util.ArrayList; import java.util.List; /** * An abstract class represent S2Geography. Has 6 subtypes of geography: POINT, POLYLINE, POLYGON, * GEOGRAPHY_COLLECTION, SHAPE_INDEX, ENCODED_SHAPE_INDEX. */ -public class S2Geography { +public abstract class S2Geography { protected final GeographyKind kind; protected S2Geography(GeographyKind kind) { @@ -72,7 +69,14 @@ public static GeographyKind fromKind(int 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 int dimension() { + 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) { @@ -84,9 +88,7 @@ public int dimension() { /** * @return The number of S2Shape objects needed to represent this Geography */ - public int numShapes() { - return 0; - } + public abstract int numShapes(); /** * Returns the given S2Shape (where 0 <= id < num_shapes()). The caller retains ownership of the @@ -96,9 +98,7 @@ public int numShapes() { * @param id (where 0 <= id < num_shapes()) * @return the given S2Shape */ - public S2Shape shape(int id) { - return null; - } + public abstract S2Shape shape(int id); /** * Returns an S2Region that represents the object. The caller retains ownership of the S2Region @@ -107,9 +107,7 @@ public S2Shape shape(int id) { * * @return S2Region */ - public S2Region region() { - return null; - } + public abstract S2Region region(); /** * Adds an unnormalized set of S2CellIDs to `cell_ids`. This is intended to be faster than using @@ -130,91 +128,14 @@ public void getCellUnionBound(List 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 4-byte + * (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 options CodingHint.FAST / CodingHint.COMPACT - */ - public void encodeTagged(OutputStream outStream, EncodeOptions options) throws IOException { - DataOutputStream out = new DataOutputStream(outStream); - - // 1) build + write tag header - EncodeTag tag = new EncodeTag(); - tag.setKind(kind); - if (numShapes() == 0) { - tag.setFlags((byte) (tag.getFlags() | EncodeTag.FLAG_EMPTY)); - } - // compute covering if requested - List cover = new ArrayList<>(); - if (options.isIncludeCovering()) { - getCellUnionBound(cover); - } - tag.setCoveringSize((byte) cover.size()); - tag.encode(out); - - // 2) write each cell-id - for (S2CellId cid : cover) { - out.writeLong(cid.id()); - } - - // 3) encode by GeographyKind - switch (kind) { - case POINT: - ((PointGeography) this).encodeTagged(out, options); - break; - - // TODO: handle POLYLINE, POLYGON, etc. - default: - throw new IllegalArgumentException("encodeTagged not implemented for kind=" + kind); - } - - out.flush(); - } - - /** - * Reads a tagged geography from the stream (header + covering + payload). Dispatches to the right - * subclass decoder. + * @param opts CodingHint.FAST / CodingHint.COMPACT / Include or omit the cell‐union covering + * prefix */ - public static S2Geography decodeTagged(DataInputStream is) throws IOException { - DataInputStream in = new DataInputStream(is); - - // 1) Read the full 4-byte header: - int kindVal = in.readUnsignedByte(); - byte flags = (byte) in.readUnsignedByte(); - int coverCount = in.readUnsignedByte(); - int reserved = in.readUnsignedByte(); - if (reserved != 0) { - throw new IOException("Reserved header byte must be 0, was " + reserved); - } + public abstract void encode(OutputStream os, EncodeOptions opts) throws IOException; - GeographyKind kind = GeographyKind.fromKind(kindVal); - EncodeTag tag = new EncodeTag(); - tag.setKind(kind); - tag.setFlags(flags); - tag.setCoveringSize((byte) coverCount); - - // 2) If CELL_CENTER, read exactly one ID *as* payload, and return immediately: - if (kind == GeographyKind.CELL_CENTER) { - long id = in.readLong(); - return new PointGeography(new S2CellId(id).toPoint()); - } - - // 2) Skip any covering IDs - for (int i = 0; i < coverCount; i++) { - in.readLong(); - } - - // 3) Dispatch to the payload decoder - switch (kind) { - case POINT: - return PointGeography.decodeTagged(in, tag); - case POLYLINE: - return PolylineGeography.decodeTagged(in, tag); - case POLYGON: - return PolygonGeography.decodeTagged(in, tag); - default: - throw new IllegalArgumentException("Unsupported kind " + kind); - } - } + // public abstract S2Geography decode(DataInputStream in) 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 index f317ca1a5d7..934328ce09c 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/PointGeographyTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PointGeographyTest.java @@ -39,13 +39,12 @@ public void testEncodeTag() throws IOException { assertTrue(geog.getPoints().isEmpty()); ByteArrayOutputStream baos = new ByteArrayOutputStream(); - geog.encodeTagged(baos, new EncodeOptions()); + geog.encode(baos, new EncodeOptions()); byte[] data = baos.toByteArray(); - assertEquals(4, data.length); + assertEquals(5, data.length); // 2) Create a single-point geography at lat=45°, lng=-64° S2Point pt = S2LatLng.fromDegrees(45, -64).toPoint(); - System.out.println(pt.toString()); PointGeography geog2 = new PointGeography(pt); assertEquals(1, geog2.numShapes()); assertEquals(0, geog2.dimension()); @@ -53,17 +52,16 @@ public void testEncodeTag() throws IOException { assertEquals(1, originalPts.size()); assertEquals(pt, originalPts.get(0)); - // 2) EncodeTagged + // 3) EncodeTagged ByteArrayOutputStream baos2 = new ByteArrayOutputStream(); - geog2.encodeTagged(baos2, new EncodeOptions()); + geog2.encode(baos2, new EncodeOptions()); byte[] data2 = baos2.toByteArray(); // should be >4 bytes (header+payload) - assertTrue(data2.length > 4); + assertTrue(data2.length > 5); } @Test public void testEmptyPointEncodeDecode() throws IOException { - // --- C++ TEST: EmptyPoint --- // 1) Create an empty geography PointGeography geog = new PointGeography(); assertEquals(S2Geography.GeographyKind.POINT, geog.kind); @@ -74,13 +72,13 @@ public void testEmptyPointEncodeDecode() throws IOException { // 2) EncodeTagged ByteArrayOutputStream baos = new ByteArrayOutputStream(); - geog.encodeTagged(baos, new EncodeOptions()); + geog.encode(baos, new EncodeOptions()); byte[] data = baos.toByteArray(); // 3) DecodeTagged DataInputStream din = new DataInputStream(new ByteArrayInputStream(data)); // 3b) Now delegate to the dispatch method that takes (DataInputStream, EncodeTag) - S2Geography decoded = S2Geography.decodeTagged(din); + S2Geography decoded = geog.decode(din); assertTrue(decoded instanceof PointGeography); PointGeography round = (PointGeography) decoded; @@ -106,25 +104,24 @@ public void testEncodedPoint() throws IOException { // 2) EncodeTagged ByteArrayOutputStream baos = new ByteArrayOutputStream(); - geog.encodeTagged(baos, new EncodeOptions()); + geog.encode(baos, new EncodeOptions()); byte[] data = baos.toByteArray(); // 3) DecodeTagged DataInputStream din = new DataInputStream(new ByteArrayInputStream(data)); - S2Geography decoded = S2Geography.decodeTagged(din); + PointGeography decoded = geog.decode(din); assertTrue(decoded instanceof PointGeography); - PointGeography round = (PointGeography) decoded; // 4) Verify everything round-tripped - assertEquals(1, round.numShapes()); - assertEquals(0, round.dimension()); - List roundPts = round.getPoints(); + assertEquals(1, decoded.numShapes()); + assertEquals(0, decoded.dimension()); + List roundPts = decoded.getPoints(); assertEquals(1, roundPts.size()); assertEquals(pt, roundPts.get(0)); System.out.println(roundPts.toString()); // 1) Get the point and turn it into WKT with 6 decimal places: - S2Point p = round.getPoints().get(0); + S2Point p = decoded.getPoints().get(0); S2LatLng ll = new S2LatLng(p); String wkt = String.format( @@ -135,7 +132,7 @@ public void testEncodedPoint() throws IOException { System.out.println(wkt); assertEquals("POINT (-64.000000 45.000000)", wkt); // 5) region() should contain exactly that point - S2Region region = round.region(); + S2Region region = decoded.region(); // Single-point region can be represented as a tiny cap containing only pt assertTrue(region.contains(pt)); } @@ -154,11 +151,11 @@ public void testEncodedSnappedPoint() throws IOException { opts.setCodingHint(EncodeOptions.CodingHint.COMPACT); ByteArrayOutputStream baos = new ByteArrayOutputStream(); - geog.encodeTagged(baos, opts); + geog.encode(baos, opts); byte[] data = baos.toByteArray(); - // Exactly 4-byte header + 8-byte cell-id - assertEquals(12, data.length); + // Exactly 5-byte header + 8-byte cell-id + assertEquals(13, data.length); // 3) Peek at the tag + covering DataInputStream in1 = new DataInputStream(new ByteArrayInputStream(data)); @@ -177,8 +174,7 @@ public void testEncodedSnappedPoint() throws IOException { // 4) Now round-trip the entire geography PointGeography round = - (PointGeography) - S2Geography.decodeTagged(new DataInputStream(new ByteArrayInputStream(data))); + (PointGeography) geog.decode(new DataInputStream(new ByteArrayInputStream(data))); System.out.println(round.getPoints().toString()); // It should still be a single‐point geography @@ -186,4 +182,29 @@ public void testEncodedSnappedPoint() throws IOException { // And the point should be exactly the snapped cell‐center assertEquals(ptSnapped, round.getPoints().get(0)); } + + @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); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + geog.encode(baos, opts); + byte[] data = baos.toByteArray(); + + // 3) Decode round-trip + PointGeography round = + (PointGeography) geog.decode(new DataInputStream(new ByteArrayInputStream(data))); + + // 4) Assert round-trip matches + assertEquals(2, round.getPoints().size()); + assertEquals(pt1, round.getPoints().get(0)); + assertEquals(pt2, round.getPoints().get(1)); + } } 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 index e8baa30d563..376d64255a6 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/PolygonGeographyTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PolygonGeographyTest.java @@ -48,24 +48,24 @@ public void testEncodedPolygon() throws IOException { S2Loop polyline = new S2Loop(points); S2Polygon poly = new S2Polygon(polyline); System.out.println(poly.toString()); - PolygonGeography pg = new PolygonGeography(poly); + PolygonGeography geo = new PolygonGeography(poly); // Encode the geography with tagging - pg.encodeTagged(baos, new EncodeOptions()); + geo.encode(baos, new EncodeOptions()); // Decode from the bytes byte[] encodedBytes = baos.toByteArray(); DataInputStream dis = new DataInputStream(new ByteArrayInputStream(encodedBytes)); - S2Geography roundtrip = S2Geography.decodeTagged(dis); + S2Geography roundtrip = geo.decode(dis); // Verify kind assertEquals(S2Geography.GeographyKind.POLYGON, roundtrip.kind); System.out.println(roundtrip.toString()); // Extract polygon and build WKT string // Extract decoded polygon - assertEquals(1, pg.getPolygons().size()); + assertEquals(1, geo.getPolygons().size()); - S2Polygon pl = pg.getPolygons().get(0); + S2Polygon pl = geo.getPolygons().get(0); // Reconstruct WKT from first loop S2Loop loop = pl.loop(0); StringBuilder sb = new StringBuilder("POLYGON (("); 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 index bdb0f494148..1a662898baf 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java @@ -49,12 +49,12 @@ public void testEncodedPolyline() throws IOException { PolylineGeography geog = new PolylineGeography(polyline); // Encode the geography with tagging - geog.encodeTagged(baos, new EncodeOptions()); + geog.encode(baos, new EncodeOptions()); // Decode from the bytes byte[] encodedBytes = baos.toByteArray(); DataInputStream dis = new DataInputStream(new ByteArrayInputStream(encodedBytes)); - S2Geography roundtrip = S2Geography.decodeTagged(dis); + S2Geography roundtrip = geog.decode(dis); // Verify kind assertEquals(S2Geography.GeographyKind.POLYLINE, roundtrip.kind); @@ -99,11 +99,11 @@ public void testEncodedMultiPolyline() throws IOException { // 3) Encode to bytes ByteArrayOutputStream baos = new ByteArrayOutputStream(); - geog.encodeTagged(baos, new EncodeOptions()); + geog.encode(baos, new EncodeOptions()); // 4) Decode back DataInputStream dis = new DataInputStream(new ByteArrayInputStream(baos.toByteArray())); - S2Geography decoded = S2Geography.decodeTagged(dis); + S2Geography decoded = geog.decode(dis); // 5) Verify it’s a PolylineGeography with two members assertTrue(decoded instanceof PolylineGeography); From 9be05ea9b88b15e787838cb36f8e16b4f2635805 Mon Sep 17 00:00:00 2001 From: Zhuocheng Shang <122398181+ZhuochengShang@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:12:51 -0700 Subject: [PATCH 07/14] clean up unnecessary files in current branch --- .../EncodedShapeIndexGeography.java | 25 ------------------- 1 file changed, 25 deletions(-) delete mode 100644 common/src/main/java/org/apache/sedona/common/S2Geography/EncodedShapeIndexGeography.java diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/EncodedShapeIndexGeography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/EncodedShapeIndexGeography.java deleted file mode 100644 index 5760985c14e..00000000000 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/EncodedShapeIndexGeography.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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 EncodedShapeIndexGeography extends S2Geography { - public EncodedShapeIndexGeography(GeographyKind geographyKind) { - super(geographyKind); - } -} From cf2423ca2e8d9f129e24c350c19c4f9d26967b7e Mon Sep 17 00:00:00 2001 From: Zhuocheng Shang <122398181+ZhuochengShang@users.noreply.github.com> Date: Tue, 24 Jun 2025 17:25:39 -0700 Subject: [PATCH 08/14] 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 --- .../sedona/common/S2Geography/EncodeTag.java | 33 +++-- .../common/S2Geography/PointGeography.java | 136 ++++++++++-------- .../common/S2Geography/PolygonGeography.java | 38 ++--- .../common/S2Geography/PolylineGeography.java | 54 +++---- .../common/S2Geography/S2Geography.java | 71 ++++++++- .../S2Geography/PointGeographyTest.java | 42 +++--- .../S2Geography/PolygonGeographyTest.java | 7 +- .../S2Geography/PolylineGeographyTest.java | 47 +++++- 8 files changed, 251 insertions(+), 177 deletions(-) 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 index 893bf97d860..a53f99fa74f 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/EncodeTag.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/EncodeTag.java @@ -18,17 +18,18 @@ */ 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.DataInputStream; -import java.io.DataOutputStream; -import java.io.IOException; +import java.io.*; import java.util.List; import org.apache.sedona.common.S2Geography.S2Geography.GeographyKind; /** - * A 4 byte prefix for encoded geographies. 4 bytes is essential so that German-style strings store - * these bytes in their prefix (i.e., don't have to load any auxiliary buffers to inspect this - * information). + * A 5 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) 1 byte: encodeType (fast vs. + * compact) */ public class EncodeTag { /** @@ -63,7 +64,7 @@ public EncodeTag(EncodeOptions opts) { // ——— Write the 4-byte tag header —————————————————————————————————————— /** Write exactly 4 bytes: [kind|flags|coveringSize|reserved]. */ - public void encode(DataOutputStream out) throws IOException { + public void encode(Output out) throws IOException { out.writeByte(kind.getKind()); out.writeByte(flags); out.writeByte(coveringSize); @@ -72,13 +73,13 @@ public void encode(DataOutputStream out) throws IOException { } // ——— Read it back ———————————————————————————————————————————————— - /** Reads exactly 4 bytes (in the same order) from the stream. */ - public static EncodeTag decode(DataInputStream in) throws IOException { + /** Reads exactly 5 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.readUnsignedByte()); - tag.flags = (byte) in.readUnsignedByte(); - tag.coveringSize = (byte) in.readUnsignedByte(); - tag.reserved = (byte) in.readUnsignedByte(); + 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); tag.encodeType = in.readByte(); @@ -88,7 +89,8 @@ public static EncodeTag decode(DataInputStream in) throws IOException { // ——— Helpers for the optional covering list ————————————————————————— /** Read coveringSize many cell-ids and add them to cellIds. */ - public void decodeCovering(DataInputStream in, List cellIds) throws IOException { + public void decodeCovering(InputStream is, List cellIds) throws IOException { + UnsafeInput in = new UnsafeInput(is); int count = coveringSize & 0xFF; for (int i = 0; i < count; i++) { long id = in.readLong(); @@ -97,7 +99,8 @@ public void decodeCovering(DataInputStream in, List cellIds) throws IO } /** Skip over coveringSize many cell-ids in the stream. */ - public void skipCovering(DataInputStream in) throws IOException { + public void skipCovering(InputStream is) throws IOException { + UnsafeInput in = new UnsafeInput(is); int count = coveringSize & 0xFF; for (int i = 0; i < count; i++) { in.readLong(); 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 index 283350d1110..a60e17a87d6 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PointGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PointGeography.java @@ -18,16 +18,21 @@ */ package org.apache.sedona.common.S2Geography; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; +import com.esotericsoftware.kryo.io.UnsafeOutput; import com.google.common.geometry.*; import com.google.common.geometry.PrimitiveArrays.Bytes; -import com.google.common.geometry.PrimitiveArrays.Cursor; import java.io.*; import java.util.*; import java.util.List; -import java.util.logging.Logger; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class PointGeography extends S2Geography { - private static final Logger logger = Logger.getLogger(PointGeography.class.getName()); + private static final Logger logger = LoggerFactory.getLogger(PointGeography.class.getName()); + + private static final int BUFFER_SIZE = 4 * 1024; private final List points = new ArrayList<>(); @@ -100,7 +105,8 @@ public void getCellUnionBound(List cellIds) { /** Returns an immutable view of the points. */ public List getPoints() { - return Collections.unmodifiableList(points); + // List.copyOf makes an unmodifiable copy under the hood + return List.copyOf(points); } // ------------------------------------------------------- @@ -108,21 +114,15 @@ public List getPoints() { // ------------------------------------------------------- @Override - public void encode(OutputStream os, EncodeOptions opts) throws IOException { - DataOutputStream out = new DataOutputStream(os); - - // EMPTY - if (points.isEmpty()) { - EncodeTag tag = new EncodeTag(opts); - tag.setKind(GeographyKind.POINT); - tag.setFlags((byte) (tag.getFlags() | EncodeTag.FLAG_EMPTY)); - tag.setCoveringSize((byte) 0); - tag.encode(out); - return; - } - + 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) { out.writeByte(GeographyKind.CELL_CENTER.getKind()); out.writeByte(0); // POINT kind @@ -130,75 +130,85 @@ public void encode(OutputStream os, EncodeOptions opts) throws IOException { out.writeByte(0); // coveringSize out.writeByte(2); // COMPACT encode type out.writeLong(cid.id()); + out.flush(); return; } + super.encodeTagged(os, opts); // Not exactly encodable as a cell center } + // In other cases, fallback to the default encodeTagged implementation: + super.encodeTagged(os, opts); + } - // Compute covering if requested - List cover = new ArrayList<>(); - if (opts.isIncludeCovering()) { - getCellUnionBound(cover); - if (cover.size() > 255) { - cover.clear(); - logger.warning("Covering size too large (> 255) — clear Covering"); - } - } - // Write tag and covering - EncodeTag tag = new EncodeTag(opts); - tag.setKind(GeographyKind.POINT); - tag.setCoveringSize((byte) cover.size()); - tag.encode(out); - for (S2CellId c2 : cover) { - out.writeLong(c2.id()); - } - + @Override + protected void encode(Output out, EncodeOptions opts) throws IOException { // Encode point payload using selected hint S2Point.Shape shp = S2Point.Shape.fromList(points); - if (opts.getCodingHint() == EncodeOptions.CodingHint.FAST) { - S2Point.Shape.FAST_CODER.encode(shp, out); - } else { - S2Point.Shape.COMPACT_CODER.encode(shp, out); + switch (opts.getCodingHint()) { + case FAST: + S2Point.Shape.FAST_CODER.encode(shp, out); + break; + case COMPACT: + S2Point.Shape.COMPACT_CODER.encode(shp, out); } } - public static PointGeography decode(DataInputStream in) throws IOException { - EncodeTag tag = new EncodeTag(); - tag = EncodeTag.decode(in); - int coverSize = tag.getCoveringSize() & 0xFF; - + public static PointGeography decode(Input in, EncodeTag tag) throws IOException { PointGeography geo = new PointGeography(); + // EMPTY if ((tag.getFlags() & EncodeTag.FLAG_EMPTY) != 0) { - logger.fine("Decoded empty PointGeography."); + logger.warn("Decoded empty PointGeography."); return geo; } // Optimized 1-point COMPACT situation - if (tag.getKind() == GeographyKind.CELL_CENTER && tag.getEncodeType() == 2) { + if (tag.getKind() == GeographyKind.CELL_CENTER) { long id = in.readLong(); geo = new PointGeography(new S2CellId(id).toPoint()); - logger.fine("Decoded compact single-point geography via cell center."); + logger.info("Decoded compact single-point geography via cell center."); return geo; } // skip cover - for (int i = 0; i < coverSize; i++) { - in.readLong(); - } - - // Read remaining bytes into buffer - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - byte[] buffer = new byte[1024]; - int len; - while ((len = in.read(buffer)) != -1) { - baos.write(buffer, 0, len); + tag.skipCovering(in); + + // Grab Kryo’s backing buffer & bounds + Input kryoIn = (Input) in; + final byte[] backing = kryoIn.getBuffer(); + final int start = kryoIn.position(); + final int end = kryoIn.limit(); + final long length = (long) end - start; // fits in an int normally + + // Zero-copy Bytes view + Bytes bytes = + new Bytes() { + @Override + public long length() { + return length; + } + + @Override + public byte get(long idx) { + if (idx < 0 || idx >= length) { + throw new IndexOutOfBoundsException(idx + " not in [0," + length + ")"); + } + // safe to cast to int because length <= backing.length + return backing[start + (int) idx]; + } + }; + + PrimitiveArrays.Cursor cursor = bytes.cursor(); + List points; + switch (tag.getEncodeType()) { + case 1: + points = S2Point.Shape.FAST_CODER.decode(bytes, cursor); + break; + case 2: + points = S2Point.Shape.COMPACT_CODER.decode(bytes, cursor); + break; + default: + throw new IllegalArgumentException("Unknown coding hint"); } - - PrimitiveArrays.Bytes bytes = Bytes.fromByteArray(baos.toByteArray()); - Cursor cursor = bytes.cursor(); - List points = new ArrayList<>(); - if (tag.getEncodeType() == 1) points = S2PointVectorCoder.FAST.decode(bytes, cursor); - else if (tag.getEncodeType() == 2) points = S2PointVectorCoder.COMPACT.decode(bytes, cursor); geo.points.addAll(points); 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 index f69c0076cc9..9b0a38e98f8 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java @@ -18,12 +18,11 @@ */ package org.apache.sedona.common.S2Geography; +import com.esotericsoftware.kryo.io.Output; +import com.esotericsoftware.kryo.io.UnsafeInput; import com.google.common.collect.ImmutableList; import com.google.common.geometry.*; -import java.io.DataInputStream; -import java.io.DataOutputStream; import java.io.IOException; -import java.io.OutputStream; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -81,28 +80,7 @@ public List getPolygons() { } @Override - public void encode(OutputStream os, EncodeOptions opts) throws IOException { - DataOutputStream out = new DataOutputStream(os); - - // 1) Serialize any covering cells (if you have them) - List cover = new ArrayList<>(); - if (opts.isIncludeCovering()) { - getCellUnionBound(cover); - if (cover.size() > 255) { - cover.clear(); - logger.warning("Covering size too large (> 255) — clear Covering"); - } - } - - // 2) Write tag header (unchanged) using leOut.writeByte(...) - EncodeTag tag = new EncodeTag(opts); - tag.setKind(GeographyKind.POLYGON); - tag.setCoveringSize((byte) cover.size()); - tag.encode(out); - for (S2CellId c2 : cover) { - out.writeLong(c2.id()); - } - + public void encode(Output out, EncodeOptions opts) throws IOException { // 3) Write number of polygons out.writeInt(polygons.size()); @@ -113,24 +91,24 @@ public void encode(OutputStream os, EncodeOptions opts) throws IOException { out.flush(); } - public static PolygonGeography decode(DataInputStream in) throws IOException { + public static PolygonGeography decode(UnsafeInput in, EncodeTag tag) throws IOException { PolygonGeography geo = new PolygonGeography(); - EncodeTag tag = new EncodeTag(); - tag = EncodeTag.decode(in); // EMPTY if ((tag.getFlags() & EncodeTag.FLAG_EMPTY) != 0) { logger.fine("Decoded empty PolygonGeography."); return geo; } - // Skip any covering cells + // 2) Skip past any covering cell-IDs written by encodeTagged tag.skipCovering(in); - // Read polygon count + // 3) Ensure we have at least 4 bytes for the count if (in.available() < Integer.BYTES) { throw new IOException("PolygonGeography.decodeTagged error: insufficient header bytes"); } + + // 5) Read the number of polylines (4-byte) int count = in.readInt(); // Decode each polygon 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 index d1b2815ff44..aef37d77884 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java @@ -18,6 +18,8 @@ */ package org.apache.sedona.common.S2Geography; +import com.esotericsoftware.kryo.io.Output; +import com.esotericsoftware.kryo.io.UnsafeInput; import com.google.common.collect.ImmutableList; import com.google.common.geometry.*; import java.io.*; @@ -86,70 +88,48 @@ public List getPolylines() { } @Override - public void encode(OutputStream os, EncodeOptions opts) throws IOException { - // Wrap your stream in a little-endian DataOutput - DataOutputStream out = new DataOutputStream(os); - - // 1) Serialize any covering cells (if you have them) - List cover = new ArrayList<>(); - if (opts.isIncludeCovering()) { - getCellUnionBound(cover); - if (cover.size() > 255) { - cover.clear(); - logger.warning("Covering size too large (> 255) — clear Covering"); - } - } - - // 2) Write tag header (unchanged) using leOut.writeByte(...) - EncodeTag tag = new EncodeTag(opts); - tag.setKind(GeographyKind.POLYLINE); - tag.setCoveringSize((byte) cover.size()); - tag.encode(out); - for (S2CellId c2 : cover) { - out.writeLong(c2.id()); - } - - // 3) **Critical**: write the number of polylines in little-endian + public void encode(Output out, EncodeOptions opts) throws IOException { + // 1) Write number of polylines as a 4-byte Kryo int out.writeInt(polylines.size()); - // Encode point payload using selected hint - // 4) Delegate each polyline’s payload (which itself writes little-endian) + // 2) Encode point payload using selected hint + boolean useFast = opts.getCodingHint() == EncodeOptions.CodingHint.FAST; for (S2Polyline pl : polylines) { - pl.encode(out); + if (useFast) { + S2Polyline.FAST_CODER.encode(pl, out); + } else { + S2Polyline.COMPACT_CODER.encode(pl, out); + } + out.flush(); } - out.flush(); } - public static PolylineGeography decode(DataInputStream in) throws IOException { + public static PolylineGeography decode(UnsafeInput in, EncodeTag tag) throws IOException { // 1) Instantiate an empty geography PolylineGeography geo = new PolylineGeography(); - EncodeTag tag = new EncodeTag(); - tag = EncodeTag.decode(in); // EMPTY if ((tag.getFlags() & EncodeTag.FLAG_EMPTY) != 0) { logger.fine("Decoded empty PointGeography."); return geo; } - // 3) Skip past any covering cell-IDs written by encodeTagged + // 2) Skip past any covering cell-IDs written by encodeTagged tag.skipCovering(in); - // 4) Ensure we have at least 4 bytes for the count + // 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 little-endian int) + // 5) Read the number of polylines (4-byte) int count = in.readInt(); - // 6) Loop and decode each polyline + // 6) For each polyline, read its block and let S2Polyline.decode(InputStream) do the rest for (int i = 0; i < count; i++) { - // This will read the version byte, then dispatch to decodeLossless or decodeCompressed 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 index 680ff6788b9..6fd81e38f37 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java @@ -18,15 +18,25 @@ */ package org.apache.sedona.common.S2Geography; +import com.esotericsoftware.kryo.io.Output; +import com.esotericsoftware.kryo.io.UnsafeInput; +import com.esotericsoftware.kryo.io.UnsafeOutput; import com.google.common.geometry.*; import java.io.*; +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) { @@ -135,7 +145,64 @@ public void getCellUnionBound(List cellIds) { * @param opts CodingHint.FAST / CodingHint.COMPACT / Include or omit the cell‐union covering * prefix */ - public abstract void encode(OutputStream os, EncodeOptions opts) throws IOException; + public void encodeTagged(OutputStream os, EncodeOptions opts) throws IOException { + UnsafeOutput out = new UnsafeOutput(os, BUFFER_SIZE); + EncodeTag tag = new EncodeTag(opts); + List 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 { + UnsafeInput in = new UnsafeInput(is, BUFFER_SIZE); + + // 1) decode the tag + EncodeTag tag = EncodeTag.decode(in); + + // 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); + default: + throw new IOException("Unsupported GeographyKind for decoding: " + tag.getKind()); + } + } - // public abstract S2Geography decode(DataInputStream in) throws IOException; + protected abstract void encode(Output 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 index 934328ce09c..7d1c86ae3a2 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/PointGeographyTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PointGeographyTest.java @@ -21,6 +21,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.UnsafeInput; import com.google.common.geometry.*; import java.io.*; import java.util.ArrayList; @@ -38,8 +40,9 @@ public void testEncodeTag() throws IOException { assertEquals(-1, geog.dimension()); assertTrue(geog.getPoints().isEmpty()); + // 2) Encode into a ByteArrayOutputStream ByteArrayOutputStream baos = new ByteArrayOutputStream(); - geog.encode(baos, new EncodeOptions()); + geog.encodeTagged(baos, new EncodeOptions()); byte[] data = baos.toByteArray(); assertEquals(5, data.length); @@ -54,7 +57,7 @@ public void testEncodeTag() throws IOException { // 3) EncodeTagged ByteArrayOutputStream baos2 = new ByteArrayOutputStream(); - geog2.encode(baos2, new EncodeOptions()); + geog2.encodeTagged(baos2, new EncodeOptions()); byte[] data2 = baos2.toByteArray(); // should be >4 bytes (header+payload) assertTrue(data2.length > 5); @@ -72,13 +75,13 @@ public void testEmptyPointEncodeDecode() throws IOException { // 2) EncodeTagged ByteArrayOutputStream baos = new ByteArrayOutputStream(); - geog.encode(baos, new EncodeOptions()); + geog.encodeTagged(baos, new EncodeOptions()); byte[] data = baos.toByteArray(); // 3) DecodeTagged - DataInputStream din = new DataInputStream(new ByteArrayInputStream(data)); + ByteArrayInputStream din = new ByteArrayInputStream(data); // 3b) Now delegate to the dispatch method that takes (DataInputStream, EncodeTag) - S2Geography decoded = geog.decode(din); + S2Geography decoded = geog.decodeTagged(din); assertTrue(decoded instanceof PointGeography); PointGeography round = (PointGeography) decoded; @@ -104,24 +107,25 @@ public void testEncodedPoint() throws IOException { // 2) EncodeTagged ByteArrayOutputStream baos = new ByteArrayOutputStream(); - geog.encode(baos, new EncodeOptions()); + geog.encodeTagged(baos, new EncodeOptions()); byte[] data = baos.toByteArray(); // 3) DecodeTagged - DataInputStream din = new DataInputStream(new ByteArrayInputStream(data)); - PointGeography decoded = geog.decode(din); + ByteArrayInputStream din = new ByteArrayInputStream(data); + PointGeography decoded = (PointGeography) geog.decodeTagged(din); assertTrue(decoded instanceof PointGeography); // 4) Verify everything round-tripped assertEquals(1, decoded.numShapes()); assertEquals(0, decoded.dimension()); - List roundPts = decoded.getPoints(); - assertEquals(1, roundPts.size()); - assertEquals(pt, roundPts.get(0)); + // 4) Now round-trip the entire geography + System.out.println(decoded.getPoints().toString()); + + // It should still be a single‐point geography + assertEquals(1, decoded.getPoints().size()); - System.out.println(roundPts.toString()); // 1) Get the point and turn it into WKT with 6 decimal places: - S2Point p = decoded.getPoints().get(0); + S2Point p = ((PointGeography) decoded).getPoints().get(0); S2LatLng ll = new S2LatLng(p); String wkt = String.format( @@ -151,14 +155,14 @@ public void testEncodedSnappedPoint() throws IOException { opts.setCodingHint(EncodeOptions.CodingHint.COMPACT); ByteArrayOutputStream baos = new ByteArrayOutputStream(); - geog.encode(baos, opts); + geog.encodeTagged(baos, opts); byte[] data = baos.toByteArray(); // Exactly 5-byte header + 8-byte cell-id assertEquals(13, data.length); // 3) Peek at the tag + covering - DataInputStream in1 = new DataInputStream(new ByteArrayInputStream(data)); + Input in1 = new Input(data); // Read and decode the 4-byte header EncodeTag tag = EncodeTag.decode(in1); @@ -173,8 +177,7 @@ public void testEncodedSnappedPoint() throws IOException { assertEquals(cellId, cover.get(0)); // 4) Now round-trip the entire geography - PointGeography round = - (PointGeography) geog.decode(new DataInputStream(new ByteArrayInputStream(data))); + PointGeography round = (PointGeography) geog.decodeTagged(new ByteArrayInputStream(data)); System.out.println(round.getPoints().toString()); // It should still be a single‐point geography @@ -195,15 +198,16 @@ public void testEncodedListPoints() throws IOException { opts.setCodingHint(EncodeOptions.CodingHint.COMPACT); ByteArrayOutputStream baos = new ByteArrayOutputStream(); - geog.encode(baos, opts); + geog.encodeTagged(baos, opts); byte[] data = baos.toByteArray(); // 3) Decode round-trip PointGeography round = - (PointGeography) geog.decode(new DataInputStream(new ByteArrayInputStream(data))); + (PointGeography) geog.decodeTagged(new UnsafeInput(new ByteArrayInputStream(data))); // 4) Assert round-trip matches assertEquals(2, round.getPoints().size()); + assertEquals(geog.getPoints(), round.getPoints()); assertEquals(pt1, round.getPoints().get(0)); assertEquals(pt2, round.getPoints().get(1)); } 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 index 376d64255a6..0622bea0b22 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/PolygonGeographyTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PolygonGeographyTest.java @@ -24,7 +24,6 @@ import com.google.common.geometry.*; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; -import java.io.DataInputStream; import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -51,12 +50,12 @@ public void testEncodedPolygon() throws IOException { PolygonGeography geo = new PolygonGeography(poly); // Encode the geography with tagging - geo.encode(baos, new EncodeOptions()); + geo.encodeTagged(baos, new EncodeOptions()); // Decode from the bytes byte[] encodedBytes = baos.toByteArray(); - DataInputStream dis = new DataInputStream(new ByteArrayInputStream(encodedBytes)); - S2Geography roundtrip = geo.decode(dis); + ByteArrayInputStream dis = new ByteArrayInputStream(encodedBytes); + S2Geography roundtrip = geo.decodeTagged(dis); // Verify kind assertEquals(S2Geography.GeographyKind.POLYGON, roundtrip.kind); 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 index 1a662898baf..fa523fae1fa 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java @@ -26,7 +26,6 @@ import com.google.common.geometry.S2Polyline; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; -import java.io.DataInputStream; import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -49,12 +48,12 @@ public void testEncodedPolyline() throws IOException { PolylineGeography geog = new PolylineGeography(polyline); // Encode the geography with tagging - geog.encode(baos, new EncodeOptions()); + geog.encodeTagged(baos, new EncodeOptions()); // Decode from the bytes byte[] encodedBytes = baos.toByteArray(); - DataInputStream dis = new DataInputStream(new ByteArrayInputStream(encodedBytes)); - S2Geography roundtrip = geog.decode(dis); + ByteArrayInputStream dis = new ByteArrayInputStream(encodedBytes); + S2Geography roundtrip = geog.decodeTagged(dis); // Verify kind assertEquals(S2Geography.GeographyKind.POLYLINE, roundtrip.kind); @@ -99,11 +98,45 @@ public void testEncodedMultiPolyline() throws IOException { // 3) Encode to bytes ByteArrayOutputStream baos = new ByteArrayOutputStream(); - geog.encode(baos, new EncodeOptions()); + geog.encodeTagged(baos, new EncodeOptions()); // 4) Decode back - DataInputStream dis = new DataInputStream(new ByteArrayInputStream(baos.toByteArray())); - S2Geography decoded = geog.decode(dis); + ByteArrayInputStream dis = new ByteArrayInputStream(baos.toByteArray()); + S2Geography decoded = geog.decodeTagged(dis); + + // 5) Verify it’s a PolylineGeography with two members + assertTrue(decoded instanceof PolylineGeography); + PolylineGeography pg = (PolylineGeography) decoded; + assertEquals(2, pg.getPolylines().size()); + + // 6) Check each one matches the original + assertTrue(pg.getPolylines().get(0).equals(poly1)); + assertTrue(pg.getPolylines().get(1).equals(poly2)); + } + + @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 + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + EncodeOptions encodeOptions = new EncodeOptions(); + encodeOptions.setCodingHint(EncodeOptions.CodingHint.COMPACT); + geog.encodeTagged(baos, encodeOptions); + + // 4) Decode back + ByteArrayInputStream dis = new ByteArrayInputStream(baos.toByteArray()); + S2Geography decoded = geog.decodeTagged(dis); // 5) Verify it’s a PolylineGeography with two members assertTrue(decoded instanceof PolylineGeography); From 44392ce5c96d92f087992a0056c81ae17aec4b5b Mon Sep 17 00:00:00 2001 From: Zhuocheng Shang <122398181+ZhuochengShang@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:37:34 -0700 Subject: [PATCH 09/14] Modify encoder() and add new test cases --- .../sedona/common/S2Geography/EncodeTag.java | 64 +++--- .../common/S2Geography/PointGeography.java | 90 ++++++--- .../common/S2Geography/PolygonGeography.java | 14 +- .../common/S2Geography/PolylineGeography.java | 14 +- .../common/S2Geography/S2Geography.java | 3 +- .../S2Geography/PointGeographyTest.java | 190 ++++++++---------- .../S2Geography/PolygonGeographyTest.java | 44 +--- .../S2Geography/PolylineGeographyTest.java | 75 +------ .../sedona/common/S2Geography/TestHelper.java | 155 ++++++++++++++ 9 files changed, 359 insertions(+), 290 deletions(-) create mode 100644 common/src/test/java/org/apache/sedona/common/S2Geography/TestHelper.java 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 index a53f99fa74f..060b6e39c62 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/EncodeTag.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/EncodeTag.java @@ -27,9 +27,8 @@ import org.apache.sedona.common.S2Geography.S2Geography.GeographyKind; /** - * A 5 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) 1 byte: encodeType (fast vs. - * compact) + * 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 { /** @@ -42,6 +41,12 @@ public class EncodeTag { * and only if the geography contains zero shapes. */ 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. @@ -50,26 +55,20 @@ public class EncodeTag { /** Reserved byte for future use. Must be set to 0. */ private byte reserved = 0; - /** If set, geography has zero shapes. */ - public static final byte FLAG_EMPTY = 1; - - private byte encodeType = 1; // fast: 1 ; compact: 2 - + // ——— Write the 4-byte tag header —————————————————————————————————————— public EncodeTag() {} public EncodeTag(EncodeOptions opts) { - this.encodeType = (byte) (opts.getCodingHint() == EncodeOptions.CodingHint.FAST ? 1 : 2); + if (opts.getCodingHint() == EncodeOptions.CodingHint.COMPACT) { + flags |= FLAG_COMPACT; + } } - - // ——— Write the 4-byte tag header —————————————————————————————————————— - /** 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); - out.writeByte(encodeType); } // ——— Read it back ———————————————————————————————————————————————— @@ -82,15 +81,13 @@ public static EncodeTag decode(Input in) throws IOException { tag.reserved = in.readByte(); if (tag.reserved != 0) throw new IOException("Reserved header byte must be 0, was " + tag.reserved); - tag.encodeType = in.readByte(); return tag; } // ——— Helpers for the optional covering list ————————————————————————— /** Read coveringSize many cell-ids and add them to cellIds. */ - public void decodeCovering(InputStream is, List cellIds) throws IOException { - UnsafeInput in = new UnsafeInput(is); + public void decodeCovering(UnsafeInput in, List cellIds) throws IOException { int count = coveringSize & 0xFF; for (int i = 0; i < count; i++) { long id = in.readLong(); @@ -99,8 +96,7 @@ public void decodeCovering(InputStream is, List cellIds) throws IOExce } /** Skip over coveringSize many cell-ids in the stream. */ - public void skipCovering(InputStream is) throws IOException { - UnsafeInput in = new UnsafeInput(is); + public void skipCovering(UnsafeInput in) throws IOException { int count = coveringSize & 0xFF; for (int i = 0; i < count; i++) { in.readLong(); @@ -132,14 +128,6 @@ public void setFlags(byte flags) { this.flags = flags; } - public void setEncodeType(byte type) { - this.encodeType = type; - } - - public byte getEncodeType() { - return encodeType; - } - public byte getCoveringSize() { return coveringSize; } @@ -147,4 +135,28 @@ public byte getCoveringSize() { 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 index a60e17a87d6..6aa6ee154e4 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PointGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PointGeography.java @@ -20,6 +20,7 @@ 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; @@ -128,31 +129,53 @@ public void encodeTagged(OutputStream os, EncodeOptions opts) throws IOException out.writeByte(0); // POINT kind out.writeByte(1); // flag out.writeByte(0); // coveringSize - out.writeByte(2); // COMPACT encode type out.writeLong(cid.id()); out.flush(); return; } - super.encodeTagged(os, opts); // Not exactly encodable as a cell center } // In other cases, fallback to the default encodeTagged implementation: - super.encodeTagged(os, opts); + super.encodeTagged(out, opts); } @Override - protected void encode(Output out, EncodeOptions opts) throws IOException { + 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, out); + S2Point.Shape.FAST_CODER.encode(shp, tmpOut); break; case COMPACT: - S2Point.Shape.COMPACT_CODER.encode(shp, out); + 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 @@ -172,44 +195,47 @@ public static PointGeography decode(Input in, EncodeTag tag) throws IOException // skip cover tag.skipCovering(in); - // Grab Kryo’s backing buffer & bounds - Input kryoIn = (Input) in; - final byte[] backing = kryoIn.getBuffer(); - final int start = kryoIn.position(); - final int end = kryoIn.limit(); - final long length = (long) end - start; // fits in an int normally + // 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); + } - // Zero-copy Bytes view + // 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 length; + return payload.length; } @Override - public byte get(long idx) { - if (idx < 0 || idx >= length) { - throw new IndexOutOfBoundsException(idx + " not in [0," + length + ")"); - } - // safe to cast to int because length <= backing.length - return backing[start + (int) idx]; + public byte get(long i) { + return payload[(int) i]; } }; - PrimitiveArrays.Cursor cursor = bytes.cursor(); - List points; - switch (tag.getEncodeType()) { - case 1: - points = S2Point.Shape.FAST_CODER.decode(bytes, cursor); - break; - case 2: - points = S2Point.Shape.COMPACT_CODER.decode(bytes, cursor); - break; - default: - throw new IllegalArgumentException("Unknown coding hint"); + + // pick the right decoder + List pts; + if (tag.isCompact()) { + pts = S2Point.Shape.COMPACT_CODER.decode(bytes, cursor); + } else { + pts = S2Point.Shape.FAST_CODER.decode(bytes, cursor); } - geo.points.addAll(points); + + 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 index 9b0a38e98f8..58ac4991ce7 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java @@ -18,8 +18,9 @@ */ package org.apache.sedona.common.S2Geography; -import com.esotericsoftware.kryo.io.Output; +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.IOException; @@ -80,7 +81,7 @@ public List getPolygons() { } @Override - public void encode(Output out, EncodeOptions opts) throws IOException { + public void encode(UnsafeOutput out, EncodeOptions opts) throws IOException { // 3) Write number of polygons out.writeInt(polygons.size()); @@ -91,6 +92,15 @@ public void encode(Output out, EncodeOptions opts) throws IOException { 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(); 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 index aef37d77884..49201c68672 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java @@ -18,8 +18,9 @@ */ package org.apache.sedona.common.S2Geography; -import com.esotericsoftware.kryo.io.Output; +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.*; @@ -88,7 +89,7 @@ public List getPolylines() { } @Override - public void encode(Output out, EncodeOptions opts) throws IOException { + public void encode(UnsafeOutput out, EncodeOptions opts) throws IOException { // 1) Write number of polylines as a 4-byte Kryo int out.writeInt(polylines.size()); @@ -104,6 +105,15 @@ public void encode(Output out, EncodeOptions opts) throws IOException { } } + /** 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(); diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java index 6fd81e38f37..558c9b55e14 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java @@ -18,7 +18,6 @@ */ package org.apache.sedona.common.S2Geography; -import com.esotericsoftware.kryo.io.Output; import com.esotericsoftware.kryo.io.UnsafeInput; import com.esotericsoftware.kryo.io.UnsafeOutput; import com.google.common.geometry.*; @@ -204,5 +203,5 @@ public S2Geography decodeTagged(InputStream is) throws IOException { } } - protected abstract void encode(Output os, EncodeOptions opts) throws IOException; + 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 index 7d1c86ae3a2..8c0e0387c26 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/PointGeographyTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PointGeographyTest.java @@ -21,8 +21,6 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; -import com.esotericsoftware.kryo.io.Input; -import com.esotericsoftware.kryo.io.UnsafeInput; import com.google.common.geometry.*; import java.io.*; import java.util.ArrayList; @@ -44,7 +42,7 @@ public void testEncodeTag() throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); geog.encodeTagged(baos, new EncodeOptions()); byte[] data = baos.toByteArray(); - assertEquals(5, data.length); + assertEquals(4, data.length); // 2) Create a single-point geography at lat=45°, lng=-64° S2Point pt = S2LatLng.fromDegrees(45, -64).toPoint(); @@ -60,85 +58,22 @@ public void testEncodeTag() throws IOException { geog2.encodeTagged(baos2, new EncodeOptions()); byte[] data2 = baos2.toByteArray(); // should be >4 bytes (header+payload) - assertTrue(data2.length > 5); + assertTrue(data2.length > 4); } @Test public void testEmptyPointEncodeDecode() 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) EncodeTagged - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - geog.encodeTagged(baos, new EncodeOptions()); - byte[] data = baos.toByteArray(); - - // 3) DecodeTagged - ByteArrayInputStream din = new ByteArrayInputStream(data); - // 3b) Now delegate to the dispatch method that takes (DataInputStream, EncodeTag) - S2Geography decoded = geog.decodeTagged(din); - - assertTrue(decoded instanceof PointGeography); - PointGeography round = (PointGeography) decoded; - assertTrue(round.getPoints().isEmpty()); - - // 4) region() should be an empty cap - S2Region region = round.region(); - assertTrue(region instanceof S2Cap); - assertTrue(((S2Cap) region).isEmpty()); + 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(); - System.out.println(pt.toString()); PointGeography geog = new PointGeography(pt); - assertEquals(1, geog.numShapes()); - assertEquals(0, geog.dimension()); - List originalPts = geog.getPoints(); - assertEquals(1, originalPts.size()); - assertEquals(pt, originalPts.get(0)); - - // 2) EncodeTagged - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - geog.encodeTagged(baos, new EncodeOptions()); - byte[] data = baos.toByteArray(); - - // 3) DecodeTagged - ByteArrayInputStream din = new ByteArrayInputStream(data); - PointGeography decoded = (PointGeography) geog.decodeTagged(din); - assertTrue(decoded instanceof PointGeography); - - // 4) Verify everything round-tripped - assertEquals(1, decoded.numShapes()); - assertEquals(0, decoded.dimension()); - // 4) Now round-trip the entire geography - System.out.println(decoded.getPoints().toString()); - - // It should still be a single‐point geography - assertEquals(1, decoded.getPoints().size()); - - // 1) Get the point and turn it into WKT with 6 decimal places: - S2Point p = ((PointGeography) decoded).getPoints().get(0); - S2LatLng ll = new S2LatLng(p); - String wkt = - String.format( - "POINT (%.6f %.6f)", - ll.lng().degrees(), // longitude first - ll.lat().degrees() // then latitude - ); - System.out.println(wkt); - assertEquals("POINT (-64.000000 45.000000)", wkt); - // 5) region() should contain exactly that point - S2Region region = decoded.region(); - // Single-point region can be represented as a tiny cap containing only pt - assertTrue(region.contains(pt)); + TestHelper.assertRoundTrip(geog, new EncodeOptions()); } @Test @@ -153,37 +88,7 @@ public void testEncodedSnappedPoint() throws IOException { PointGeography geog = new PointGeography(ptSnapped); EncodeOptions opts = new EncodeOptions(); opts.setCodingHint(EncodeOptions.CodingHint.COMPACT); - - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - geog.encodeTagged(baos, opts); - byte[] data = baos.toByteArray(); - - // Exactly 5-byte header + 8-byte cell-id - assertEquals(13, data.length); - - // 3) Peek at the tag + covering - Input in1 = new Input(data); - - // Read and decode the 4-byte header - EncodeTag tag = EncodeTag.decode(in1); - assertEquals(S2Geography.GeographyKind.CELL_CENTER, tag.getKind()); - assertEquals(1, tag.getCoveringSize() & 0xFF); - - // Read the single cell in the covering - List cover = new ArrayList<>(); - tag.decodeCovering(in1, cover); - assertEquals(1, cover.size()); - // Covering must contain the original unsnapped cell - assertEquals(cellId, cover.get(0)); - - // 4) Now round-trip the entire geography - PointGeography round = (PointGeography) geog.decodeTagged(new ByteArrayInputStream(data)); - System.out.println(round.getPoints().toString()); - - // It should still be a single‐point geography - assertEquals(1, round.getPoints().size()); - // And the point should be exactly the snapped cell‐center - assertEquals(ptSnapped, round.getPoints().get(0)); + TestHelper.assertRoundTrip(geog, opts); } @Test @@ -196,19 +101,82 @@ public void testEncodedListPoints() throws IOException { PointGeography geog = new PointGeography(List.of(pt1, pt2)); EncodeOptions opts = new EncodeOptions(); opts.setCodingHint(EncodeOptions.CodingHint.COMPACT); + TestHelper.assertRoundTrip(geog, opts); + } - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - geog.encodeTagged(baos, opts); - byte[] data = baos.toByteArray(); + @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); - // 3) Decode round-trip - PointGeography round = - (PointGeography) geog.decodeTagged(new UnsafeInput(new ByteArrayInputStream(data))); + // should write coveringSize == 0 + TestHelper.assertCovering(geo, opts); + } - // 4) Assert round-trip matches - assertEquals(2, round.getPoints().size()); - assertEquals(geog.getPoints(), round.getPoints()); - assertEquals(pt1, round.getPoints().get(0)); - assertEquals(pt2, round.getPoints().get(1)); + @Test + public void testSmallPointUnionCovering() throws IOException { + // fewer than 10 points: each point should produce one cell + List 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 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 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 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 index 0622bea0b22..4ac5c8e8c22 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/PolygonGeographyTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PolygonGeographyTest.java @@ -18,12 +18,7 @@ */ 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.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -35,9 +30,6 @@ 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(); - - // Prepare encoder output - ByteArrayOutputStream baos = new ByteArrayOutputStream(); // Build a single polygon and wrap in geography List points = new ArrayList<>(); points.add(pt); @@ -49,40 +41,6 @@ public void testEncodedPolygon() throws IOException { System.out.println(poly.toString()); PolygonGeography geo = new PolygonGeography(poly); - // Encode the geography with tagging - geo.encodeTagged(baos, new EncodeOptions()); - - // Decode from the bytes - byte[] encodedBytes = baos.toByteArray(); - ByteArrayInputStream dis = new ByteArrayInputStream(encodedBytes); - S2Geography roundtrip = geo.decodeTagged(dis); - - // Verify kind - assertEquals(S2Geography.GeographyKind.POLYGON, roundtrip.kind); - System.out.println(roundtrip.toString()); - // Extract polygon and build WKT string - // Extract decoded polygon - assertEquals(1, geo.getPolygons().size()); - - S2Polygon pl = geo.getPolygons().get(0); - // Reconstruct WKT from first loop - S2Loop loop = pl.loop(0); - StringBuilder sb = new StringBuilder("POLYGON (("); - for (int i = 0; i < loop.numVertices(); i++) { - if (i > 0) sb.append(", "); - S2LatLng ll = new S2LatLng(loop.vertex(i)); - sb.append(String.format("%.0f %.0f", ll.lng().degrees(), ll.lat().degrees())); - } - sb.append("))"); - // Build a simple triangle polygon: POLYGON ((-64 45, 0 45, 0 0, -64 45)) - String wkt = "POLYGON ((-64 45, 0 45, 0 0, -64 45))"; - assertEquals(wkt, sb.toString()); - assertTrue(roundtrip instanceof PolygonGeography); - PolygonGeography rtTyped = (PolygonGeography) roundtrip; - assertEquals(1, rtTyped.getPolygons().size()); - S2Polygon decodedPolygon = rtTyped.getPolygons().get(0); - - // Compare geometry equality - assertTrue(decodedPolygon.equals(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 index fa523fae1fa..98e9422b0e8 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java @@ -18,13 +18,9 @@ */ package org.apache.sedona.common.S2Geography; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - import com.google.common.geometry.S2LatLng; import com.google.common.geometry.S2Point; import com.google.common.geometry.S2Polyline; -import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.ArrayList; @@ -37,49 +33,13 @@ public void testEncodedPolyline() throws IOException { // Create two points S2Point ptStart = S2LatLng.fromDegrees(45, -64).toPoint(); S2Point ptEnd = S2LatLng.fromDegrees(0, 0).toPoint(); - - // Prepare encoder output - ByteArrayOutputStream baos = new ByteArrayOutputStream(); // Build a single polyline and wrap in geography List points = new ArrayList<>(); points.add(ptStart); points.add(ptEnd); S2Polyline polyline = new S2Polyline(points); PolylineGeography geog = new PolylineGeography(polyline); - - // Encode the geography with tagging - geog.encodeTagged(baos, new EncodeOptions()); - - // Decode from the bytes - byte[] encodedBytes = baos.toByteArray(); - ByteArrayInputStream dis = new ByteArrayInputStream(encodedBytes); - S2Geography roundtrip = geog.decodeTagged(dis); - - // Verify kind - assertEquals(S2Geography.GeographyKind.POLYLINE, roundtrip.kind); - System.out.println(roundtrip.toString()); - S2Polyline pl = (S2Polyline) roundtrip.shape(0); - StringBuilder sb = new StringBuilder("LINESTRING ("); - for (int i = 0; i < pl.numVertices(); i++) { - S2Point p = pl.vertex(i); - S2LatLng ll = new S2LatLng(p); - if (i > 0) sb.append(", "); - // WKT is “lon lat” - sb.append(String.format("%.0f %.0f", ll.lng().degrees(), ll.lat().degrees())); - } - sb.append(")"); - System.out.println(sb.toString()); - // Verify WKT representation (assuming toWkt() is implemented) - assertEquals("LINESTRING (-64 45, 0 0)", sb.toString()); - - // Downcast and inspect internal polylines - assertTrue(roundtrip instanceof PolylineGeography); - PolylineGeography rtTyped = (PolylineGeography) roundtrip; - assertEquals(1, rtTyped.getPolylines().size()); - S2Polyline decodedPolyline = rtTyped.getPolylines().get(0); - - // Compare geometry equality - assertTrue(decodedPolyline.equals(polyline)); + TestHelper.assertRoundTrip(geog, new EncodeOptions()); } @Test @@ -95,23 +55,7 @@ public void testEncodedMultiPolyline() throws IOException { // 2) Wrap both in a single geography PolylineGeography geog = new PolylineGeography(List.of(poly1, poly2)); - - // 3) Encode to bytes - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - geog.encodeTagged(baos, new EncodeOptions()); - - // 4) Decode back - ByteArrayInputStream dis = new ByteArrayInputStream(baos.toByteArray()); - S2Geography decoded = geog.decodeTagged(dis); - - // 5) Verify it’s a PolylineGeography with two members - assertTrue(decoded instanceof PolylineGeography); - PolylineGeography pg = (PolylineGeography) decoded; - assertEquals(2, pg.getPolylines().size()); - - // 6) Check each one matches the original - assertTrue(pg.getPolylines().get(0).equals(poly1)); - assertTrue(pg.getPolylines().get(1).equals(poly2)); + TestHelper.assertRoundTrip(geog, new EncodeOptions()); } @Test @@ -132,19 +76,6 @@ public void testEncodedMultiPolylineHint() throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); EncodeOptions encodeOptions = new EncodeOptions(); encodeOptions.setCodingHint(EncodeOptions.CodingHint.COMPACT); - geog.encodeTagged(baos, encodeOptions); - - // 4) Decode back - ByteArrayInputStream dis = new ByteArrayInputStream(baos.toByteArray()); - S2Geography decoded = geog.decodeTagged(dis); - - // 5) Verify it’s a PolylineGeography with two members - assertTrue(decoded instanceof PolylineGeography); - PolylineGeography pg = (PolylineGeography) decoded; - assertEquals(2, pg.getPolylines().size()); - - // 6) Check each one matches the original - assertTrue(pg.getPolylines().get(0).equals(poly1)); - assertTrue(pg.getPolylines().get(1).equals(poly2)); + TestHelper.assertRoundTrip(geog, new 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 00000000000..12ef6f159a2 --- /dev/null +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/TestHelper.java @@ -0,0 +1,155 @@ +/* + * 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 ptsOrig = ((PointGeography) original).getPoints(); + List 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 a = ((PolylineGeography) original).getPolylines(); + List 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) { + List a = ((PolygonGeography) original).getPolygons(); + List b = ((PolygonGeography) decoded).getPolygons(); + assertEquals("Polygon list size mismatch", a.size(), b.size()); + for (int i = 0; i < a.size(); i++) { + S2Polygon pgOrig = a.get(i); + S2Polygon pgDec = b.get(i); + assertEquals( + "Loop count mismatch in polygon[" + i + "]", pgOrig.numLoops(), pgDec.numLoops()); + for (int l = 0; l < pgOrig.numLoops(); l++) { + S2Loop loopOrig = pgOrig.loop(l); + S2Loop loopDec = pgDec.loop(l); + assertEquals( + "Vertex count mismatch in loop[" + l + "] of polygon[" + i + "]", + loopOrig.numVertices(), + loopDec.numVertices()); + for (int v = 0; v < loopOrig.numVertices(); v++) { + assertEquals( + "Vertex mismatch at polygon[" + i + "] loop[" + l + "] 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(); + } +} From 3eb362671e3f41317c9b8abd6fc934568e015152 Mon Sep 17 00:00:00 2001 From: Zhuocheng Shang <122398181+ZhuochengShang@users.noreply.github.com> Date: Thu, 26 Jun 2025 09:44:07 -0700 Subject: [PATCH 10/14] clean up code of encode and clarify comments --- common/pom.xml | 15 ++++----------- .../sedona/common/S2Geography/EncodeTag.java | 7 ++++--- .../sedona/common/S2Geography/PointGeography.java | 9 +++++---- .../sedona/common/S2Geography/S2Geography.java | 2 ++ .../common/S2Geography/PointGeographyTest.java | 1 - .../common/S2Geography/PolygonGeographyTest.java | 1 - 6 files changed, 15 insertions(+), 20 deletions(-) diff --git a/common/pom.xml b/common/pom.xml index da90411b44e..b5127e0e007 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -81,10 +81,9 @@ org.locationtech.spatial4j spatial4j - - - - + + + org.datasyslab s2-geometry-library @@ -114,12 +113,6 @@ edu.ucar cdm-core - - org.apache.commons - commons-collections4 - 4.4 - compile - src/main/java @@ -190,7 +183,7 @@ org.apache.sedona.shaded.codehaus - com.google.common.geometry; + com.google.common.geometry org.apache.sedona.shaded.s2 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 index 060b6e39c62..a139965372b 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/EncodeTag.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/EncodeTag.java @@ -37,8 +37,9 @@ public class EncodeTag { */ private GeographyKind kind = GeographyKind.UNINITIALIZED; /** - * Flags for encoding metadata. Currently, only {@code kFlagEmpty} is supported, which is set if - * and only if the geography contains zero shapes. + * 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 ——————————————————— @@ -72,7 +73,7 @@ public void encode(Output out) throws IOException { } // ——— Read it back ———————————————————————————————————————————————— - /** Reads exactly 5 bytes (in the same order) from the stream. */ + /** 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()); 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 index 6aa6ee154e4..b1192d775e0 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PointGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PointGeography.java @@ -125,10 +125,11 @@ public void encodeTagged(OutputStream os, EncodeOptions opts) throws IOException // Level 23 has a cell size of ~1 meter // (http://s2geometry.io/resources/s2cell_statistics) if (cid.level() >= 23) { - out.writeByte(GeographyKind.CELL_CENTER.getKind()); - out.writeByte(0); // POINT kind - out.writeByte(1); // flag - out.writeByte(0); // coveringSize + 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; diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java index 558c9b55e14..c60551ecf8b 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java @@ -198,6 +198,8 @@ public S2Geography decodeTagged(InputStream is) throws IOException { return PolylineGeography.decode(in, tag); case POLYGON: return PolygonGeography.decode(in, tag); + case GEOGRAPHY_COLLECTION: + return GeographyCollection.decode(in, tag); default: throw new IOException("Unsupported GeographyKind for decoding: " + tag.getKind()); } 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 index 8c0e0387c26..49173417e84 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/PointGeographyTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PointGeographyTest.java @@ -82,7 +82,6 @@ public void testEncodedSnappedPoint() throws IOException { S2Point pt = S2LatLng.fromDegrees(45, -64).toPoint(); S2CellId cellId = S2CellId.fromPoint(pt); S2Point ptSnapped = cellId.toPoint(); - System.out.println(ptSnapped.toString()); // 2) EncodeTagged in COMPACT mode PointGeography geog = new PointGeography(ptSnapped); 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 index 4ac5c8e8c22..5a1ace79765 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/PolygonGeographyTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PolygonGeographyTest.java @@ -38,7 +38,6 @@ public void testEncodedPolygon() throws IOException { points.add(pt); S2Loop polyline = new S2Loop(points); S2Polygon poly = new S2Polygon(polyline); - System.out.println(poly.toString()); PolygonGeography geo = new PolygonGeography(poly); TestHelper.assertRoundTrip(geo, new EncodeOptions()); From 1aec27f8cde13f4398748d009a426e07c5afeb85 Mon Sep 17 00:00:00 2001 From: Zhuocheng Shang <122398181+ZhuochengShang@users.noreply.github.com> Date: Thu, 26 Jun 2025 23:58:11 -0700 Subject: [PATCH 11/14] Update POLYGON to only take one polygon --- .../common/S2Geography/PointGeography.java | 2 +- .../common/S2Geography/PolygonGeography.java | 47 +++++++------------ .../common/S2Geography/PolylineGeography.java | 8 +++- .../S2Geography/PolylineGeographyTest.java | 1 - .../sedona/common/S2Geography/TestHelper.java | 38 +++++++-------- 5 files changed, 42 insertions(+), 54 deletions(-) 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 index b1192d775e0..17d7a3b53c8 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PointGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PointGeography.java @@ -35,7 +35,7 @@ public class PointGeography extends S2Geography { private static final int BUFFER_SIZE = 4 * 1024; - private final List points = new ArrayList<>(); + public final List points = new ArrayList<>(); /** Constructs an empty PointGeography. */ public PointGeography() { diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java index 58ac4991ce7..3581eda395c 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java @@ -21,7 +21,6 @@ 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.IOException; import java.util.ArrayList; @@ -32,42 +31,39 @@ public class PolygonGeography extends S2Geography { private static final Logger logger = Logger.getLogger(PolygonGeography.class.getName()); - private final List polygons; + public final S2Polygon polygon; public PolygonGeography() { super(GeographyKind.POLYGON); - this.polygons = new ArrayList<>(); + this.polygon = null; } public PolygonGeography(S2Polygon polygon) { super(GeographyKind.POLYGON); - this.polygons = new ArrayList<>(); - this.polygons.add(polygon); - } - - public PolygonGeography(List polygons) { - super(GeographyKind.POLYGON); - this.polygons = new ArrayList<>(polygons); + this.polygon = polygon; } @Override public int dimension() { - return polygons.isEmpty() ? -1 : 2; + return polygon == null ? -1 : 2; } @Override public int numShapes() { - return polygons.size(); + return polygon == null ? 0 : 1; } @Override public S2Shape shape(int id) { - return polygons.get(id).shape(); + assert polygon != null; + return polygon.shape(); } @Override public S2Region region() { - Collection regionCollection = new ArrayList<>(polygons); + S2RegionWrapper s2RegionWrapper = new S2RegionWrapper(polygon); + Collection regionCollection = new ArrayList<>(); + regionCollection.add(s2RegionWrapper); return new S2RegionUnion(regionCollection); } @@ -76,19 +72,12 @@ public void getCellUnionBound(List cellIds) { super.getCellUnionBound(cellIds); } - public List getPolygons() { - return ImmutableList.copyOf(polygons); - } - @Override public void encode(UnsafeOutput out, EncodeOptions opts) throws IOException { - // 3) Write number of polygons - out.writeInt(polygons.size()); - - // 4) Encode each polygon - for (S2Polygon poly : polygons) { - poly.encode(out); - } + // 3) Write number of polygon + out.writeInt(numShapes()); + // 4) Encode polygon + polygon.encode(out); out.flush(); } @@ -122,10 +111,10 @@ public static PolygonGeography decode(UnsafeInput in, EncodeTag tag) throws IOEx int count = in.readInt(); // Decode each polygon - for (int i = 0; i < count; i++) { - S2Polygon poly = S2Polygon.decode(in); - geo.polygons.add(poly); - } + + 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 index 49201c68672..12c06871c3a 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java @@ -33,7 +33,7 @@ public class PolylineGeography extends S2Geography { private static final Logger logger = Logger.getLogger(PolylineGeography.class.getName()); - private final List polylines; + public final List polylines; private static int sizeofInt() { return Integer.BYTES; @@ -73,7 +73,11 @@ public S2Shape shape(int id) { @Override public S2Region region() { Collection polylineRegionCollection = new ArrayList<>(); - polylineRegionCollection.addAll(polylines); + S2RegionWrapper s2RegionWrapper; + for (S2Polyline polyline : polylines) { + s2RegionWrapper = new S2RegionWrapper(polyline); + polylineRegionCollection.add(s2RegionWrapper); + } S2RegionUnion union = new S2RegionUnion(polylineRegionCollection); return union; } 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 index 98e9422b0e8..f30a99722e0 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java @@ -73,7 +73,6 @@ public void testEncodedMultiPolylineHint() throws IOException { PolylineGeography geog = new PolylineGeography(List.of(poly1, poly2)); // 3) Encode to bytes - ByteArrayOutputStream baos = new ByteArrayOutputStream(); EncodeOptions encodeOptions = new EncodeOptions(); encodeOptions.setCodingHint(EncodeOptions.CodingHint.COMPACT); TestHelper.assertRoundTrip(geog, new 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 index 12ef6f159a2..54495848592 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/TestHelper.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/TestHelper.java @@ -74,28 +74,24 @@ public static void assertRoundTrip(S2Geography original, EncodeOptions opts) thr } } else if (original instanceof PolygonGeography && decoded instanceof PolygonGeography) { - List a = ((PolygonGeography) original).getPolygons(); - List b = ((PolygonGeography) decoded).getPolygons(); - assertEquals("Polygon list size mismatch", a.size(), b.size()); - for (int i = 0; i < a.size(); i++) { - S2Polygon pgOrig = a.get(i); - S2Polygon pgDec = b.get(i); + 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( - "Loop count mismatch in polygon[" + i + "]", pgOrig.numLoops(), pgDec.numLoops()); - for (int l = 0; l < pgOrig.numLoops(); l++) { - S2Loop loopOrig = pgOrig.loop(l); - S2Loop loopDec = pgDec.loop(l); - assertEquals( - "Vertex count mismatch in loop[" + l + "] of polygon[" + i + "]", - loopOrig.numVertices(), - loopDec.numVertices()); - for (int v = 0; v < loopOrig.numVertices(); v++) { - assertEquals( - "Vertex mismatch at polygon[" + i + "] loop[" + l + "] vertex[" + v + "]", - loopOrig.vertex(v), - loopDec.vertex(v)); - } - } + "Vertex mismatch at polygon[" + 1 + "] loop[" + 1 + "] vertex[" + v + "]", + loopOrig.vertex(v), + loopDec.vertex(v)); } } } From a4ceb15159d50cfb3e059f94f64609973db393be Mon Sep 17 00:00:00 2001 From: Zhuocheng Shang <122398181+ZhuochengShang@users.noreply.github.com> Date: Fri, 27 Jun 2025 09:37:26 -0700 Subject: [PATCH 12/14] Remove S2Regionwrapper & S2Shapewrapper --- .../sedona/common/S2Geography/PolygonGeography.java | 9 +++------ .../sedona/common/S2Geography/PolylineGeography.java | 9 ++------- .../sedona/common/S2Geography/PolylineGeographyTest.java | 1 - 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java index 3581eda395c..bdbe5b43374 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java @@ -35,7 +35,7 @@ public class PolygonGeography extends S2Geography { public PolygonGeography() { super(GeographyKind.POLYGON); - this.polygon = null; + this.polygon = new S2Polygon(); } public PolygonGeography(S2Polygon polygon) { @@ -61,10 +61,7 @@ public S2Shape shape(int id) { @Override public S2Region region() { - S2RegionWrapper s2RegionWrapper = new S2RegionWrapper(polygon); - Collection regionCollection = new ArrayList<>(); - regionCollection.add(s2RegionWrapper); - return new S2RegionUnion(regionCollection); + return this.polygon; } @Override @@ -107,7 +104,7 @@ public static PolygonGeography decode(UnsafeInput in, EncodeTag tag) throws IOEx throw new IOException("PolygonGeography.decodeTagged error: insufficient header bytes"); } - // 5) Read the number of polylines (4-byte) + // 4) Read the number of polylines (4-byte) int count = in.readInt(); // Decode each polygon 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 index 12c06871c3a..7f654d344d5 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java @@ -73,13 +73,8 @@ public S2Shape shape(int id) { @Override public S2Region region() { Collection polylineRegionCollection = new ArrayList<>(); - S2RegionWrapper s2RegionWrapper; - for (S2Polyline polyline : polylines) { - s2RegionWrapper = new S2RegionWrapper(polyline); - polylineRegionCollection.add(s2RegionWrapper); - } - S2RegionUnion union = new S2RegionUnion(polylineRegionCollection); - return union; + polylineRegionCollection.addAll(polylines); + return new S2RegionUnion(polylineRegionCollection); } @Override 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 index f30a99722e0..22e70c41eaf 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java @@ -21,7 +21,6 @@ import com.google.common.geometry.S2LatLng; import com.google.common.geometry.S2Point; import com.google.common.geometry.S2Polyline; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.ArrayList; import java.util.List; From 511220cff1739a0b8e48870c39f553a541ba8791 Mon Sep 17 00:00:00 2001 From: Zhuocheng Shang <122398181+ZhuochengShang@users.noreply.github.com> Date: Fri, 27 Jun 2025 13:34:31 -0700 Subject: [PATCH 13/14] clean up minor issue --- .../common/S2Geography/PolygonGeography.java | 9 +++++---- .../common/S2Geography/PolylineGeography.java | 2 +- .../sedona/common/S2Geography/S2Geography.java | 15 ++++++++++----- .../common/S2Geography/PolylineGeographyTest.java | 2 +- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java index bdbe5b43374..fb8027a1b19 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java @@ -23,8 +23,6 @@ import com.esotericsoftware.kryo.io.UnsafeOutput; import com.google.common.geometry.*; import java.io.IOException; -import java.util.ArrayList; -import java.util.Collection; import java.util.List; import java.util.logging.Logger; @@ -35,7 +33,7 @@ public class PolygonGeography extends S2Geography { public PolygonGeography() { super(GeographyKind.POLYGON); - this.polygon = new S2Polygon(); + this.polygon = new S2Polygon(); } public PolygonGeography(S2Polygon polygon) { @@ -106,8 +104,11 @@ public static PolygonGeography decode(UnsafeInput in, EncodeTag tag) throws IOEx // 4) Read the number of polylines (4-byte) int count = in.readInt(); - // Decode each polygon + if (count != 1) { + throw new IOException( + "PolygonGeography.decode error: expected exactly one polygon, but found " + count); + } S2Polygon poly = S2Polygon.decode(in); geo = new PolygonGeography(poly); 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 index 7f654d344d5..ad8088222d9 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolylineGeography.java @@ -100,8 +100,8 @@ public void encode(UnsafeOutput out, EncodeOptions opts) throws IOException { } else { S2Polyline.COMPACT_CODER.encode(pl, out); } - out.flush(); } + out.flush(); } /** This is what decodeTagged() actually calls */ diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java index c60551ecf8b..274a041a541 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/S2Geography.java @@ -184,11 +184,14 @@ public void encodeTagged(OutputStream os, EncodeOptions opts) throws IOException } public S2Geography decodeTagged(InputStream is) throws IOException { - UnsafeInput in = new UnsafeInput(is, BUFFER_SIZE); - + // wrap ONCE + UnsafeInput kryoIn = new UnsafeInput(is, BUFFER_SIZE); + EncodeTag topTag = EncodeTag.decode(kryoIn); // 1) decode the tag - EncodeTag tag = EncodeTag.decode(in); + 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: @@ -198,8 +201,10 @@ public S2Geography decodeTagged(InputStream is) throws IOException { return PolylineGeography.decode(in, tag); case POLYGON: return PolygonGeography.decode(in, tag); - case GEOGRAPHY_COLLECTION: - return GeographyCollection.decode(in, tag); + // case GEOGRAPHY_COLLECTION: + // return GeographyCollection.decode(in, tag); + // case SHAPE_INDEX: + // return EncodedShapeIndexGeography.decode(in, tag); default: throw new IOException("Unsupported GeographyKind for decoding: " + tag.getKind()); } diff --git a/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java b/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java index 22e70c41eaf..3a3a1fea9a8 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/PolylineGeographyTest.java @@ -74,6 +74,6 @@ public void testEncodedMultiPolylineHint() throws IOException { // 3) Encode to bytes EncodeOptions encodeOptions = new EncodeOptions(); encodeOptions.setCodingHint(EncodeOptions.CodingHint.COMPACT); - TestHelper.assertRoundTrip(geog, new EncodeOptions()); + TestHelper.assertRoundTrip(geog, encodeOptions); } } From 42125f23f9e3bbfdbe9432d544d2553a5e85b9dd Mon Sep 17 00:00:00 2001 From: Zhuocheng Shang <122398181+ZhuochengShang@users.noreply.github.com> Date: Mon, 30 Jun 2025 22:23:58 -0700 Subject: [PATCH 14/14] resolve minor issue with PolygonGeography --- .../common/S2Geography/PolygonGeography.java | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java index fb8027a1b19..1927566c27c 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/PolygonGeography.java @@ -43,12 +43,12 @@ public PolygonGeography(S2Polygon polygon) { @Override public int dimension() { - return polygon == null ? -1 : 2; + return 2; } @Override public int numShapes() { - return polygon == null ? 0 : 1; + return polygon.isEmpty() ? 0 : 1; } @Override @@ -69,9 +69,7 @@ public void getCellUnionBound(List cellIds) { @Override public void encode(UnsafeOutput out, EncodeOptions opts) throws IOException { - // 3) Write number of polygon - out.writeInt(numShapes()); - // 4) Encode polygon + // Encode polygon polygon.encode(out); out.flush(); } @@ -97,19 +95,6 @@ public static PolygonGeography decode(UnsafeInput in, EncodeTag tag) throws IOEx // 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("PolygonGeography.decodeTagged error: insufficient header bytes"); - } - - // 4) Read the number of polylines (4-byte) - int count = in.readInt(); - // Decode each polygon - if (count != 1) { - throw new IOException( - "PolygonGeography.decode error: expected exactly one polygon, but found " + count); - } - S2Polygon poly = S2Polygon.decode(in); geo = new PolygonGeography(poly);