jiayuasu commented on code in PR #2863:
URL: https://github.com/apache/sedona/pull/2863#discussion_r3144487263


##########
common/src/test/java/org/apache/sedona/common/FunctionsTest.java:
##########
@@ -1081,6 +1081,181 @@ public void testS2ToGeom() {
     assertTrue(polygons[100].intersects(target));
   }
 
+  @Test
+  public void testS2CoverageContainsInput() throws ParseException {
+    String wkt =
+        "POLYGON ((-102.060778 39.9999603, -102.0535384 40.0119065, -101.98532 
40.0122906, "
+            + "-95.30829 40.009008, -95.2456364 39.9564784, -95.1982467 
39.9455019, "
+            + "-95.1964657 39.9113444, -95.1460439 39.9142017, -95.1316877 
39.8855881, "
+            + "-95.087643 39.8717975, -95.0389987 39.8749063, -95.0146232 
39.9088422, "
+            + "-94.9403146 39.906409, -94.9183761 39.8846514, -94.9329504 
39.8578468, "
+            + "-94.8824331 39.8409102, -94.8675709 39.8227528, -94.878404 
39.787242, "
+            + "-94.9266292 39.7786779, -94.9070076 39.7679231, -94.8631596 
39.779774, "
+            + "-94.8497103 39.7604914, -94.8545514 39.7397163, -94.8985678 
39.7150641, "
+            + "-94.9584897 39.7331377, -94.9637588 39.6814526, -95.0187723 
39.6615712, "
+            + "-95.0456083 39.6252182, -95.0430365 39.5826542, -95.0650104 
39.5677387, "
+            + "-95.0985514 39.570063, -95.1012286 39.5462821, -95.0459187 
39.5064755, "
+            + "-95.0320969 39.4709074, -94.976457 39.4475392, -94.9362514 
39.3964717, "
+            + "-94.8781205 39.3949417, -94.8733156 39.3663291, -94.9014695 
39.3495654, "
+            + "-94.8963639 39.3135051, -94.8237994 39.2609956, -94.8194328 
39.2178517, "
+            + "-94.7789019 39.214907, -94.7398645 39.1789812, -94.6785238 
39.1931279, "
+            + "-94.6533801 39.1816662, -94.6517728 39.1640754, -94.605515 
39.1696807, "
+            + "-94.5791896 39.1504025, -94.5983384 39.1134256, -94.6095667 
36.9948123, "
+            + "-102.045765 36.9847897, -102.060778 39.9999603))";
+    Geometry input = geomFromWKT(wkt, 0);
+    Long[] cellIds = Functions.s2CellIDs(input, 12);
+    Geometry[] polygons =
+        
Functions.s2ToGeom(Arrays.stream(cellIds).mapToLong(Long::longValue).toArray());
+    Geometry coverage = Functions.union(polygons);
+    if (!coverage.covers(input)) {
+      // The union above is unavoidable for an exact containment check; we 
additionally
+      // skip the (expensive, ~50k-cell) difference on the happy path and only 
compute it
+      // on failure to surface a useful diagnostic.
+      Geometry uncovered = input.difference(coverage);
+      double uncoveredArea = uncovered.getArea();
+      fail(
+          String.format(
+              "Coverage does not contain input. Missing %.8f deg^2 (%.6f%%)",
+              uncoveredArea, (uncoveredArea / input.getArea()) * 100.0));
+    }
+  }
+
+  @Test
+  public void testS2CoverageContainsLineString() throws ParseException {
+    // Long east-west line at mid-latitude. The great-circle arc bulges 
poleward of the JTS
+    // chord, so before the buffer fix the cells along the parallel were 
missed in the middle.
+    Geometry line = geomFromWKT("LINESTRING (-102 37, -94 37)", 0);
+    Long[] cellIds = Functions.s2CellIDs(line, 12);
+    Geometry[] cells =
+        
Functions.s2ToGeom(Arrays.stream(cellIds).mapToLong(Long::longValue).toArray());
+    Geometry coverage = Functions.union(cells);
+    assertTrue(
+        "S2 cell coverage of LineString does not contain the line itself.", 
coverage.covers(line));
+  }
+
+  @Test
+  public void testS2CoverageContainsMultiPolygon() throws ParseException {
+    // Two disjoint polygons, both at mid-northern latitude with long 
east-west edges.
+    String wkt =
+        "MULTIPOLYGON ("
+            + "((-102 37, -94 37, -94 40, -102 40, -102 37)),"
+            + "((-90 50, -80 50, -80 53, -90 53, -90 50)))";
+    Geometry input = geomFromWKT(wkt, 0);
+    Long[] cellIds = Functions.s2CellIDs(input, 10);
+    Geometry[] cells =
+        
Functions.s2ToGeom(Arrays.stream(cellIds).mapToLong(Long::longValue).toArray());
+    Geometry coverage = Functions.union(cells);
+    assertTrue(
+        "S2 cell coverage does not contain every member of the MultiPolygon.",
+        coverage.covers(input));
+  }
+
+  @Test
+  public void testS2CoverageContainsMultiLineString() throws ParseException {
+    // Three disjoint multi-segment lines: northern hemisphere, southern 
hemisphere, and a
+    // diagonal climb. Each is decomposed and buffered independently before S2 
covering.
+    String wkt =
+        "MULTILINESTRING ("
+            + "(-102 37, -98 37, -94 37),"
+            + "(-90 -42, -85 -42, -80 -42),"
+            + "(-100 50, -95 55, -90 60))";
+    Geometry input = geomFromWKT(wkt, 0);
+    Long[] cellIds = Functions.s2CellIDs(input, 10);
+    Geometry[] cells =
+        
Functions.s2ToGeom(Arrays.stream(cellIds).mapToLong(Long::longValue).toArray());
+    Geometry coverage = Functions.union(cells);
+    assertTrue(
+        "S2 cell coverage does not contain every member of the 
MultiLineString.",
+        coverage.covers(input));
+  }
+
+  @Test
+  public void testS2CoverageContainsGeometryCollection() throws ParseException 
{
+    String wkt =
+        "GEOMETRYCOLLECTION ("
+            + "POLYGON ((-102 37, -94 37, -94 40, -102 40, -102 37)),"
+            + "LINESTRING (10 60, 20 60, 30 60))";
+    Geometry input = geomFromWKT(wkt, 0);
+    Long[] cellIds = Functions.s2CellIDs(input, 10);
+    Geometry[] cells =
+        
Functions.s2ToGeom(Arrays.stream(cellIds).mapToLong(Long::longValue).toArray());
+    Geometry coverage = Functions.union(cells);
+    assertTrue(
+        "S2 cell coverage does not contain every member of the 
GeometryCollection.",
+        coverage.covers(input));
+  }
+
+  @Test
+  public void testS2CoverageContainsPolygonWithHole() throws ParseException {
+    // Outer ring at mid-latitude with long east-west edges; an inner hole. 
Both rings need
+    // their great-circle bulge accounted for so the buffer applies to 
interior rings too.
+    String wkt =
+        "POLYGON ("
+            + "(-102 37, -94 37, -94 40, -102 40, -102 37),"
+            + "(-100 38, -96 38, -96 39, -100 39, -100 38))";
+    Geometry input = geomFromWKT(wkt, 0);
+    Long[] cellIds = Functions.s2CellIDs(input, 12);
+    Geometry[] cells =
+        
Functions.s2ToGeom(Arrays.stream(cellIds).mapToLong(Long::longValue).toArray());
+    Geometry coverage = Functions.union(cells);
+    assertTrue("S2 cell coverage does not contain a polygon with a hole.", 
coverage.covers(input));
+  }
+
+  @Test
+  public void testS2CoverageContainsAntimeridianLine() throws ParseException {
+    // Edge crossing the antimeridian. The naive chord midpoint (a.x+b.x)/2 
would land at
+    // 0° longitude — the opposite side of the planet — and produce a ~180° 
bogus deviation.
+    // Verify that the buffer stays small (so we don't blow up the cell count) 
and that the
+    // returned cells still cover the line.
+    Geometry line = geomFromWKT("LINESTRING (179 5, -179 5)", 0);
+    Long[] cellIds = Functions.s2CellIDs(line, 12);
+    Geometry[] cells =
+        
Functions.s2ToGeom(Arrays.stream(cellIds).mapToLong(Long::longValue).toArray());
+    Geometry coverage = Functions.union(cells);
+    // Sanity check: a 2°-long edge near the equator at level 12 should not 
produce
+    // millions of cells. If antimeridian chord-midpoint handling is wrong the 
buffer
+    // explodes by ~180° and the cell count would be enormous.
+    assertTrue(
+        "Antimeridian-spanning line produced too many cells (chord midpoint 
likely wrong): "
+            + cellIds.length,
+        cellIds.length < 5000);
+    assertTrue(
+        "S2 cell coverage of antimeridian-spanning LineString does not contain 
the line.",
+        coverage.covers(line));
+  }
+
+  @Test
+  public void testS2CoverageContainsWideNonAntimeridianPolygon() throws 
ParseException {
+    // Polygon spanning >180° in longitude but NOT crossing the antimeridian 
(every edge has
+    // |Δlng| < 180°). An envelope-width-based antimeridian heuristic would 
incorrectly skip
+    // the buffer here and reintroduce GH-2857 miscoverage along the long 
east-west edges;
+    // the per-edge heuristic correctly leaves the buffer on.
+    String wkt = "POLYGON ((-100 30, 100 30, 100 50, -100 50, -100 30))";

Review Comment:
   Good catch — fixed in 659a9eee12. The original WKT had four edges with 
`|Δlng| = 200`, so my per-edge heuristic correctly flagged it as 
antimeridian-crossing and the test was passing only because the buffer was 
skipped (not for the reason the test name and comment claimed). New WKT `((-100 
30, -10 30, 100 30, 100 50, 10 50, -100 50, -100 30))` keeps the envelope 200° 
wide but every individual edge is under 180°, so the per-edge check returns 
false and the buffer is actually applied — which is the path the test is meant 
to exercise.



##########
common/src/main/java/org/apache/sedona/common/utils/S2Utils.java:
##########
@@ -107,13 +107,252 @@ public static Polygon toJTSPolygon(S2CellId cellId) {
     return new GeometryFactory().createPolygon(coords);
   }
 
+  /**
+   * Convert a JTS planar geometry into an S2Region whose lat/lng projection 
is guaranteed to
+   * contain the input geometry.
+   *
+   * <p>Why a buffer is needed: Sedona geometries are planar — an edge between 
two vertices is a
+   * straight line in (lng, lat) space — but S2 connects the same vertices 
with a great-circle arc
+   * on the sphere. The two interpretations agree at the vertices but diverge 
along the edges (e.g.
+   * the great-circle arc between two points at the same northern latitude 
bulges northward, leaving
+   * the parallel that would form the planar chord). If we hand the JTS 
vertices to S2 directly, the
+   * spherical polygon's interior is *smaller* than the planar polygon's 
interior along
+   * non-meridional edges, so the S2 covering misses thin slivers of the 
original planar polygon
+   * (see GH-2857).
+   *
+   * <p>To compensate, we JTS-buffer the input by an upper bound on the 
worst-case great-circle
+   * deviation before converting to S2. A side effect for {@link LineString} 
inputs is that the
+   * buffer turns the line into a polygon corridor; downstream callers 
therefore see cells in a thin
+   * strip around the line rather than only cells the line geometrically 
traverses.
+   */
   public static S2Region toS2Region(Geometry geom) throws 
IllegalArgumentException {
+    if (!(geom instanceof Polygon) && !(geom instanceof LineString)) {
+      throw new IllegalArgumentException(
+          "only Polygon or LineString (including LinearRing) types can be 
converted to S2Region");
+    }
+    // JTS planar buffer doesn't understand antimeridian crossing — for inputs 
that
+    // straddle the antimeridian, buffering produces a polygon that goes the 
wrong way
+    // around the globe and explodes the S2 covering. Detect antimeridian 
crossing
+    // per-edge (any edge with |Δlng| > 180° must wrap) rather than via the 
envelope:
+    // the envelope-width heuristic also fires for wide non-straddling 
polygons (e.g.
+    // a polygon spanning -100° to +100°), which would incorrectly skip the 
buffer and
+    // reintroduce the GH-2857 miscoverage along their long non-meridional 
edges.

Review Comment:
   Fixed in 659a9eee12. You're right that a coarse `-100°→+100°` edge has 
`|Δlng|=200` and gets classified as crossing under both heuristics, so it's not 
the case where per-edge differs from envelope-width. Reworded the comment to 
describe the actual differentiating case (a *tessellated* wide polygon with 
intermediate vertices, where envelope width > 180° but every edge is < 180°), 
and noted that for a coarse single-edge spanning > 180° both heuristics agree 
and skipping the buffer is the safe choice anyway since JTS planar buffer can't 
process such an edge usefully.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to