diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/WKBGeography.java b/common/src/main/java/org/apache/sedona/common/S2Geography/WKBGeography.java index b730ba52f69..8ef77d973ca 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/WKBGeography.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/WKBGeography.java @@ -341,11 +341,14 @@ public void getCellUnionBound(List cellIds) { @Override public String toString() { - return getS2Geography().toString(); + // WKB is the structural source of truth. Rendering the derived S2 view here would both + // canonicalize repeated vertices and apply Geography's default fixed-precision formatting. + return getJTSGeometry().toText(); } @Override public String toString(PrecisionModel precisionModel) { + // Keep the S2 writer available to callers that explicitly request precision-controlled text. return getS2Geography().toString(precisionModel); } @@ -356,9 +359,8 @@ public String toText(PrecisionModel precisionModel) { @Override public String toEWKT() { - Geography s2 = getS2Geography(); - s2.setSRID(getSRID()); - return s2.toEWKT(); + String wkt = toString(); + return getSRID() > 0 ? "SRID=" + getSRID() + "; " + wkt : wkt; } @Override diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/WKBReader.java b/common/src/main/java/org/apache/sedona/common/S2Geography/WKBReader.java index cc043caea2e..d68b3f6959f 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/WKBReader.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/WKBReader.java @@ -343,7 +343,15 @@ private PolygonGeography readPolygon(EnumSet ordinateFlags) for (int i = 0; i < seq.size(); i++) { pts.add(S2LatLng.fromDegrees(seq.getY(i), seq.getX(i)).toPoint()); } - loops.add(new S2Loop(pts)); + S2Loop loop = new S2Loop(pts); + // Match simple-features/SedonaDB semantics: ring position determines whether it is a shell + // or hole. Normalize only the operational S2 loop; WKBGeography retains the source bytes. + boolean isHole = r > 0; + boolean isClockwise = loop.getTurningAngle() < 0; + if (isHole != isClockwise) { + loop.invert(); + } + loops.add(loop); } if (loops.isEmpty()) { return new PolygonGeography(); diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/WKTReader.java b/common/src/main/java/org/apache/sedona/common/S2Geography/WKTReader.java index 1375af7e496..7499aa739ac 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/WKTReader.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/WKTReader.java @@ -821,14 +821,16 @@ private PolygonGeography readPolygonText( return new PolygonGeography(); } - List holes = new ArrayList(); + List loops = new ArrayList(); S2Loop shell = readLoopText(tokenizer, ordinateFlags); - holes.add(shell); + normalizePolygonLoop(shell, false); + loops.add(shell); nextToken = getNextCloserOrComma(tokenizer); while (nextToken.equals(COMMA)) { S2Loop hole = readLoopText(tokenizer, ordinateFlags); - holes.add(hole); + normalizePolygonLoop(hole, true); + loops.add(hole); nextToken = getNextCloserOrComma(tokenizer); } @@ -838,7 +840,7 @@ private PolygonGeography readPolygonText( builder.startLayer(polyLayer); // add shell + holes - for (S2Loop loop : holes) { + for (S2Loop loop : loops) { builder.addLoop(loop); } @@ -855,6 +857,18 @@ private PolygonGeography readPolygonText( return new PolygonGeography(s2poly); } + /** + * Normalizes an operational S2 loop using its simple-features ring role. Structural WKT callers + * use {@link org.apache.sedona.common.geography.Constructors}, which keeps the submitted + * coordinate order in WKBGeography; this reader produces only the derived S2 representation. + */ + private static void normalizePolygonLoop(S2Loop loop, boolean isHole) { + boolean isClockwise = loop.getTurningAngle() < 0; + if (isHole != isClockwise) { + loop.invert(); + } + } + /** * Creates a MultiLineString using the next token in the stream. * diff --git a/common/src/main/java/org/apache/sedona/common/S2Geography/WkbS2Shape.java b/common/src/main/java/org/apache/sedona/common/S2Geography/WkbS2Shape.java index 4fa108a5465..bcf0f92e273 100644 --- a/common/src/main/java/org/apache/sedona/common/S2Geography/WkbS2Shape.java +++ b/common/src/main/java/org/apache/sedona/common/S2Geography/WkbS2Shape.java @@ -21,11 +21,16 @@ import com.google.common.geometry.S2; import com.google.common.geometry.S2EdgeUtil; import com.google.common.geometry.S2LatLng; +import com.google.common.geometry.S2Loop; import com.google.common.geometry.S2Point; import com.google.common.geometry.S2Predicates; import com.google.common.geometry.S2Shape; +import com.google.common.geometry.S2ShapeMeasures; +import com.google.common.geometry.S2ShapeUtil; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; /** * An S2Shape implementation that reads WKB bytes once, converts all coordinates to S2Points in the @@ -48,11 +53,29 @@ public class WkbS2Shape implements S2Shape { private final int[] chainStarts; // edge offset for each chain private final int[] chainLengths; // edge count for each chain private final int[] vertexOffsets; // index into vertices[] for first vertex of each chain + // Polygon rings are normalized virtually: source WKB and vertices[] remain unchanged. + private final boolean[] chainReversed; // For polygon containsOrigin — computed eagerly at construction for polygons private final boolean containsOriginValue; public WkbS2Shape(byte[] wkb) { + this(wkb, true); + } + + /** + * Builds a shape whose polygon rings retain their WKB traversal direction. + * + *

This is an internal escape hatch for callers that intentionally encode the spherical + * interior through ring direction, such as raster footprints that cover more than a hemisphere. + * General Geography construction must use {@link #WkbS2Shape(byte[])}, which applies + * simple-features shell/hole semantics independent of input winding. + */ + public static WkbS2Shape withPreservedLoopOrientation(byte[] wkb) { + return new WkbS2Shape(wkb, false); + } + + private WkbS2Shape(byte[] wkb, boolean normalizePolygonRings) { boolean le = (wkb[0] == 0x01); ByteBuffer buf = ByteBuffer.wrap(wkb).order(le ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN); @@ -80,6 +103,7 @@ public WkbS2Shape(byte[] wkb) { this.chainStarts = new int[] {0}; this.chainLengths = new int[] {1}; this.vertexOffsets = new int[] {0}; + this.chainReversed = new boolean[] {false}; this.containsOriginValue = false; break; } @@ -93,6 +117,7 @@ public WkbS2Shape(byte[] wkb) { this.chainStarts = new int[] {0}; this.chainLengths = new int[] {totalEdges}; this.vertexOffsets = new int[] {0}; + this.chainReversed = new boolean[] {false}; this.containsOriginValue = false; break; } @@ -104,6 +129,7 @@ public WkbS2Shape(byte[] wkb) { this.chainStarts = new int[numRings]; this.chainLengths = new int[numRings]; this.vertexOffsets = new int[numRings]; + this.chainReversed = new boolean[numRings]; // First pass: count total vertices and compute offsets. Sedona's WKBWriter writes // open rings (n unique vertices, no closing duplicate); standard WKB writes closed @@ -149,7 +175,18 @@ public WkbS2Shape(byte[] wkb) { } } - // Eagerly compute containsOrigin from first ring + if (normalizePolygonRings) { + // Match SedonaDB's simple-features interpretation: the first ring is a shell and every + // subsequent ring is a hole regardless of input winding. Reverse only the S2-facing + // traversal, leaving the stored WKB and vertex order untouched. + for (int r = 0; r < numRings; r++) { + boolean isHole = r > 0; + boolean isClockwise = isClockwise(r); + chainReversed[r] = isHole != isClockwise; + } + } + + // Compute reference containment after any virtual ring reversal has been applied. this.containsOriginValue = computeContainsOrigin(); break; } @@ -176,9 +213,7 @@ public void getEdge(int edgeId, MutableEdge result) { // Find chain int chainId = findChain(edgeId); int offset = edgeId - chainStarts[chainId]; - int vi = vertexOffsets[chainId] + offset; - result.a = vertices[vi]; - result.b = vertices[vi + 1]; + getChainEdge(chainId, offset, result); } @Override @@ -213,9 +248,14 @@ public void getChainEdge(int chainId, int offset, MutableEdge result) { result.b = vertices[0]; return; } - int vi = vertexOffsets[chainId] + offset; - result.a = vertices[vi]; - result.b = vertices[vi + 1]; + int vi = vertexOffsets[chainId]; + if (chainReversed[chainId]) { + result.a = vertices[vi + chainLengths[chainId] - offset]; + result.b = vertices[vi + chainLengths[chainId] - offset - 1]; + } else { + result.a = vertices[vi + offset]; + result.b = vertices[vi + offset + 1]; + } } @Override @@ -226,7 +266,11 @@ public void getChainPosition(int edgeId, ChainPosition result) { @Override public S2Point getChainVertex(int chainId, int edgeOffset) { - return vertices[vertexOffsets[chainId] + edgeOffset]; + if (dim == 0) { + return vertices[0]; + } + int offset = chainReversed[chainId] ? chainLengths[chainId] - edgeOffset : edgeOffset; + return vertices[vertexOffsets[chainId] + offset]; } @Override @@ -268,40 +312,83 @@ private static S2Point[] readVertices(ByteBuffer buf, int byteOffset, int numCoo } /** - * Compute containsOrigin for polygon outer ring using direct edge-crossing test against - * S2.origin(). Same algorithm as S2Loop.initOriginAndBound() but without constructing an S2Loop - * (which builds its own internal S2ShapeIndex). + * Returns whether a polygon chain has negative S2 curvature (clockwise traversal). The fast path + * uses S2's robust turning-angle implementation through {@link S2ShapeMeasures}. Only a result + * within S2's documented turning-angle error bound of a half-sphere allocates an S2Loop to apply + * S2's hemisphere normalization convention. */ - private boolean computeContainsOrigin() { - int start = vertexOffsets[0]; - int numVerts = chainLengths[0]; // edges = verts - 1 for closed ring, but we use edge count + private boolean isClockwise(int chainId) { + if (chainLengths[chainId] < 3) { + return false; + } - if (numVerts < 3) return false; + // approxLoopArea() calls back into getChainVertex(). This method must run before assigning this + // chain's chainReversed entry so it measures the original WKB traversal. + assert !chainReversed[chainId] : "ring orientation must be measured before virtual reversal"; - // Same logic as S2Loop.initOriginAndBound(): - // 1. Guess originInside = false - // 2. Check if vertex(1) is inside via angle test - // 3. Check if contains(vertex(1)) matches — if not, flip originInside - S2Point v0 = vertices[start]; - S2Point v1 = vertices[start + 1]; - S2Point v2 = vertices[start + 2]; + double halfSphere = 2.0 * Math.PI; + double loopArea = S2ShapeMeasures.approxLoopArea(this, chainId); + double maxError = S2.getTurningAngleMaxError(chainLengths[chainId]); + if (Math.abs(loopArea - halfSphere) > maxError) { + return loopArea > halfSphere; + } - boolean v1Inside = - !v0.equalsPoint(v1) && !v2.equalsPoint(v1) && S2Predicates.angleContainsVertex(v0, v1, v2); + int length = chainLengths[chainId]; + List ring = new ArrayList<>(length); + int start = vertexOffsets[chainId]; + for (int i = 0; i < length; i++) { + ring.add(vertices[start + i]); + } + return !new S2Loop(ring).isNormalized(); + } - // Brute force contains(vertex(1)) with originInside = false - boolean originInside = false; + /** + * Computes S2.origin() containment. A single-ring polygon uses the same one-pass initialization + * as S2Loop; polygons with holes use a whole-shape reference point so every ring participates in + * the result. + */ + private boolean computeContainsOrigin() { + if (numChains() == 1) { + return computeSingleLoopContainsOrigin(0); + } + + ReferencePoint reference = S2ShapeUtil.getReferencePoint(this); S2Point origin = S2.origin(); - S2EdgeUtil.EdgeCrosser crosser = new S2EdgeUtil.EdgeCrosser(origin, v1, v0); - boolean inside = originInside; - for (int i = 1; i <= numVerts; i++) { - S2Point next = vertices[start + (i % numVerts)]; - inside ^= crosser.edgeOrVertexCrossing(next); + if (reference.equalsPoint(origin)) { + return reference.contained(); + } + + S2EdgeUtil.EdgeCrosser crosser = new S2EdgeUtil.EdgeCrosser(reference.point(), origin); + boolean inside = reference.contained(); + MutableEdge edge = new MutableEdge(); + for (int i = 0; i < numEdges(); i++) { + getEdge(i, edge); + inside ^= crosser.edgeOrVertexCrossing(edge.a, edge.b); } + return inside; + } + + /** + * Computes origin containment for one polygon chain using the initialization algorithm from + * S2Loop, without constructing an S2Loop or first finding another reference point. + */ + private boolean computeSingleLoopContainsOrigin(int chainId) { + int numVertices = chainLengths[chainId]; + if (numVertices < 3) { + return false; + } + + S2Point v0 = getChainVertex(chainId, 0); + S2Point v1 = getChainVertex(chainId, 1); + S2Point v2 = getChainVertex(chainId, 2); + boolean v1Inside = + !v0.equalsPoint(v1) && !v2.equalsPoint(v1) && S2Predicates.angleContainsVertex(v0, v1, v2); - if (v1Inside != inside) { - originInside = true; + S2EdgeUtil.EdgeCrosser crosser = new S2EdgeUtil.EdgeCrosser(S2.origin(), v1, v0); + boolean inside = false; + for (int i = 1; i <= numVertices; i++) { + inside ^= crosser.edgeOrVertexCrossing(getChainVertex(chainId, i)); } - return originInside; + return v1Inside != inside; } } diff --git a/common/src/main/java/org/apache/sedona/common/geography/Constructors.java b/common/src/main/java/org/apache/sedona/common/geography/Constructors.java index 8e60afa023e..d6fd9072787 100644 --- a/common/src/main/java/org/apache/sedona/common/geography/Constructors.java +++ b/common/src/main/java/org/apache/sedona/common/geography/Constructors.java @@ -320,10 +320,10 @@ public static Geography geomToGeography(Geometry geom) { if (geom == null) { return null; } - // Build S2 Geography first for proper spherical normalization (e.g., deduplication), - // then wrap in WKBGeography for WKB-based storage. - Geography s2geog = geomToS2Geography(geom); - return WKBGeography.fromS2Geography(s2geog); + // Keep the input WKB as the structural source of truth. S2 is derived lazily when a spherical + // operation needs it; converting through S2 here would alter coordinates, collapse repeated + // vertices, and fail to serialize empty polygons. + return WKBGeography.fromJTS(geom); } /** diff --git a/common/src/main/java/org/apache/sedona/common/geography/Functions.java b/common/src/main/java/org/apache/sedona/common/geography/Functions.java index d7453a59930..f495d86c723 100644 --- a/common/src/main/java/org/apache/sedona/common/geography/Functions.java +++ b/common/src/main/java/org/apache/sedona/common/geography/Functions.java @@ -25,13 +25,19 @@ import org.apache.sedona.common.sphere.Haversine; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.MultiPoint; import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.PrecisionModel; public class Functions { private static final double EPSILON = 1e-9; + // S2 expands conservative rectangle bounds by a few floating-point ulps. Snap only those tiny + // expansions back to a source ordinate; genuine great-circle extrema remain unchanged. + private static final double BOUND_ORDINATE_SNAP_TOLERANCE_DEGREES = 1e-12; private static boolean nearlyEqual(double a, double b) { if (Double.isNaN(a) || Double.isNaN(b)) { @@ -50,6 +56,7 @@ public static Geography getEnvelope(Geography geography, boolean splitAtAntiMeri && ((WKBGeography) geography).getPointX() == null) { return geography; } + Geometry sourceGeometry = toJTS(geography); S2LatLngRect rect = geography.region().getRectBound(); // Match the Geometry overload by preserving an empty input's type and SRID. if (rect.isEmpty()) return geography; @@ -57,28 +64,51 @@ public static Geography getEnvelope(Geography geography, boolean splitAtAntiMeri double latLo = rect.latLo().degrees(); double lngHi = rect.lngHi().degrees(); double latHi = rect.latHi().degrees(); + Coordinate[] sourceCoordinates = sourceGeometry.getCoordinates(); + lngLo = snapToSourceOrdinate(lngLo, sourceCoordinates, true); + latLo = snapToSourceOrdinate(latLo, sourceCoordinates, false); + lngHi = snapToSourceOrdinate(lngHi, sourceCoordinates, true); + latHi = snapToSourceOrdinate(latHi, sourceCoordinates, false); + GeometryFactory geometryFactory = + new GeometryFactory(new PrecisionModel(), geography.getSRID()); if (nearlyEqual(latLo, latHi) && nearlyEqual(lngLo, lngHi)) { - S2Point point = S2LatLng.fromDegrees(latLo, lngLo).toPoint(); - Geography pointGeo = new SinglePointGeography(point); - pointGeo.setSRID(geography.getSRID()); - return pointGeo; + if (sourceGeometry instanceof Point && !sourceGeometry.isEmpty()) { + Coordinate coordinate = sourceGeometry.getCoordinate(); + return WKBGeography.fromJTS( + geometryFactory.createPoint(new Coordinate(coordinate.x, coordinate.y))); + } + return WKBGeography.fromJTS(geometryFactory.createPoint(new Coordinate(lngLo, latLo))); } - Geography envelope; + Geometry envelope; if (splitAtAntiMeridian && rect.lng().isInverted()) { - S2Polygon left = rectToPolygon(lngLo, latLo, 180.0, latHi); - S2Polygon right = rectToPolygon(-180.0, latLo, lngHi, latHi); - envelope = - new MultiPolygonGeography(Geography.GeographyKind.MULTIPOLYGON, List.of(left, right)); + Polygon left = rectToPolygon(geometryFactory, lngLo, latLo, 180.0, latHi); + Polygon right = rectToPolygon(geometryFactory, -180.0, latLo, lngHi, latHi); + envelope = geometryFactory.createMultiPolygon(new Polygon[] {left, right}); } else { - envelope = new PolygonGeography(rectToPolygon(lngLo, latLo, lngHi, latHi)); + envelope = rectToPolygon(geometryFactory, lngLo, latLo, lngHi, latHi); + } + return WKBGeography.fromJTS(envelope); + } + + private static double snapToSourceOrdinate( + double bound, Coordinate[] sourceCoordinates, boolean longitude) { + double closest = bound; + double closestDifference = BOUND_ORDINATE_SNAP_TOLERANCE_DEGREES; + for (Coordinate coordinate : sourceCoordinates) { + double candidate = longitude ? coordinate.x : coordinate.y; + double difference = Math.abs(bound - candidate); + if (difference <= closestDifference) { + closest = candidate; + closestDifference = difference; + } } - envelope.setSRID(geography.getSRID()); - return envelope; + return closest; } - private static S2Polygon rectToPolygon(double lngLo, double latLo, double lngHi, double latHi) { + private static Polygon rectToPolygon( + GeometryFactory geometryFactory, double lngLo, double latLo, double lngHi, double latHi) { ArrayList v = new ArrayList<>(4); v.add(S2LatLng.fromDegrees(latLo, lngLo).toPoint()); v.add(S2LatLng.fromDegrees(latLo, lngHi).toPoint()); @@ -86,9 +116,29 @@ private static S2Polygon rectToPolygon(double lngLo, double latLo, double lngHi, v.add(S2LatLng.fromDegrees(latHi, lngLo).toPoint()); S2Loop loop = new S2Loop(v); - loop.normalize(); - - return new S2Polygon(loop); + Coordinate[] coordinates; + if (loop.isNormalized()) { + coordinates = + new Coordinate[] { + new Coordinate(lngLo, latLo), + new Coordinate(lngHi, latLo), + new Coordinate(lngHi, latHi), + new Coordinate(lngLo, latHi), + new Coordinate(lngLo, latLo) + }; + } else { + // S2Loop.normalize() reverses all four vertices when the rectangle covers more than a + // hemisphere. Mirror that ordering while retaining the exact rectangle ordinates in WKB. + coordinates = + new Coordinate[] { + new Coordinate(lngLo, latHi), + new Coordinate(lngHi, latHi), + new Coordinate(lngHi, latLo), + new Coordinate(lngLo, latLo), + new Coordinate(lngLo, latHi) + }; + } + return geometryFactory.createPolygon(coordinates); } // ─── Level 1: JTS-only structural operations ───────────────────────────── @@ -320,10 +370,9 @@ public static double area(Geography g) { if (g == null) return 0.0; Geography typed = (g instanceof WKBGeography) ? ((WKBGeography) g).getS2Geography() : g; double steradians = sphericalArea(typed); - // S2 polygons can be wound either CCW (interior is the small side) or CW (interior is the - // complement on the sphere). Some WKT inputs land in the latter form after parsing, which - // makes S2 report the entire sphere minus the visible polygon. Always return the smaller - // of the two regions so the answer is bounded by half the surface of the sphere. + // Public Geography readers normalize polygon shells to at most one hemisphere, but callers can + // still supply a directed S2 Geography whose interior is the complementary large region. + // Preserve ST_Area's small-side contract for both representations. if (steradians > 2.0 * Math.PI) { steradians = 4.0 * Math.PI - steradians; } @@ -498,7 +547,10 @@ public static Geography buffer(Geography g, double radiusMeters, String paramete Geometry buffered = org.apache.sedona.common.Functions.buffer(jts, radiusMeters, true, parameters); if (buffered == null) return null; - Geography result = Constructors.geomToGeography(buffered); + // JTS buffer shells are commonly wound for planar geometry semantics. Normalize them through + // S2 before storing the computed result so the geography keeps the same spherical interior it + // had before geomToGeography began preserving caller-provided WKB verbatim. + Geography result = WKBGeography.fromS2Geography(Constructors.geomToS2Geography(buffered)); result.setSRID(srid); return result; } diff --git a/common/src/main/java/org/apache/sedona/common/raster/RasterPredicates.java b/common/src/main/java/org/apache/sedona/common/raster/RasterPredicates.java index e0b9619e5be..c00ac7bdc50 100644 --- a/common/src/main/java/org/apache/sedona/common/raster/RasterPredicates.java +++ b/common/src/main/java/org/apache/sedona/common/raster/RasterPredicates.java @@ -18,10 +18,16 @@ */ package org.apache.sedona.common.raster; +import com.google.common.geometry.S1Angle; +import com.google.common.geometry.S2LatLng; +import com.google.common.geometry.S2Point; import java.util.Set; import org.apache.commons.lang3.tuple.Pair; import org.apache.sedona.common.FunctionsGeoTools; -import org.apache.sedona.common.S2Geography.WKBGeography; +import org.apache.sedona.common.S2Geography.Distance; +import org.apache.sedona.common.S2Geography.ShapeIndexGeography; +import org.apache.sedona.common.S2Geography.WkbS2Shape; +import org.apache.sedona.common.sphere.Haversine; import org.apache.sedona.common.utils.CachedCRSTransformFinder; import org.apache.sedona.common.utils.GeomUtils; import org.geotools.api.referencing.FactoryException; @@ -37,9 +43,13 @@ import org.geotools.referencing.crs.DefaultGeographicCRS; import org.locationtech.jts.algorithm.Orientation; import org.locationtech.jts.geom.Geometry; -import org.locationtech.jts.geom.GeometryFactory; -import org.locationtech.jts.geom.MultiPolygon; +import org.locationtech.jts.geom.GeometryCollection; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.io.ByteOrderValues; +import org.locationtech.jts.io.WKBWriter; public class RasterPredicates { /** @@ -110,85 +120,118 @@ public static boolean rsDWithin(GridCoverage2D left, GridCoverage2D right, doubl } /** - * Run {@link org.apache.sedona.common.geography.Functions#dWithin} on two WGS84 JTS geometries by - * wrapping them as {@link WKBGeography}. The Geography path uses S2's {@code ClosestEdgeQuery} - * for the minimum geodesic distance — overlap/touch returns 0 — so the threshold is interpreted - * strictly as "meters between any two points on the shapes." + * Run S2's {@code ClosestEdgeQuery} on two WGS84 JTS geometries, interpreting ring direction as + * the spherical interior. The result is the minimum geodesic distance — overlap/touch returns 0 — + * so the threshold is interpreted strictly as "meters between any two points on the shapes." * - *

This intentionally does not go through {@code Constructors.geomToGeography}: that helper - * builds S2 loops via {@code S2Loop.normalize()}, which always picks the smaller hemisphere as - * the polygon interior. Raster convex hulls (especially global mosaics, polar projections, and - * antimeridian-crossing UTM zones) can be larger than a hemisphere after WGS84 reprojection, so - * normalisation would collapse the intended footprint to a tiny region between the projected - * corners. The WKB path preserves the orientation we set in {@link #ensureCcwForS2}. + *

This intentionally uses a private directed-ring conversion rather than public Geography + * construction, where polygon ring positions have simple-features semantics independent of + * winding. Raster convex hulls (especially global mosaics, polar projections, and + * antimeridian-crossing UTM zones) can cover more than a hemisphere after WGS84 reprojection, so + * choosing the smaller spherical side would invert the intended footprint. The directed path + * preserves the orientation set by {@link #orientPolygonForS2} without changing Geography + * behavior elsewhere. */ private static boolean geographyDWithin(Geometry left, Geometry right, double distance) { - WKBGeography leftGeog = WKBGeography.fromJTS(toS2Ready(left)); - WKBGeography rightGeog = WKBGeography.fromJTS(toS2Ready(right)); - return org.apache.sedona.common.geography.Functions.dWithin(leftGeog, rightGeog, distance); + boolean leftIsPoint = left instanceof Point && !left.isEmpty(); + boolean rightIsPoint = right instanceof Point && !right.isEmpty(); + + double radians; + if (leftIsPoint && rightIsPoint) { + radians = new S1Angle(toS2Point((Point) left), toS2Point((Point) right)).radians(); + } else { + WKBWriter writer = new WKBWriter(2, ByteOrderValues.LITTLE_ENDIAN); + if (leftIsPoint) { + radians = + Distance.S2_distancePointToIndex( + toS2Point((Point) left), toDirectedShapeIndex(right, writer)); + } else if (rightIsPoint) { + radians = + Distance.S2_distancePointToIndex( + toS2Point((Point) right), toDirectedShapeIndex(left, writer)); + } else { + radians = + new Distance() + .S2_distance( + toDirectedShapeIndex(left, writer), toDirectedShapeIndex(right, writer)); + } + } + return radians * Haversine.AVG_EARTH_RADIUS <= distance; + } + + private static S2Point toS2Point(Point point) { + return S2LatLng.fromDegrees(point.getY(), point.getX()).toPoint(); } /** - * Produce a fresh JTS geometry ready for the S2-backed distance query: shell orientation forced - * to CCW (S2's expected interior side) and SRID stamped to 4326 (the caller has already - * reprojected to WGS84 via {@link #toWGS84Pair}). Returns a copy whenever the caller-owned - * geometry would otherwise be mutated, so this helper is side-effect-free with respect to its - * input — important because {@link #rsDWithin} is a public predicate that should not modify - * geometries handed to it. + * Builds a temporary ShapeIndex whose polygon chains retain the direction supplied by the JTS + * geometry. Multi-geometries are decomposed into their simple components so the directed behavior + * never leaks into the general Geography WKB reader. */ - private static Geometry toS2Ready(Geometry geom) { - Geometry oriented = ensureCcwForS2(geom); - if (oriented == geom) { - // ensureCcwForS2 returned the input unchanged (already CCW or non-polygon); clone before - // touching SRID so we don't write back into the caller's object. - oriented = geom.copy(); + private static ShapeIndexGeography toDirectedShapeIndex(Geometry geometry, WKBWriter writer) { + ShapeIndexGeography result = new ShapeIndexGeography(); + addDirectedShapes(geometry, writer, result); + return result; + } + + private static void addDirectedShapes( + Geometry geometry, WKBWriter writer, ShapeIndexGeography result) { + if (geometry.isEmpty()) { + return; } - // S2 treats coordinates as lat/lng regardless of SRID metadata, but we tag the geography as - // EPSG:4326 since both inputs are guaranteed to be in WGS84 here. JTS.transform does not - // propagate SRID, so we set it explicitly on the (now-owned) copy. - oriented.setSRID(4326); - return oriented; + if (geometry instanceof Polygon) { + Polygon oriented = orientPolygonForS2((Polygon) geometry); + result.shapeIndex.add(WkbS2Shape.withPreservedLoopOrientation(writer.write(oriented))); + return; + } + if (geometry instanceof Point || geometry instanceof LineString) { + result.shapeIndex.add(WkbS2Shape.withPreservedLoopOrientation(writer.write(geometry))); + return; + } + if (geometry instanceof GeometryCollection) { + for (int i = 0; i < geometry.getNumGeometries(); i++) { + addDirectedShapes(geometry.getGeometryN(i), writer, result); + } + return; + } + throw new IllegalArgumentException( + "Unsupported JTS geometry for raster distance: " + geometry.getGeometryType()); } /** - * Return {@code geom} with every polygon shell oriented CCW (S2's expected orientation). - * Non-polygon geometries are returned unchanged. For MultiPolygons each component polygon is - * checked and reversed independently — JTS/OGC does not require consistent ring orientation - * across the components of a user-supplied MultiPolygon, so flipping the whole geometry based on - * the first shell would mis-orient any later polygon with the opposite winding. Empty polygons - * and {@code MULTIPOLYGON EMPTY} pass through untouched. + * Returns a polygon whose shell traverses CCW and whose holes traverse CW, as required by the + * directed S2 shape. Ring position remains authoritative even when the JTS input uses arbitrary + * winding. The input polygon is returned unchanged when every ring is already oriented correctly. */ - private static Geometry ensureCcwForS2(Geometry geom) { - if (geom instanceof Polygon) { - Polygon p = (Polygon) geom; - if (p.isEmpty() || Orientation.isCCW(p.getExteriorRing().getCoordinates())) { - return geom; - } - return geom.reverse(); + private static Polygon orientPolygonForS2(Polygon polygon) { + if (polygon.isEmpty()) { + return polygon; } - if (geom instanceof MultiPolygon) { - int n = geom.getNumGeometries(); - if (n == 0) { - return geom; - } - Polygon[] reoriented = new Polygon[n]; - boolean anyReversal = false; - for (int i = 0; i < n; i++) { - Polygon p = (Polygon) geom.getGeometryN(i); - if (p.isEmpty() || Orientation.isCCW(p.getExteriorRing().getCoordinates())) { - reoriented[i] = p; - } else { - reoriented[i] = (Polygon) p.reverse(); - anyReversal = true; - } - } - if (!anyReversal) { - return geom; + + LinearRing shell = (LinearRing) polygon.getExteriorRing(); + boolean changed = false; + if (!Orientation.isCCW(shell.getCoordinates())) { + shell = (LinearRing) shell.reverse(); + changed = true; + } + + int numHoles = polygon.getNumInteriorRing(); + LinearRing[] holes = new LinearRing[numHoles]; + for (int i = 0; i < numHoles; i++) { + LinearRing hole = (LinearRing) polygon.getInteriorRingN(i); + if (Orientation.isCCW(hole.getCoordinates())) { + hole = (LinearRing) hole.reverse(); + changed = true; } - GeometryFactory factory = geom.getFactory(); - return factory.createMultiPolygon(reoriented); + holes[i] = hole; + } + if (!changed) { + return polygon; } - return geom; + + Polygon oriented = polygon.getFactory().createPolygon(shell, holes); + oriented.setSRID(polygon.getSRID()); + return oriented; } private static Pair toWGS84Pair(GridCoverage2D raster, Geometry queryWindow) { diff --git a/common/src/main/java/org/apache/sedona/common/utils/GeoHashDecoder.java b/common/src/main/java/org/apache/sedona/common/utils/GeoHashDecoder.java index 9842038c310..409ae7f35df 100644 --- a/common/src/main/java/org/apache/sedona/common/utils/GeoHashDecoder.java +++ b/common/src/main/java/org/apache/sedona/common/utils/GeoHashDecoder.java @@ -18,12 +18,13 @@ */ package org.apache.sedona.common.utils; -import com.google.common.geometry.*; import java.io.IOException; -import java.util.List; import org.apache.sedona.common.S2Geography.Geography; -import org.apache.sedona.common.S2Geography.PolygonGeography; +import org.apache.sedona.common.S2Geography.WKBGeography; +import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.PrecisionModel; public class GeoHashDecoder { private static final int[] bits = GeoHashUtils.BITS; @@ -41,23 +42,18 @@ public static Geometry decode(String geohash, Integer precision) throws InvalidG public static Geography decodeGeog(String geohash, Integer precision) throws InvalidGeoHashException, IOException { - BBox box = decodeGeoHashBBox(geohash, precision).getBbox(); // west,east,south,north - double south = box.startLat, north = box.endLat; - double west = box.startLon, east = box.endLon; - - // 4 corners (lat, lon) in CCW order: SW → SE → NE → NW - S2Point SW = S2LatLng.fromDegrees(south, west).toPoint(); - S2Point SE = S2LatLng.fromDegrees(south, east).toPoint(); - S2Point NE = S2LatLng.fromDegrees(north, east).toPoint(); - S2Point NW = S2LatLng.fromDegrees(north, west).toPoint(); - - S2Loop shell = new S2Loop(List.of(SW, SE, NE, NW)); - shell.normalize(); // ensure CCW & smaller-area orientation (safe even if already CCW) - - S2Polygon s2poly = new S2Polygon(shell); - Geography geog = new PolygonGeography(s2poly); - geog.setSRID(4326); - return geog; + BBox box = decodeGeoHashBBox(geohash, precision).getBbox(); + GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326); + Geometry geometry = + geometryFactory.createPolygon( + new Coordinate[] { + new Coordinate(box.startLon, box.startLat), + new Coordinate(box.endLon, box.startLat), + new Coordinate(box.endLon, box.endLat), + new Coordinate(box.startLon, box.endLat), + new Coordinate(box.startLon, box.startLat) + }); + return WKBGeography.fromJTS(geometry); } private static class LatLon { diff --git a/common/src/test/java/org/apache/sedona/common/Geography/ConstructorsTest.java b/common/src/test/java/org/apache/sedona/common/Geography/ConstructorsTest.java index c454daca8e9..1284b36260d 100644 --- a/common/src/test/java/org/apache/sedona/common/Geography/ConstructorsTest.java +++ b/common/src/test/java/org/apache/sedona/common/Geography/ConstructorsTest.java @@ -46,15 +46,15 @@ public void geogFromEWKT() throws ParseException { Geography geog = Constructors.geogFromEWKT("POINT (1 1)"); assertEquals(0, geog.getSRID()); - assertEquals("POINT (1 1)", geog.toString()); + assertEquals("POINT (1 1)", geog.toString(new PrecisionModel(PrecisionModel.FIXED))); geog = Constructors.geogFromEWKT("SRID=4269; POINT (1 1)"); assertEquals(4269, geog.getSRID()); - assertEquals("SRID=4269; POINT (1 1)", geog.toEWKT()); + assertEquals("SRID=4269; POINT (1 1)", geog.toEWKT(new PrecisionModel(PrecisionModel.FIXED))); geog = Constructors.geogFromEWKT("SRID=4269;POINT (1 1)"); assertEquals(4269, geog.getSRID()); - assertEquals("SRID=4269; POINT (1 1)", geog.toEWKT()); + assertEquals("SRID=4269; POINT (1 1)", geog.toEWKT(new PrecisionModel(PrecisionModel.FIXED))); ParseException invalid = assertThrows(ParseException.class, () -> Constructors.geogFromEWKT("not valid")); @@ -219,7 +219,10 @@ public void deep_nesting_twoComponents() throws Exception { org.locationtech.jts.io.WKTWriter wktWriter = new org.locationtech.jts.io.WKTWriter(); wktWriter.setPrecisionModel(new PrecisionModel(PrecisionModel.FIXED)); String gotGeom = wktWriter.write(got); - assertEquals(expected, gotGeom); + // S2 loops have no distinguished first vertex, so conversion may cyclically rotate a ring. + assertPolygonRingsEqual( + new org.locationtech.jts.io.WKTReader().read(expected), + new org.locationtech.jts.io.WKTReader().read(gotGeom)); } @Test @@ -244,7 +247,8 @@ public void polygon_threeHoles() throws Exception { Geometry got = Constructors.geogToGeometry( g, new GeometryFactory(new PrecisionModel(PrecisionModel.FIXED))); - assertEquals(expected, got.toString()); + // S2 loops have no distinguished first vertex, so conversion may cyclically rotate a ring. + assertPolygonRingsEqual(new org.locationtech.jts.io.WKTReader().read(expected), got); assertEquals(0, got.getSRID()); } @@ -266,8 +270,6 @@ public void MultiPolygonGeomToGeography() throws Exception { Geography got = Constructors.geomToGeography(g); String expected = "SRID=4326; " + wkt; assertEquals(4326, got.getSRID()); - org.locationtech.jts.io.WKTWriter wktWriter = new org.locationtech.jts.io.WKTWriter(); - wktWriter.setPrecisionModel(new PrecisionModel(PrecisionModel.FIXED)); assertEquals(expected, got.toEWKT()); } @@ -277,13 +279,29 @@ public void PointGeomToGeography() throws Exception { Geography got = Constructors.geomToGeography(geom); org.locationtech.jts.io.WKTWriter wktWriter = new org.locationtech.jts.io.WKTWriter(); wktWriter.setPrecisionModel(new PrecisionModel(PrecisionModel.FIXED)); - assertEquals(geom.toString(), got.toString()); + assertEquals(geom.toString(), got.toString(new PrecisionModel(PrecisionModel.FIXED))); geom = org.apache.sedona.common.Constructors.geomFromWKT( "MULTIPOINT ((10 10), (20 20), (30 30))", 0); got = Constructors.geomToGeography(geom); - assertEquals(geom.toString(), got.toString()); + assertEquals(geom.toString(), got.toString(new PrecisionModel(PrecisionModel.FIXED))); + } + + @Test + public void geomToGeographyPreservesExactWKBAndEmptyPolygon() throws Exception { + Geometry point = org.apache.sedona.common.Constructors.geomFromWKT("POINT (1 2)", 4326); + Geography pointGeography = Constructors.geomToGeography(point); + assertTrue(pointGeography instanceof WKBGeography); + assertEquals("POINT (1 2)", pointGeography.toString()); + assertEquals("SRID=4326; POINT (1 2)", pointGeography.toEWKT()); + + Geometry emptyPolygon = + org.apache.sedona.common.Constructors.geomFromWKT("POLYGON EMPTY", 4326); + Geography emptyGeography = Constructors.geomToGeography(emptyPolygon); + assertTrue(emptyGeography instanceof WKBGeography); + assertEquals("POLYGON EMPTY", emptyGeography.toString()); + assertEquals("SRID=4326; POLYGON EMPTY", emptyGeography.toEWKT()); } @Test @@ -292,15 +310,13 @@ public void PointGeomToGeographyDuplicate() throws Exception { org.apache.sedona.common.Constructors.geomFromWKT( "MULTIPOINT ((10 10), (20 20), (20 20), (30 30))", 0); Geography got = Constructors.geomToGeography(geom); - org.locationtech.jts.io.WKTWriter wktWriter = new org.locationtech.jts.io.WKTWriter(); - wktWriter.setPrecisionModel(new PrecisionModel(PrecisionModel.FIXED)); - assertEquals("MULTIPOINT ((10 10), (20 20), (30 30))", got.toString()); + assertEquals(geom.toText(), got.toString()); geom = org.apache.sedona.common.Constructors.geomFromWKT( "MULTIPOINT ((10 10), (20 20), (30 30), (20 20), (10 10))", 0); got = Constructors.geomToGeography(geom); - assertEquals("MULTIPOINT ((10 10), (20 20), (30 30))", got.toString()); + assertEquals(geom.toText(), got.toString()); } @Test @@ -310,13 +326,13 @@ public void LineGeomToGeography() throws Exception { Geography got = Constructors.geomToGeography(geom); org.locationtech.jts.io.WKTWriter wktWriter = new org.locationtech.jts.io.WKTWriter(); wktWriter.setPrecisionModel(new PrecisionModel(PrecisionModel.FIXED)); - assertEquals(geom.toString(), got.toString()); + assertEquals(geom.toString(), got.toString(new PrecisionModel(PrecisionModel.FIXED))); geom = org.apache.sedona.common.Constructors.geomFromWKT( "MULTILINESTRING((1 2, 3 4), (4 5, 6 7))", 0); got = Constructors.geomToGeography(geom); - assertEquals(geom.toString(), got.toString()); + assertEquals(geom.toString(), got.toString(new PrecisionModel(PrecisionModel.FIXED))); } @Test @@ -324,21 +340,19 @@ public void LineGeomToGeographyDuplicate() throws Exception { Geometry geom = org.apache.sedona.common.Constructors.geomFromWKT("LINESTRING (1 2, 3 4, 3 4, 5 6)", 0); Geography got = Constructors.geomToGeography(geom); - org.locationtech.jts.io.WKTWriter wktWriter = new org.locationtech.jts.io.WKTWriter(); - wktWriter.setPrecisionModel(new PrecisionModel(PrecisionModel.FIXED)); - assertEquals("LINESTRING (1 2, 3 4, 5 6)", got.toString()); + assertEquals(geom.toText(), got.toString()); geom = org.apache.sedona.common.Constructors.geomFromWKT( "MULTILINESTRING ((1 2, 3 4), (4 5, 6 7), (1 2, 3 4))", 0); got = Constructors.geomToGeography(geom); - assertEquals("MULTILINESTRING ((1 2, 3 4), (4 5, 6 7))", got.toString()); + assertEquals(geom.toText(), got.toString()); geom = org.apache.sedona.common.Constructors.geomFromWKT( "MULTILINESTRING ((1 2, 3 4), EMPTY, (4 5, 6 7), (1 2, 3 4))", 0); got = Constructors.geomToGeography(geom); - assertEquals("MULTILINESTRING ((1 2, 3 4), (4 5, 6 7))", got.toString()); + assertEquals(geom.toText(), got.toString()); } @Test @@ -350,6 +364,53 @@ public void CollGeomToGeography() throws Exception { Geography got = Constructors.geomToGeography(geom); org.locationtech.jts.io.WKTWriter wktWriter = new org.locationtech.jts.io.WKTWriter(); wktWriter.setPrecisionModel(new PrecisionModel(PrecisionModel.FIXED)); - assertEquals(geom.toString(), got.toString()); + assertEquals(geom.toString(), got.toString(new PrecisionModel(PrecisionModel.FIXED))); + } + + private static void assertPolygonRingsEqual(Geometry expected, Geometry actual) { + assertEquals(expected.getGeometryType(), actual.getGeometryType()); + assertEquals(expected.getNumGeometries(), actual.getNumGeometries()); + for (int i = 0; i < expected.getNumGeometries(); i++) { + org.locationtech.jts.geom.Polygon expectedPolygon = + (org.locationtech.jts.geom.Polygon) expected.getGeometryN(i); + org.locationtech.jts.geom.Polygon actualPolygon = + (org.locationtech.jts.geom.Polygon) actual.getGeometryN(i); + assertRingEqualUpToRotation( + expectedPolygon.getExteriorRing().getCoordinates(), + actualPolygon.getExteriorRing().getCoordinates()); + assertEquals(expectedPolygon.getNumInteriorRing(), actualPolygon.getNumInteriorRing()); + for (int j = 0; j < expectedPolygon.getNumInteriorRing(); j++) { + assertRingEqualUpToRotation( + expectedPolygon.getInteriorRingN(j).getCoordinates(), + actualPolygon.getInteriorRingN(j).getCoordinates()); + } + } + } + + private static void assertRingEqualUpToRotation( + org.locationtech.jts.geom.Coordinate[] expected, + org.locationtech.jts.geom.Coordinate[] actual) { + assertEquals(expected.length, actual.length); + int numVertices = expected.length - 1; + for (int offset = 0; offset < numVertices; offset++) { + boolean matches = true; + for (int i = 0; i < numVertices; i++) { + org.locationtech.jts.geom.Coordinate expectedCoordinate = expected[i]; + org.locationtech.jts.geom.Coordinate actualCoordinate = actual[(offset + i) % numVertices]; + if (Math.abs(expectedCoordinate.x - actualCoordinate.x) > 1e-12 + || Math.abs(expectedCoordinate.y - actualCoordinate.y) > 1e-12) { + matches = false; + break; + } + } + if (matches) { + return; + } + } + fail( + "Expected a cyclic rotation of " + + java.util.Arrays.toString(expected) + + " but got " + + java.util.Arrays.toString(actual)); } } diff --git a/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java b/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java index 8982efe16ff..c6019b7be1b 100644 --- a/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java +++ b/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java @@ -34,6 +34,7 @@ import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.PrecisionModel; import org.locationtech.jts.io.ParseException; import org.locationtech.jts.io.WKTReader; @@ -77,7 +78,8 @@ private static void assertRectLoopVertices( public void envelope_noSplit_antimeridian() throws Exception { String wkt = "MULTIPOINT ((-179 0), (179 1), (-180 10))"; Geography g = Constructors.geogFromWKT(wkt, 4326); - PolygonGeography env = (PolygonGeography) Functions.getEnvelope(g, false); + Geography env = Functions.getEnvelope(g, false); + assertTrue(env instanceof WKBGeography); S2LatLngRect r = g.region().getRectBound(); assertTrue(r.lng().isInverted()); @@ -86,7 +88,8 @@ public void envelope_noSplit_antimeridian() throws Exception { assertDegAlmostEqual(r.lngLo().degrees(), 179.0); assertDegAlmostEqual(r.lngHi().degrees(), -179.0); - S2Loop loop = env.polygon.getLoops().get(0); + PolygonGeography s2Envelope = (PolygonGeography) ((WKBGeography) env).getS2Geography(); + S2Loop loop = s2Envelope.polygon.getLoops().get(0); assertRectLoopVertices(loop, 0, 179, 10, -179); } @@ -97,7 +100,7 @@ public void envelope_netherlands_perVertex() throws Exception { Geography g = Constructors.geogFromWKT(nl, 4326); Geography env = Functions.getEnvelope(g, true); String expectedWKT = "POLYGON ((3.3 50.8, 7.1 50.8, 7.1 53.5, 3.3 53.5, 3.3 50.8))"; - assertEquals(expectedWKT, env.toString()); + assertEquals(expectedWKT, env.toString(new PrecisionModel(PrecisionModel.FIXED))); assertEquals(4326, env.getSRID()); } @@ -113,12 +116,12 @@ public void envelope_fiji_split_perVertex() throws Exception { String expectedWKT = "MULTIPOLYGON (((177.3 -18.3, 180 -18.3, 180 -16, 177.3 -16, 177.3 -18.3)), " + "((-180 -18.3, -179.8 -18.3, -179.8 -16, -180 -16, -180 -18.3)))"; - assertEquals(expectedWKT, env.toString()); + assertEquals(expectedWKT, env.toString(new PrecisionModel(PrecisionModel.FIXED))); String expectedWKT2 = "POLYGON ((177.3 -18.3, -179.8 -18.3, -179.8 -16, 177.3 -16, 177.3 -18.3))"; env = Functions.getEnvelope(g, false); - assertEquals(expectedWKT2, env.toString()); + assertEquals(expectedWKT2, env.toString(new PrecisionModel(PrecisionModel.FIXED))); } @Test @@ -126,7 +129,23 @@ public void getEnvelopePoint() throws ParseException { String wkt = "POINT (-180 10)"; Geography geography = Constructors.geogFromWKT(wkt, 0); Geography envelope = Functions.getEnvelope(geography, false); - assertEquals("POINT (180 10)", envelope.toString()); + assertEquals("POINT (-180 10)", envelope.toString()); + } + + @Test + public void envelope_preservesExactEndpointBoundsAcrossWKBRoundTrip() throws ParseException { + Geography line = Constructors.geogFromWKT("LINESTRING (0 0, 2 3)", 4326); + Geography envelope = Functions.getEnvelope(line, false); + assertEquals("SRID=4326; POLYGON ((0 0, 2 0, 2 3, 0 3, 0 0))", envelope.toEWKT()); + assertEquals(envelope.toEWKT(), roundTripWKB(envelope).toEWKT()); + + Geography antiMeridianLine = Constructors.geogFromWKT("LINESTRING (170 10, -170 20)", 4326); + Geography splitEnvelope = Functions.getEnvelope(antiMeridianLine, true); + assertEquals( + "SRID=4326; MULTIPOLYGON (((170 10, 180 10, 180 20, 170 20, 170 10)), " + + "((-180 10, -170 10, -170 20, -180 20, -180 10)))", + splitEnvelope.toEWKT()); + assertEquals(splitEnvelope.toEWKT(), roundTripWKB(splitEnvelope).toEWKT()); } @Test @@ -157,7 +176,7 @@ public void testEnvelopeWKTCompare() throws Exception { Geography env = Functions.getEnvelope(g, true); String expectedWKT = "POLYGON ((-180 -63.3, 180 -63.3, 180 -90, -180 -90, -180 -63.3))"; - assertEquals((expectedWKT), (env.toString())); + assertEquals(expectedWKT, env.toString(new PrecisionModel(PrecisionModel.FIXED))); String multiCountry = "MULTIPOLYGON (((-180 -90, -180 -63.27066, 180 -63.27066, 180 -90, -180 -90))," @@ -166,7 +185,7 @@ public void testEnvelopeWKTCompare() throws Exception { env = Functions.getEnvelope(g, true); String expectedWKT2 = "POLYGON ((-180 53.5, 180 53.5, 180 -90, -180 -90, -180 53.5))"; - assertEquals((expectedWKT2), (env.toString())); + assertEquals(expectedWKT2, env.toString(new PrecisionModel(PrecisionModel.FIXED))); } // ─── Level 1: ST_NPoints ───────────────────────────────────────────────── 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 fd6fcfda684..1be1e226ee0 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 @@ -279,15 +279,38 @@ private static void checkPolygonShape(PolygonGeography poly1, PolygonGeography p assertEquals( String.format("Vertex count in loop[%d]", li), loop1.numVertices(), loop2.numVertices()); - // compare each vertex + // A closed S2Loop has no distinguished first vertex. Readers and builders may preserve the + // same directed loop while cyclically rotating its stored vertex sequence. + int rotation = findLoopRotation(loop1, loop2); + assertTrue(String.format("No matching rotation for loop[%d]", li), rotation >= 0); for (int vi = 0; vi < loop1.numVertices(); vi++) { S2Point v1 = loop1.vertex(vi); - S2Point v2 = loop2.vertex(vi); + S2Point v2 = loop2.vertex((rotation + vi) % loop2.numVertices()); assertPointEquals(v1, v2); } } } + private static int findLoopRotation(S2Loop expected, S2Loop actual) { + int size = expected.numVertices(); + if (size == 0) { + return 0; + } + for (int rotation = 0; rotation < size; rotation++) { + boolean matches = true; + for (int i = 0; i < size; i++) { + if (expected.vertex(i).angle(actual.vertex((rotation + i) % size)) > Math.toRadians(EPS)) { + matches = false; + break; + } + } + if (matches) { + return rotation; + } + } + return -1; + } + private static void assertPointEquals(S2Point p1, S2Point p2) { S2LatLng ll1 = S2LatLng.fromPoint(p1); S2LatLng ll2 = S2LatLng.fromPoint(p2); diff --git a/common/src/test/java/org/apache/sedona/common/S2Geography/WKBGeographyTest.java b/common/src/test/java/org/apache/sedona/common/S2Geography/WKBGeographyTest.java index f759d978afe..43d012cd0b6 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/WKBGeographyTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/WKBGeographyTest.java @@ -197,6 +197,40 @@ public void toEWKT_works() throws ParseException { assertEquals("SRID=4326; POINT (1 1)", geog.toEWKT()); } + @Test + public void defaultTextRendering_preservesStoredCoordinatesAndSRID() throws ParseException { + org.locationtech.jts.io.WKTReader jtsReader = new org.locationtech.jts.io.WKTReader(); + Geometry jts = jtsReader.read("POINT (-122.4194 37.7749)"); + byte[] wkb = new org.locationtech.jts.io.WKBWriter().write(jts); + WKBGeography geog = WKBGeography.fromWKB(wkb, 4326); + + assertEquals("POINT (-122.4194 37.7749)", geog.toString()); + assertEquals("SRID=4326; POINT (-122.4194 37.7749)", geog.toEWKT()); + } + + @Test + public void defaultTextRendering_preservesRepeatedCoordinates() throws ParseException { + org.locationtech.jts.io.WKTReader jtsReader = new org.locationtech.jts.io.WKTReader(); + Geometry jts = jtsReader.read("LINESTRING (0 0, 1.25 2.5, 1.25 2.5, 3 4)"); + byte[] wkb = new org.locationtech.jts.io.WKBWriter().write(jts); + WKBGeography geog = WKBGeography.fromWKB(wkb, 4326); + + assertEquals("LINESTRING (0 0, 1.25 2.5, 1.25 2.5, 3 4)", geog.toString()); + assertEquals("SRID=4326; LINESTRING (0 0, 1.25 2.5, 1.25 2.5, 3 4)", geog.toEWKT()); + } + + @Test + public void explicitPrecisionRendering_usesRequestedS2Formatting() throws ParseException { + org.locationtech.jts.io.WKTReader jtsReader = new org.locationtech.jts.io.WKTReader(); + Geometry jts = jtsReader.read("POINT (-122.4194 37.7749)"); + byte[] wkb = new org.locationtech.jts.io.WKBWriter().write(jts); + WKBGeography geog = WKBGeography.fromWKB(wkb, 4326); + PrecisionModel fixed = new PrecisionModel(PrecisionModel.FIXED); + + assertEquals("POINT (-122.4 37.8)", geog.toString(fixed)); + assertEquals("SRID=4326; POINT (-122.4 37.8)", geog.toEWKT(fixed)); + } + // ─── Serializer round-trip ─────────────────────────────────────────────── @Test @@ -313,7 +347,7 @@ public void serialize_s2Geography_producesWKBFormat() throws IOException { Geography deserialized = GeographyWKBSerializer.deserialize(bytes); assertTrue(deserialized instanceof WKBGeography); assertEquals(4326, deserialized.getSRID()); - assertEquals("POINT (30 10)", deserialized.toString()); + assertEquals("POINT (30 10)", deserialized.toString(new PrecisionModel(PrecisionModel.FIXED))); } // ─── SRID preservation ─────────────────────────────────────────────────── @@ -363,7 +397,7 @@ public void geogFromWKT_returnsWKBGeography() throws ParseException { Geography geog = Constructors.geogFromWKT("POINT (1 1)", 4326); assertTrue(geog instanceof WKBGeography); assertEquals(4326, geog.getSRID()); - assertEquals("POINT (1 1)", geog.toString()); + assertEquals("POINT (1 1)", geog.toString(new PrecisionModel(PrecisionModel.FIXED))); } @Test @@ -396,7 +430,7 @@ public void geogFromEWKT_returnsWKBGeography() throws ParseException { Geography geog = Constructors.geogFromEWKT("SRID=4269; POINT (1 1)"); assertTrue(geog instanceof WKBGeography); assertEquals(4269, geog.getSRID()); - assertEquals("SRID=4269; POINT (1 1)", geog.toEWKT()); + assertEquals("SRID=4269; POINT (1 1)", geog.toEWKT(new PrecisionModel(PrecisionModel.FIXED))); } // ─── Eager ShapeIndex mode ─────────────────────────────────────────────── diff --git a/common/src/test/java/org/apache/sedona/common/S2Geography/WkbContainsRoundtripTest.java b/common/src/test/java/org/apache/sedona/common/S2Geography/WkbContainsRoundtripTest.java index c8720f7e6de..91a76c94678 100644 --- a/common/src/test/java/org/apache/sedona/common/S2Geography/WkbContainsRoundtripTest.java +++ b/common/src/test/java/org/apache/sedona/common/S2Geography/WkbContainsRoundtripTest.java @@ -18,18 +18,20 @@ */ package org.apache.sedona.common.S2Geography; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import com.google.common.geometry.S2LatLng; +import com.google.common.geometry.S2ShapeUtil; import org.apache.sedona.common.geography.Constructors; import org.apache.sedona.common.geography.Functions; import org.junit.Test; /** - * Localises a Geography ST_Contains correctness bug seen when arguments come from a DataFrame (i.e. - * after a GeographyWKBSerializer round-trip). The WKBGeography fast path (getShapeIndexGeography → - * WkbS2Shape) returns the wrong answer for some polygon-point pairs; the slow path through - * S2Polygon (and direct ST_GeogFromWKT in a SELECT) is correct. + * Regression coverage for WKB-backed Geography containment, including direct shape access, + * ShapeIndex predicates, the full S2 parse path, and serializer round-trips. */ public class WkbContainsRoundtripTest { @@ -98,4 +100,143 @@ public void containsIsFalseAfterWkbRoundTrip() throws Exception { assertTrue( "polygon at (2..3,2..3) must contain (2.5, 2.5)", Functions.contains(poly, ptInside)); } + + @Test + public void clockwiseShellUsesSimpleFeaturesInteriorWithoutChangingWkb() throws Exception { + String wkt = "POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))"; + WKBGeography polygon = (WKBGeography) Constructors.geogFromWKT(wkt, 4326); + byte[] originalWkb = polygon.getWKBBytes().clone(); + Geography inside = Constructors.geogFromWKT("POINT (0.5 0.5)", 4326); + Geography outside = Constructors.geogFromWKT("POINT (5 5)", 4326); + + // The direct WkbS2Shape path and the full WKBReader path must use the same normalized view. + assertTrue(Functions.contains(polygon, inside)); + assertFalse(Functions.contains(polygon, outside)); + assertTrue(Functions.contains(polygon.getS2Geography(), inside)); + assertFalse(Functions.contains(polygon.getS2Geography(), outside)); + assertTrue( + S2ShapeUtil.containsBruteForce(polygon.shape(0), S2LatLng.fromDegrees(0.5, 0.5).toPoint())); + assertFalse( + S2ShapeUtil.containsBruteForce(polygon.shape(0), S2LatLng.fromDegrees(5, 5).toPoint())); + + assertEquals(wkt, Functions.asText(polygon)); + assertArrayEquals(originalWkb, polygon.getWKBBytes()); + + WKBGeography roundTripped = + (WKBGeography) + GeographyWKBSerializer.deserialize(GeographyWKBSerializer.serialize(polygon)); + assertArrayEquals(originalWkb, roundTripped.getWKBBytes()); + assertTrue(Functions.contains(roundTripped, inside)); + assertFalse(Functions.contains(roundTripped, outside)); + } + + @Test + public void directedShapeFactoryPreservesComplementaryInterior() throws Exception { + org.locationtech.jts.geom.Geometry polygon = + new org.locationtech.jts.io.WKTReader().read("POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))"); + byte[] wkb = new org.locationtech.jts.io.WKBWriter().write(polygon); + WkbS2Shape normalized = new WkbS2Shape(wkb); + WkbS2Shape directed = WkbS2Shape.withPreservedLoopOrientation(wkb); + com.google.common.geometry.S2Point inside = S2LatLng.fromDegrees(0.5, 0.5).toPoint(); + com.google.common.geometry.S2Point outside = S2LatLng.fromDegrees(5, 5).toPoint(); + + assertTrue(S2ShapeUtil.containsBruteForce(normalized, inside)); + assertFalse(S2ShapeUtil.containsBruteForce(normalized, outside)); + assertFalse(S2ShapeUtil.containsBruteForce(directed, inside)); + assertTrue(S2ShapeUtil.containsBruteForce(directed, outside)); + } + + @Test + public void singleRingFastPathHandlesRepeatedVertex() throws Exception { + Geography polygon = Constructors.geogFromWKT("POLYGON ((0 0, 0 0, 0 1, 1 1, 1 0, 0 0))", 4326); + + assertTrue(Functions.contains(polygon, Constructors.geogFromWKT("POINT (0.5 0.5)", 4326))); + assertFalse(Functions.contains(polygon, Constructors.geogFromWKT("POINT (5 5)", 4326))); + } + + @Test + public void polygonRingRolesOverrideInputWinding() throws Exception { + String[] shells = { + "0 0, 4 0, 4 4, 0 4, 0 0", // counter-clockwise + "0 0, 0 4, 4 4, 4 0, 0 0" // clockwise + }; + String[] holes = { + "1 1, 3 1, 3 3, 1 3, 1 1", // counter-clockwise + "1 1, 1 3, 3 3, 3 1, 1 1" // clockwise + }; + Geography shellPoint = Constructors.geogFromWKT("POINT (0.5 0.5)", 4326); + Geography holePoint = Constructors.geogFromWKT("POINT (2 2)", 4326); + Geography outsidePoint = Constructors.geogFromWKT("POINT (5 5)", 4326); + + for (String shell : shells) { + for (String hole : holes) { + String wkt = "POLYGON ((" + shell + "), (" + hole + "))"; + WKBGeography polygon = (WKBGeography) Constructors.geogFromWKT(wkt, 4326); + + assertTrue(wkt, Functions.contains(polygon, shellPoint)); + assertFalse(wkt, Functions.contains(polygon, holePoint)); + assertFalse(wkt, Functions.contains(polygon, outsidePoint)); + + Geography parsed = polygon.getS2Geography(); + assertTrue(wkt, Functions.contains(parsed, shellPoint)); + assertFalse(wkt, Functions.contains(parsed, holePoint)); + assertFalse(wkt, Functions.contains(parsed, outsidePoint)); + + Geography parsedFromOperationalWkt = new WKTReader().read(wkt); + assertTrue(wkt, Functions.contains(parsedFromOperationalWkt, shellPoint)); + assertFalse(wkt, Functions.contains(parsedFromOperationalWkt, holePoint)); + assertFalse(wkt, Functions.contains(parsedFromOperationalWkt, outsidePoint)); + } + } + } + + @Test + public void shellsLargerThanAHemisphereUseComplementRegardlessOfWinding() throws Exception { + Geography forward = + Constructors.geogFromWKT("POLYGON ((0 -80, 120 -80, -120 -80, 0 -80))", 4326); + Geography reversed = + Constructors.geogFromWKT("POLYGON ((0 -80, -120 -80, 120 -80, 0 -80))", 4326); + Geography equator = Constructors.geogFromWKT("POINT (0 0)", 4326); + + for (Geography polygon : new Geography[] {forward, reversed}) { + assertFalse(Functions.contains(polygon, equator)); + assertFalse(Functions.intersects(polygon, equator)); + assertFalse(Functions.within(equator, polygon)); + double area = Functions.area(polygon); + assertTrue(area > 1.0e12 && area < 2.0e12); + } + assertEquals(Functions.area(forward), Functions.area(reversed), 1.0); + } + + @Test + public void multipolygonFallbackNormalizesEachShellAndHole() throws Exception { + String wkt = + "MULTIPOLYGON (((0 0, 0 2, 2 2, 2 0, 0 0), " + + "(0.5 0.5, 0.5 1.5, 1.5 1.5, 1.5 0.5, 0.5 0.5)), " + + "((10 0, 12 0, 12 2, 10 2, 10 0), " + + "(10.5 0.5, 11.5 0.5, 11.5 1.5, 10.5 1.5, 10.5 0.5)))"; + Geography polygon = Constructors.geogFromWKT(wkt, 4326); + + assertTrue(Functions.contains(polygon, Constructors.geogFromWKT("POINT (0.25 0.25)", 4326))); + assertFalse(Functions.contains(polygon, Constructors.geogFromWKT("POINT (1 1)", 4326))); + assertTrue(Functions.contains(polygon, Constructors.geogFromWKT("POINT (10.25 0.25)", 4326))); + assertFalse(Functions.contains(polygon, Constructors.geogFromWKT("POINT (11 1)", 4326))); + assertFalse(Functions.contains(polygon, Constructors.geogFromWKT("POINT (5 5)", 4326))); + assertEquals(wkt, Functions.asText(polygon)); + } + + @Test + public void holeContainingS2OriginIsExcluded() throws Exception { + Geography polygon = + Constructors.geogFromWKT( + "POLYGON ((160 88, 160 89.8, 170 89.8, 170 88, 160 88), " + + "(164 89, 167 89, 167 89.6, 164 89.6, 164 89))", + 4326); + Geography inShell = Constructors.geogFromWKT("POINT (161 88.5)", 4326); + Geography inHole = + Constructors.geogFromWKT("POINT (165.4655449194599 89.40812060946742)", 4326); + + assertTrue(Functions.contains(polygon, inShell)); + assertFalse(Functions.contains(polygon, inHole)); + } } diff --git a/common/src/test/java/org/apache/sedona/common/raster/RasterPredicatesTest.java b/common/src/test/java/org/apache/sedona/common/raster/RasterPredicatesTest.java index 97db9d00e47..0f5f22acbb3 100644 --- a/common/src/test/java/org/apache/sedona/common/raster/RasterPredicatesTest.java +++ b/common/src/test/java/org/apache/sedona/common/raster/RasterPredicatesTest.java @@ -514,6 +514,21 @@ public void testDWithinWGS84RasterPointMeterSemantics() throws FactoryException Assert.assertFalse(RasterPredicates.rsDWithin(raster, tenDegreesEast, 10.0)); } + @Test + public void testDWithinGlobalRasterPreservesDirectedInterior() + throws FactoryException, IOException { + GridCoverage2D raster = + rasterFromGeoTiff(resourceFolder + "raster/raster_with_no_data/test5.tiff"); + Geometry point = GEOMETRY_FACTORY.createPoint(new Coordinate(-73.9857, 40.7484)); + point.setSRID(4326); + + // The WGS84 footprint covers more than half the sphere. Public Geography construction + // deliberately applies simple-features ring roles, but the raster distance path must retain + // this directed footprint so a point inside it has zero distance. + Assert.assertTrue(RasterPredicates.rsDWithin(raster, point, 0.0)); + Assert.assertTrue(RasterPredicates.rsDWithin(raster, point, 1.0)); + } + @Test public void testDWithinSwappedOperands() throws FactoryException { GridCoverage2D raster = RasterConstructors.makeEmptyRaster(1, 5, 5, 0, 0, 1, -1, 0, 0, 4326); @@ -612,6 +627,42 @@ public void testDWithinMixedOrientationMultiPolygon() throws FactoryException, P RasterPredicates.rsDWithin(raster, mixed, 1_000_000.0)); } + @Test + public void testDWithinPolygonHoleUsesRingRole() throws FactoryException, ParseException { + GridCoverage2D raster = + RasterConstructors.makeEmptyRaster(1, 1, 1, 1.9, 2.1, 0.2, -0.2, 0, 0, 4326); + WKTReader reader = new WKTReader(); + Geometry clockwiseHole = + reader.read("POLYGON ((0 0, 4 0, 4 4, 0 4, 0 0), " + "(1 1, 1 3, 3 3, 3 1, 1 1))"); + clockwiseHole.setSRID(4326); + Geometry counterClockwiseHole = + reader.read("POLYGON ((0 0, 4 0, 4 4, 0 4, 0 0), " + "(1 1, 3 1, 3 3, 1 3, 1 1))"); + counterClockwiseHole.setSRID(4326); + + // The raster lies strictly inside the hole, so it does not overlap the polygon. Ring position, + // not caller-provided winding, determines that result. + Assert.assertFalse(RasterPredicates.rsDWithin(raster, clockwiseHole, 0.0)); + Assert.assertFalse(RasterPredicates.rsDWithin(raster, counterClockwiseHole, 0.0)); + Assert.assertTrue(RasterPredicates.rsDWithin(raster, clockwiseHole, 200_000.0)); + Assert.assertTrue(RasterPredicates.rsDWithin(raster, counterClockwiseHole, 200_000.0)); + } + + @Test + public void testDWithinNormalizesPolygonInsideGeometryCollection() + throws FactoryException, ParseException { + GridCoverage2D raster = + RasterConstructors.makeEmptyRaster(1, 1, 1, 1.9, 2.1, 0.2, -0.2, 0, 0, 4326); + WKTReader reader = new WKTReader(); + Geometry counterClockwise = + reader.read("GEOMETRYCOLLECTION (POLYGON ((0 0, 4 0, 4 4, 0 4, 0 0)))"); + counterClockwise.setSRID(4326); + Geometry clockwise = reader.read("GEOMETRYCOLLECTION (POLYGON ((0 0, 0 4, 4 4, 4 0, 0 0)))"); + clockwise.setSRID(4326); + + Assert.assertTrue(RasterPredicates.rsDWithin(raster, counterClockwise, 0.0)); + Assert.assertTrue(RasterPredicates.rsDWithin(raster, clockwise, 0.0)); + } + @Test public void testDWithinEmptyMultiPolygon() throws FactoryException, ParseException { // Regression: `getGeometryN(0)` would throw IndexOutOfBoundsException on MULTIPOLYGON EMPTY. diff --git a/docs/api/flink/Geography-Functions/ST_Area.md b/docs/api/flink/Geography-Functions/ST_Area.md index 0cd8b53ff52..6756dd3f6e4 100644 --- a/docs/api/flink/Geography-Functions/ST_Area.md +++ b/docs/api/flink/Geography-Functions/ST_Area.md @@ -23,7 +23,7 @@ Introduction: Return the spherical area of a geography in square meters, calcula ![ST_Area on a Geography (sphere-native)](../../../image/ST_Area_geography/ST_Area_geography.svg "ST_Area on a Geography (sphere-native)") -If the input ring is wound in the orientation that would describe the rest of the planet, Sedona returns the smaller of the two regions, so the answer is always bounded by half the surface of the Earth. If you specifically want the WGS84 ellipsoidal value, convert via `ST_GeogToGeometry` first and use the geometry overload. +Each Geography polygon shell is normalized to an area of at most one hemisphere. A shell intended to represent more than half the sphere is therefore interpreted as its complement; reversing the ring's coordinate sequence does not select the larger region. The returned area is bounded by half the surface of the Earth. If you specifically want the WGS84 ellipsoidal value, convert via `ST_GeogToGeometry` first and use the geometry overload. Format: diff --git a/docs/api/flink/Geography-Predicates/ST_Contains.md b/docs/api/flink/Geography-Predicates/ST_Contains.md index b8d41c17b83..b6645e0c035 100644 --- a/docs/api/flink/Geography-Predicates/ST_Contains.md +++ b/docs/api/flink/Geography-Predicates/ST_Contains.md @@ -31,7 +31,9 @@ Return type: `Boolean` Since: `v1.9.1` -Geography polygons follow the spherical right-hand rule: a counter-clockwise ring's interior is the enclosed region, so the polygon below is counter-clockwise. A clockwise ring would denote the complementary region on the sphere. +Polygon ring roles follow the simple-features structure: the first ring is a shell and subsequent rings are holes, regardless of input winding. Sedona preserves the submitted coordinate order for structural output such as `ST_AsText`, while normalizing only the S2-facing traversal used by spherical operations. + +Each Geography polygon shell is normalized to an area of at most one hemisphere. A shell intended to represent more than half the sphere is therefore interpreted as its complement; reversing the ring's coordinate sequence does not select the larger region. SQL Example: diff --git a/docs/api/flink/Geography-Predicates/ST_Intersects.md b/docs/api/flink/Geography-Predicates/ST_Intersects.md index 0d7be86722e..5cce710086b 100644 --- a/docs/api/flink/Geography-Predicates/ST_Intersects.md +++ b/docs/api/flink/Geography-Predicates/ST_Intersects.md @@ -31,7 +31,9 @@ Return type: `Boolean` Since: `v1.9.1` -Geography polygons follow the spherical right-hand rule: a counter-clockwise ring's interior is the enclosed region, so the polygon below is counter-clockwise. A clockwise ring would denote the complementary region on the sphere. +Polygon ring roles follow the simple-features structure: the first ring is a shell and subsequent rings are holes, regardless of input winding. Sedona preserves the submitted coordinate order for structural output such as `ST_AsText`, while normalizing only the S2-facing traversal used by spherical operations. + +Each Geography polygon shell is normalized to an area of at most one hemisphere. A shell intended to represent more than half the sphere is therefore interpreted as its complement; reversing the ring's coordinate sequence does not select the larger region. SQL Example: diff --git a/docs/api/flink/Geography-Predicates/ST_Within.md b/docs/api/flink/Geography-Predicates/ST_Within.md index 95eb8a6c8c5..c82fcd05176 100644 --- a/docs/api/flink/Geography-Predicates/ST_Within.md +++ b/docs/api/flink/Geography-Predicates/ST_Within.md @@ -31,7 +31,9 @@ Return type: `Boolean` Since: `v1.9.1` -Geography polygons follow the spherical right-hand rule: a counter-clockwise ring's interior is the enclosed region, so the polygon below is counter-clockwise. A clockwise ring would denote the complementary region on the sphere. +Polygon ring roles follow the simple-features structure: the first ring is a shell and subsequent rings are holes, regardless of input winding. Sedona preserves the submitted coordinate order for structural output such as `ST_AsText`, while normalizing only the S2-facing traversal used by spherical operations. + +Each Geography polygon shell is normalized to an area of at most one hemisphere. A shell intended to represent more than half the sphere is therefore interpreted as its complement; reversing the ring's coordinate sequence does not select the larger region. SQL Example: diff --git a/docs/api/sql/geography/Geography-Functions/ST_Area.md b/docs/api/sql/geography/Geography-Functions/ST_Area.md index fae1bf38dd9..0c1d1f694e9 100644 --- a/docs/api/sql/geography/Geography-Functions/ST_Area.md +++ b/docs/api/sql/geography/Geography-Functions/ST_Area.md @@ -25,7 +25,7 @@ Multi-polygons sum the children's areas; geography collections recurse into thei ![ST_Area on a Geography on the sphere](../../../../image/ST_Area_geography/ST_Area_geography.svg "ST_Area on a Geography (sphere-native)") -The result is the area of the polygon's user-intended interior. If the input ring happens to be wound in the orientation that would describe the rest of the planet instead, Sedona returns the smaller of the two regions, so the answer is always bounded by half the surface of the Earth (~2.55 × 10¹⁴ m²). +Each Geography polygon shell is normalized to an area of at most one hemisphere. A shell intended to represent more than half the sphere is therefore interpreted as its complement; reversing the ring's coordinate sequence does not select the larger region. The returned area is bounded by half the surface of the Earth (~2.55 × 10¹⁴ m²). If you specifically want the WGS84 ellipsoidal value (which is ~0.5 % lower for typical shapes), convert via `ST_GeogToGeometry` first and use the geometry overload: diff --git a/docs/api/sql/geography/Geography-Functions/ST_Contains.md b/docs/api/sql/geography/Geography-Functions/ST_Contains.md index b7187c45f96..37698127750 100644 --- a/docs/api/sql/geography/Geography-Functions/ST_Contains.md +++ b/docs/api/sql/geography/Geography-Functions/ST_Contains.md @@ -21,6 +21,10 @@ Introduction: Tests whether geography A fully contains geography B using S2 spherical boolean operations. Returns true if every point of B is inside or on the boundary of A. +Polygon ring roles follow the simple-features structure: the first ring is a shell and subsequent rings are holes, regardless of input winding. Sedona preserves the submitted coordinate order for structural output such as `ST_AsText`, while normalizing only the S2-facing traversal used by spherical operations. + +Each Geography polygon shell is normalized to an area of at most one hemisphere. A shell intended to represent more than half the sphere is therefore interpreted as its complement; reversing the ring's coordinate sequence does not select the larger region. + ![ST_Contains returning true](../../../../image/ST_Contains_geography/ST_Contains_geography_true.svg "ST_Contains returning true") ![ST_Contains returning false](../../../../image/ST_Contains_geography/ST_Contains_geography_false.svg "ST_Contains returning false") diff --git a/docs/api/sql/geography/Geography-Functions/ST_Intersects.md b/docs/api/sql/geography/Geography-Functions/ST_Intersects.md index 6fb1cb7f573..f561da616c4 100644 --- a/docs/api/sql/geography/Geography-Functions/ST_Intersects.md +++ b/docs/api/sql/geography/Geography-Functions/ST_Intersects.md @@ -23,6 +23,10 @@ Introduction: Tests whether two geography objects intersect on the sphere using Edges are interpreted as great-circle arcs, so the test is correct even when geographies cross the antimeridian or wrap around the poles — situations where a planar `ST_Intersects` would be wrong. +Polygon ring roles follow the simple-features structure: the first ring is a shell and subsequent rings are holes, regardless of input winding. Sedona preserves the submitted coordinate order for structural output such as `ST_AsText`, while normalizing only the S2-facing traversal used by spherical operations. + +Each Geography polygon shell is normalized to an area of at most one hemisphere. A shell intended to represent more than half the sphere is therefore interpreted as its complement; reversing the ring's coordinate sequence does not select the larger region. + ![ST_Intersects returning true](../../../../image/ST_Intersects_geography/ST_Intersects_geography_true.svg "ST_Intersects returning true") ![ST_Intersects returning false](../../../../image/ST_Intersects_geography/ST_Intersects_geography_false.svg "ST_Intersects returning false") diff --git a/docs/api/sql/geography/Geography-Functions/ST_Within.md b/docs/api/sql/geography/Geography-Functions/ST_Within.md index d9058eec114..41f493a25f4 100644 --- a/docs/api/sql/geography/Geography-Functions/ST_Within.md +++ b/docs/api/sql/geography/Geography-Functions/ST_Within.md @@ -21,7 +21,11 @@ Introduction: Tests whether geography `A` is fully within geography `B` using S2 spherical boolean operations. Returns true when every point of `A`'s interior lies in `B`'s interior. By OGC convention, `ST_Within(A, B)` is equivalent to `ST_Contains(B, A)`, and shares the same boundary semantics. -Boundary semantics on the sphere are inherited from S2's boolean operations and depend on each ring's vertex orientation: along an edge that is "owned" by `B`'s boundary the test returns true, and along the opposite edge it returns false. Do not rely on a specific result for points that lie exactly on `B`'s boundary; for predictable behavior, use a strict interior point or expand `B` slightly with `ST_Buffer` before testing. +Polygon ring roles follow the simple-features structure: the first ring is a shell and subsequent rings are holes, regardless of input winding. Sedona preserves the submitted coordinate order for structural output such as `ST_AsText`, while normalizing only the S2-facing traversal used by spherical operations. + +Each Geography polygon shell is normalized to an area of at most one hemisphere. A shell intended to represent more than half the sphere is therefore interpreted as its complement; reversing the ring's coordinate sequence does not select the larger region. + +Boundary semantics on the sphere are inherited from S2's boolean operations and depend on the normalized S2 edge orientation: along an edge that is "owned" by `B`'s boundary the test returns true, and along the opposite edge it returns false. Do not rely on a specific result for points that lie exactly on `B`'s boundary; for predictable behavior, use a strict interior point or expand `B` slightly with `ST_Buffer` before testing. ![ST_Within returning true](../../../../image/ST_Within_geography/ST_Within_geography_true.svg "ST_Within returning true") ![ST_Within returning false](../../../../image/ST_Within_geography/ST_Within_geography_false.svg "ST_Within returning false") diff --git a/flink/src/test/java/org/apache/sedona/flink/GeographyConstructorTest.java b/flink/src/test/java/org/apache/sedona/flink/GeographyConstructorTest.java index c4c31486979..460dbc8ebe0 100644 --- a/flink/src/test/java/org/apache/sedona/flink/GeographyConstructorTest.java +++ b/flink/src/test/java/org/apache/sedona/flink/GeographyConstructorTest.java @@ -21,6 +21,8 @@ import static org.apache.flink.table.api.Expressions.$; import static org.apache.flink.table.api.Expressions.call; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import java.util.Collections; import java.util.List; @@ -33,6 +35,7 @@ import org.apache.flink.types.Row; import org.apache.sedona.common.S2Geography.Geography; import org.apache.sedona.common.geography.Constructors; +import org.apache.sedona.common.geography.Functions; import org.apache.sedona.flink.expressions.geography.GeographyConstructors; import org.junit.BeforeClass; import org.junit.Test; @@ -160,8 +163,47 @@ public void testGeomToGeography() throws Exception { call(GeographyConstructors.ST_GeomToGeography.class.getSimpleName(), $("geom")) .as("geog")); Geography result = first(out).getFieldAs("geog"); - Geometry point = new WKTReader().read("POINT (1 2)"); - assertEquals(Constructors.geomToGeography(point).toEWKT(), result.toEWKT()); + assertEquals("POINT (1 2)", result.toString()); + } + + @Test + public void testGeomToGeographyEmptyPolygon() { + Table src = sourceTable("wkt", "POLYGON EMPTY", BasicTypeInfo.STRING_TYPE_INFO); + Table geomTable = + src.select( + call( + org.apache.sedona.flink.expressions.Constructors.ST_GeomFromWKT.class + .getSimpleName(), + $("wkt")) + .as("geom")); + Table out = + geomTable.select( + call(GeographyConstructors.ST_GeomToGeography.class.getSimpleName(), $("geom")) + .as("geog")); + Geography result = first(out).getFieldAs("geog"); + assertEquals("POLYGON EMPTY", result.toString()); + } + + @Test + public void testGeomToGeographyPolygonUsesRingRoles() throws Exception { + String wkt = "POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))"; + Table src = sourceTable("wkt", wkt, BasicTypeInfo.STRING_TYPE_INFO); + Table geomTable = + src.select( + call( + org.apache.sedona.flink.expressions.Constructors.ST_GeomFromWKT.class + .getSimpleName(), + $("wkt")) + .as("geom")); + Table out = + geomTable.select( + call(GeographyConstructors.ST_GeomToGeography.class.getSimpleName(), $("geom")) + .as("geog")); + Geography result = first(out).getFieldAs("geog"); + + assertEquals(wkt, result.toString()); + assertTrue(Functions.contains(result, Constructors.geogFromWKT("POINT (0.5 0.5)", 0))); + assertFalse(Functions.contains(result, Constructors.geogFromWKT("POINT (5 5)", 0))); } @Test diff --git a/flink/src/test/java/org/apache/sedona/flink/GeographyPredicateTest.java b/flink/src/test/java/org/apache/sedona/flink/GeographyPredicateTest.java index 347a5a1ba2a..494db11411a 100644 --- a/flink/src/test/java/org/apache/sedona/flink/GeographyPredicateTest.java +++ b/flink/src/test/java/org/apache/sedona/flink/GeographyPredicateTest.java @@ -40,9 +40,8 @@ public class GeographyPredicateTest extends TestBase { - // Geography polygons follow the spherical right-hand rule: a CCW ring's interior is the enclosed - // region. The polygons below are CCW so containment matches the intuitive (planar) expectation; a - // CW ring would denote the complementary region on the sphere. + // Geography predicates use simple-features ring roles: the first polygon ring is a shell and + // subsequent rings are holes regardless of their input winding. @BeforeClass public static void onceExecutedBeforeAll() { @@ -93,11 +92,37 @@ public void testIntersects() { @Test public void testContains() { + String[] polygons = { + "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))", "POLYGON ((0 0, 0 2, 2 2, 2 0, 0 0))" + }; + for (String polygon : polygons) { + assertTrue( + (Boolean) + eval( + polygon, + "POINT (1 1)", + (a, b) -> call(Predicates.ST_Contains.class.getSimpleName(), a, b))); + assertFalse( + (Boolean) + eval( + polygon, + "POINT (5 5)", + (a, b) -> call(Predicates.ST_Contains.class.getSimpleName(), a, b))); + } + + String sameWindingShellAndHole = + "POLYGON ((0 0, 4 0, 4 4, 0 4, 0 0), (1 1, 3 1, 3 3, 1 3, 1 1))"; assertTrue( (Boolean) eval( - "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))", - "POINT (1 1)", + sameWindingShellAndHole, + "POINT (0.5 0.5)", + (a, b) -> call(Predicates.ST_Contains.class.getSimpleName(), a, b))); + assertFalse( + (Boolean) + eval( + sameWindingShellAndHole, + "POINT (2 2)", (a, b) -> call(Predicates.ST_Contains.class.getSimpleName(), a, b))); } diff --git a/flink/src/test/java/org/apache/sedona/flink/GeographyTypeSerializerTest.java b/flink/src/test/java/org/apache/sedona/flink/GeographyTypeSerializerTest.java index 3e6a92b147d..3c692f1578e 100644 --- a/flink/src/test/java/org/apache/sedona/flink/GeographyTypeSerializerTest.java +++ b/flink/src/test/java/org/apache/sedona/flink/GeographyTypeSerializerTest.java @@ -18,13 +18,18 @@ */ package org.apache.sedona.flink; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import org.apache.flink.core.memory.DataInputDeserializer; import org.apache.flink.core.memory.DataOutputSerializer; import org.apache.sedona.common.S2Geography.Geography; +import org.apache.sedona.common.S2Geography.WKBGeography; import org.apache.sedona.common.geography.Constructors; +import org.apache.sedona.common.geography.Functions; import org.junit.Test; public class GeographyTypeSerializerTest { @@ -48,9 +53,16 @@ public void testPointRoundTrip() throws Exception { @Test public void testPolygonRoundTrip() throws Exception { - Geography polygon = Constructors.geogFromWKT("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", 4326); + String wkt = "POLYGON ((0 0, 0 4, 4 4, 4 0, 0 0), (1 1, 1 3, 3 3, 3 1, 1 1))"; + WKBGeography polygon = (WKBGeography) Constructors.geogFromWKT(wkt, 4326); + byte[] originalWkb = polygon.getWKBBytes().clone(); Geography result = roundTrip(polygon); + assertEquals(polygon.toEWKT(), result.toEWKT()); + assertTrue(result instanceof WKBGeography); + assertArrayEquals(originalWkb, ((WKBGeography) result).getWKBBytes()); + assertTrue(Functions.contains(result, Constructors.geogFromWKT("POINT (0.5 0.5)", 4326))); + assertFalse(Functions.contains(result, Constructors.geogFromWKT("POINT (2 2)", 4326))); } @Test diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/geography/ConstructorsDataFrameAPITest.scala b/spark/common/src/test/scala/org/apache/sedona/sql/geography/ConstructorsDataFrameAPITest.scala index 330f790e47e..139de9937ee 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/geography/ConstructorsDataFrameAPITest.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/geography/ConstructorsDataFrameAPITest.scala @@ -47,7 +47,7 @@ class ConstructorsDataFrameAPITest extends TestBaseScala { val df = wkbSeq.toDF("wkb").select(st_constructors.ST_GeogFromWKB(col("wkb"))) val actualResult = df.take(1)(0).get(0).asInstanceOf[Geography].toString() val expectedResult = - "LINESTRING (-2.1 -0.4, -1.5 -0.7)" + "LINESTRING (-2.1047439575195312 -0.354827880859375, -1.49606454372406 -0.6676061153411865)" assert(actualResult == expectedResult) } @@ -59,7 +59,7 @@ class ConstructorsDataFrameAPITest extends TestBaseScala { val df = wkbSeq.toDF("wkb").select(st_constructors.ST_GeogFromWKB(col("wkb"))) val actualResult = df.take(1)(0).get(0).asInstanceOf[Geography].toEWKT() val expectedResult = - "GEOMETRYCOLLECTION (SRID=4326; POINT (0 1), SRID=4326; POINT (0 1), SRID=4326; POINT (2 3), SRID=4326; LINESTRING (2 3, 4 5), SRID=4326; LINESTRING (0 1, 2 3), SRID=4326; LINESTRING (4 5, 6 7), SRID=4326; POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0), (9 1, 9 9, 1 9, 1 1, 9 1)), SRID=4326; POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0), (9 1, 9 9, 1 9, 1 1, 9 1)), SRID=4326; POLYGON ((-9 0, -9 10, -1 10, -1 0, -9 0)))" + "GEOMETRYCOLLECTION (POINT (0 1), POINT (0 1), POINT (2 3), LINESTRING (2 3, 4 5), LINESTRING (0 1, 2 3), LINESTRING (4 5, 6 7), POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0), (1 1, 1 9, 9 9, 9 1, 1 1)), POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0), (1 1, 1 9, 9 9, 9 1, 1 1)), POLYGON ((-9 0, -9 10, -1 10, -1 0, -9 0)))" assert(actualResult == expectedResult) } @@ -71,7 +71,7 @@ class ConstructorsDataFrameAPITest extends TestBaseScala { val df = wkbSeq.toDF("wkb") select (st_constructors.ST_GeogFromEWKB("wkb")) val actualResult = df.take(1)(0).get(0).asInstanceOf[Geography].toEWKT() val expectedResult = { - "SRID=4326; LINESTRING (-2.1 -0.4, -1.5 -0.7)" + "SRID=4326; LINESTRING (-2.1047439575195312 -0.354827880859375, -1.49606454372406 -0.6676061153411865)" } assert(df.take(1)(0).get(0).asInstanceOf[Geography].getSRID == 4326) assert(actualResult == expectedResult) @@ -112,7 +112,7 @@ class ConstructorsDataFrameAPITest extends TestBaseScala { val geom = df.head().getAs[Geometry]("geom") assert(geom.getGeometryType == "MultiPolygon") - // S2 normalizes polygon loop orientation, so hole winding may differ from input + // WKB-backed conversion preserves the submitted shell and hole coordinate order. val expectedWkt = "MULTIPOLYGON (((10 10, 70 10, 70 70, 10 70, 10 10), " + "(20 20, 60 20, 60 60, 20 60, 20 20)), " + diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/geography/ConstructorsTest.scala b/spark/common/src/test/scala/org/apache/sedona/sql/geography/ConstructorsTest.scala index c012f6abbfc..1560a52966e 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/geography/ConstructorsTest.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/geography/ConstructorsTest.scala @@ -164,7 +164,7 @@ class ConstructorsTest extends TestBaseScala { val geography = sparkSession.sql("SELECT ST_GeogFromEWKB(rawWKBTable.wkb) as countyshape from rawWKBTable") val expectedGeog = { - "SRID=4326; LINESTRING (-2.1 -0.4, -1.5 -0.7)" + "SRID=4326; LINESTRING (-2.1047439575195312 -0.354827880859375, -1.49606454372406 -0.6676061153411865)" } assert(geography.first().getAs[Geography](0).getSRID == 4326) assert(geography.first().getAs[Geography](0).toEWKT().equals(expectedGeog)) @@ -178,7 +178,7 @@ class ConstructorsTest extends TestBaseScala { ST_GeogToGeometry(ST_GeogFromWKT('$wkt')) AS geom """) val geom = df.first().getAs[Geometry](0) - // S2 normalizes polygon loop orientation, so hole winding may differ from input + // WKB-backed conversion preserves the submitted shell and hole coordinate order. val expected = "POLYGON ((0 0, 95 20, 95 85, 10 85, 0 0), " + "(20 30, 35 25, 30 40, 20 30), " + "(50 50, 65 50, 65 65, 50 65, 50 50), " + "(25 60, 35 58, 38 66, 30 72, 22 66, 25 60))" assert(geom.getGeometryType == "Polygon") @@ -198,7 +198,7 @@ class ConstructorsTest extends TestBaseScala { ST_GeogToGeometry(ST_GeogFromWKT('$wkt', 4326)) AS geom """) val geom = df.first().getAs[Geometry](0) - // S2 normalizes polygon loop orientation, so hole winding may differ from input + // WKB-backed conversion preserves the submitted shell and hole coordinate order. val expected = "MULTIPOLYGON (((10 10, 70 10, 70 70, 10 70, 10 10), " + "(20 20, 60 20, 60 60, 20 60, 20 20)), " + "((30 30, 50 30, 50 50, 30 50, 30 30), " + "(36 36, 44 36, 44 44, 36 44, 36 36)))"; diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/geography/FunctionsDataFrameAPITest.scala b/spark/common/src/test/scala/org/apache/sedona/sql/geography/FunctionsDataFrameAPITest.scala index 9932980c3c7..2a828483c5f 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/geography/FunctionsDataFrameAPITest.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/geography/FunctionsDataFrameAPITest.scala @@ -23,6 +23,7 @@ import org.apache.sedona.sql.TestBaseScala import org.apache.spark.sql.functions.{col, lit} import org.apache.spark.sql.sedona_sql.expressions.{ST_Envelope, st_constructors, st_functions, st_predicates} import org.junit.Assert.{assertEquals, assertTrue} +import org.locationtech.jts.geom.PrecisionModel class FunctionsDataFrameAPITest extends TestBaseScala { import sparkSession.implicits._ @@ -39,7 +40,7 @@ class FunctionsDataFrameAPITest extends TestBaseScala { val env = df.first().get(0).asInstanceOf[Geography] val expectedWKT = "POLYGON ((-180 -63.3, 180 -63.3, 180 -90, -180 -90, -180 -63.3))"; - assertEquals(expectedWKT, env.toString) + assertEquals(expectedWKT, env.toString(new PrecisionModel(PrecisionModel.FIXED))) } it("Passed ST_Envelope Fiji") { @@ -56,7 +57,7 @@ class FunctionsDataFrameAPITest extends TestBaseScala { val env = df.first().get(0).asInstanceOf[Geography] val expectedWKT = "POLYGON ((177.3 -18.3, -179.8 -18.3, -179.8 -16, 177.3 -16, 177.3 -18.3))"; - assertEquals(expectedWKT, env.toString) + assertEquals(expectedWKT, env.toString(new PrecisionModel(PrecisionModel.FIXED))) } it("ST_Envelope preserves empty Geography") { diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala b/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala index bfbc3601848..7899c8c5a1c 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/geography/GeographyFunctionTest.scala @@ -22,7 +22,7 @@ import org.apache.sedona.common.S2Geography.{Geography, WKBGeography} import org.apache.sedona.sql.TestBaseScala import org.apache.spark.sql.functions.{col, lit} import org.apache.spark.sql.sedona_sql.expressions.{st_constructors, st_functions, st_predicates} -import org.junit.Assert.{assertEquals, assertNotNull, assertTrue} +import org.junit.Assert.{assertEquals, assertFalse, assertNotNull, assertTrue} import org.locationtech.jts.geom.Point import org.locationtech.jts.io.WKTReader @@ -76,6 +76,20 @@ class GeographyFunctionTest extends TestBaseScala { val wkt = row.getString(0) assertTrue(wkt.contains("POLYGON")) } + + it("ST_GeomToGeography preserves exact coordinates and empty polygons") { + val row = sparkSession + .sql(""" + SELECT + ST_GeomToGeography(ST_GeomFromWKT('POINT (1 2)', 4326)) AS point, + ST_GeomToGeography(ST_GeomFromWKT('POLYGON EMPTY', 4326)) AS empty_polygon + """) + .first() + val point = row.getAs[Geography](0) + val emptyPolygon = row.getAs[Geography](1) + assertEquals("SRID=4326; POINT (1 2)", point.toEWKT) + assertEquals("SRID=4326; POLYGON EMPTY", emptyPolygon.toEWKT) + } } // ─── Level 1: ST_NPoints, ST_Centroid, ST_NumGeometries, ST_GeometryType, ST_AsText ─ @@ -372,6 +386,42 @@ class GeographyFunctionTest extends TestBaseScala { assertTrue(!row.getBoolean(0)) } + it("ST_GeomToGeography uses polygon ring roles regardless of winding") { + val row = sparkSession + .sql(""" + WITH polygons AS ( + SELECT + ST_GeomToGeography(ST_GeomFromWKT( + 'POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))', 4326 + )) AS clockwise, + ST_GeomToGeography(ST_GeomFromWKT( + 'POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))', 4326 + )) AS counterclockwise + ), + points AS ( + SELECT + ST_GeogFromWKT('POINT (0.5 0.5)', 4326) AS inside, + ST_GeogFromWKT('POINT (5 5)', 4326) AS outside + ) + SELECT + ST_Contains(clockwise, inside) AS cw_inside, + ST_Contains(clockwise, outside) AS cw_outside, + ST_Contains(counterclockwise, inside) AS ccw_inside, + ST_Contains(counterclockwise, outside) AS ccw_outside, + ST_AsText(clockwise) AS cw_text, + ST_AsText(counterclockwise) AS ccw_text + FROM polygons CROSS JOIN points + """) + .first() + + assertTrue(row.getBoolean(0)) + assertFalse(row.getBoolean(1)) + assertTrue(row.getBoolean(2)) + assertFalse(row.getBoolean(3)) + assertEquals("POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))", row.getString(4)) + assertEquals("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", row.getString(5)) + } + it("ST_DWithin true when within threshold") { val row = sparkSession .sql("""