You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sedona.apache.org by ji...@apache.org on 2026/09/23 22:47:23 UTC

(sedona) branch master updated: [GH-2417] Support GeometryCollection inputs in ST_Crosses (#3397)

This is an automated email from the ASF dual-hosted git repository.

jiayuasu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sedona.git


The following commit(s) were added to refs/heads/master by this push:
     new da6b11c9d25 [GH-2417] Support GeometryCollection inputs in ST_Crosses (#3397)
da6b11c9d25 is described below

commit da6b11c9d25f18736bd3e03ce0730056fa38500f
Author: Minh Dau <mt...@bucknell.edu>
AuthorDate: Wed Sep 23 18:47:16 2026 -0400

    [GH-2417] Support GeometryCollection inputs in ST_Crosses (#3397)
---
 .../java/org/apache/sedona/common/Predicates.java  |  4 +-
 .../org/apache/sedona/common/PredicatesTest.java   | 52 ++++++++++++++++++++++
 docs/api/sql/Predicates/ST_Crosses.md              |  2 +
 python/sedona/spark/geopandas/geoseries.py         |  7 +--
 python/tests/geopandas/test_geoseries.py           | 27 +++++++++--
 .../tests/geopandas/test_match_geopandas_series.py |  3 --
 .../SpatialPredicateEvaluators.java                |  5 ++-
 .../SpatialPredicateEvaluatorsTest.java            | 50 +++++++++++++++++++++
 .../org/apache/sedona/sql/predicateTestScala.scala | 23 ++++++++++
 9 files changed, 158 insertions(+), 15 deletions(-)

diff --git a/common/src/main/java/org/apache/sedona/common/Predicates.java b/common/src/main/java/org/apache/sedona/common/Predicates.java
index 6f11364489c..a4c00d44d3b 100644
--- a/common/src/main/java/org/apache/sedona/common/Predicates.java
+++ b/common/src/main/java/org/apache/sedona/common/Predicates.java
@@ -25,6 +25,8 @@ import org.apache.sedona.common.utils.GeometryEquality;
 import org.locationtech.jts.geom.*;
 import org.locationtech.jts.operation.distance3d.Distance3DOp;
 import org.locationtech.jts.operation.relate.RelateOp;
+import org.locationtech.jts.operation.relateng.RelateNG;
+import org.locationtech.jts.operation.relateng.RelatePredicate;
 
 public class Predicates {
   public static boolean contains(Geometry leftGeometry, Geometry rightGeometry) {
@@ -141,7 +143,7 @@ public class Predicates {
   }
 
   public static boolean crosses(Geometry leftGeometry, Geometry rightGeometry) {
-    return leftGeometry.crosses(rightGeometry);
+    return RelateNG.relate(leftGeometry, rightGeometry, RelatePredicate.crosses());
   }
 
   public static boolean overlaps(Geometry leftGeometry, Geometry rightGeometry) {
diff --git a/common/src/test/java/org/apache/sedona/common/PredicatesTest.java b/common/src/test/java/org/apache/sedona/common/PredicatesTest.java
index 269326f51b8..eb609ca1ad0 100644
--- a/common/src/test/java/org/apache/sedona/common/PredicatesTest.java
+++ b/common/src/test/java/org/apache/sedona/common/PredicatesTest.java
@@ -40,6 +40,58 @@ public class PredicatesTest extends TestBase {
 
   private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory();
 
+  @Test
+  public void testCrossesGeometryCollections() throws ParseException {
+    // The original report: a collection containing a line endpoint does not cross the line.
+    assertCrosses("LINESTRING (1 1, 1 2)", "GEOMETRYCOLLECTION (POINT (1 1))", false);
+    assertCrosses("LINESTRING (0 0, 2 2)", "GEOMETRYCOLLECTION (POINT (1 1), POINT (3 3))", true);
+    assertCrosses(
+        "LINESTRING (0 0, 2 2)", "GEOMETRYCOLLECTION (POINT (10 10), LINESTRING (0 2, 2 0))", true);
+    assertCrosses(
+        "GEOMETRYCOLLECTION (LINESTRING (0 0, 2 2))",
+        "GEOMETRYCOLLECTION (LINESTRING (0 2, 2 0))",
+        true);
+    assertCrosses("LINESTRING (0 0, 2 2)", "GEOMETRYCOLLECTION (LINESTRING (3 3, 4 4))", false);
+    assertCrosses("LINESTRING (0 0, 2 2)", "GEOMETRYCOLLECTION (LINESTRING (1 1, 3 3))", false);
+  }
+
+  @Test
+  public void testCrossesNestedGeometryCollections() throws ParseException {
+    assertCrosses(
+        "LINESTRING (0 0, 2 2)",
+        "GEOMETRYCOLLECTION (POINT EMPTY, GEOMETRYCOLLECTION (LINESTRING (0 2, 2 0)))",
+        true);
+  }
+
+  @Test
+  public void testCrossesGeometryCollectionUnion() throws ParseException {
+    String polygon = "POLYGON ((1 1, 4 1, 4 4, 1 4, 1 1))";
+    assertCrosses("LINESTRING (0 0, 2 2)", polygon, true);
+    // The line crosses the polygon, but the entire collection has an area intersection.
+    // Evaluating components separately and OR-ing their results would incorrectly return true.
+    assertCrosses(
+        "GEOMETRYCOLLECTION (LINESTRING (0 0, 2 2), "
+            + "POLYGON ((-1 -1, 3 -1, 3 3, -1 3, -1 -1)))",
+        polygon,
+        false);
+  }
+
+  @Test
+  public void testCrossesEmptyGeometryCollections() throws ParseException {
+    assertCrosses("GEOMETRYCOLLECTION EMPTY", "LINESTRING (0 0, 2 2)", false);
+    assertCrosses(
+        "GEOMETRYCOLLECTION (POINT EMPTY, LINESTRING EMPTY)", "LINESTRING (0 0, 2 2)", false);
+    assertCrosses("GEOMETRYCOLLECTION EMPTY", "GEOMETRYCOLLECTION EMPTY", false);
+  }
+
+  private static void assertCrosses(String leftWkt, String rightWkt, boolean expected)
+      throws ParseException {
+    Geometry left = geomFromEWKT(leftWkt);
+    Geometry right = geomFromEWKT(rightWkt);
+    assertEquals(leftWkt + " crosses " + rightWkt, expected, Predicates.crosses(left, right));
+    assertEquals(rightWkt + " crosses " + leftWkt, expected, Predicates.crosses(right, left));
+  }
+
   @Test
   public void testBoxIntersects() {
     Box2D a = new Box2D(0.0, 0.0, 5.0, 5.0);
diff --git a/docs/api/sql/Predicates/ST_Crosses.md b/docs/api/sql/Predicates/ST_Crosses.md
index eaf00ea2f68..25e18e84b5f 100644
--- a/docs/api/sql/Predicates/ST_Crosses.md
+++ b/docs/api/sql/Predicates/ST_Crosses.md
@@ -21,6 +21,8 @@
 
 Introduction: Return true if A crosses B
 
+GeometryCollection inputs are supported and evaluated as the union of their members, including mixed and nested collections. Empty geometries return `false`.
+
 ![ST_Crosses returning true](../../../image/ST_Crosses/ST_Crosses_true.svg "ST_Crosses returning true")
 ![ST_Crosses returning false](../../../image/ST_Crosses/ST_Crosses_false.svg "ST_Crosses returning false")
 
diff --git a/python/sedona/spark/geopandas/geoseries.py b/python/sedona/spark/geopandas/geoseries.py
index 9c763c7345b..be1e609bd5f 100644
--- a/python/sedona/spark/geopandas/geoseries.py
+++ b/python/sedona/spark/geopandas/geoseries.py
@@ -3008,15 +3008,10 @@ class GeoSeries(GeoFrame, pspd.Series):
         return geom
 
     def crosses(self, other, align=None) -> pspd.Series:
-        # Sedona does not support GeometryCollection (errors), so we return NULL for now to avoid error.
         other_series, extended = self._make_series_of_val(other)
         align = False if extended else align
 
-        spark_expr = F.when(
-            (stf.ST_GeometryType(F.col("L")) == "ST_GeometryCollection")
-            | (stf.ST_GeometryType(F.col("R")) == "ST_GeometryCollection"),
-            None,
-        ).otherwise(stp.ST_Crosses(F.col("L"), F.col("R")))
+        spark_expr = stp.ST_Crosses(F.col("L"), F.col("R"))
         result = self._row_wise_operation(
             spark_expr,
             other_series,
diff --git a/python/tests/geopandas/test_geoseries.py b/python/tests/geopandas/test_geoseries.py
index e5ff24455ca..a25c8b7268d 100644
--- a/python/tests/geopandas/test_geoseries.py
+++ b/python/tests/geopandas/test_geoseries.py
@@ -6094,6 +6094,30 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         result = s.to_geoframe().difference(s2.to_geoframe())
         self.check_sgpd_equals_gpd(result, expected)
 
+    def test_crosses_geometry_collections(self):
+        geometries = [
+            wkt.loads("GEOMETRYCOLLECTION (POINT (1 1), POINT (3 3))"),
+            wkt.loads("GEOMETRYCOLLECTION (POINT (10 10), LINESTRING (0 2, 2 0))"),
+            wkt.loads(
+                "GEOMETRYCOLLECTION (GEOMETRYCOLLECTION (LINESTRING (0 2, 2 0)))"
+            ),
+            wkt.loads("GEOMETRYCOLLECTION (LINESTRING (1 1, 3 3))"),
+            wkt.loads("GEOMETRYCOLLECTION (POINT (0 0))"),
+            GeometryCollection(),
+            None,
+        ]
+        line = LineString([(0, 0), (2, 2)])
+        local = gpd.GeoSeries(geometries)
+        expected = local.crosses(line)
+        assert expected.tolist() == [True, True, True, False, False, False, False]
+
+        series = GeoSeries(geometries)
+        self.check_pd_series_equal(series.crosses(line), expected)
+        self.check_pd_series_equal(series.to_geoframe().crosses(line), expected)
+
+        lines = GeoSeries([line] * len(geometries))
+        self.check_pd_series_equal(lines.crosses(series, align=False), expected)
+
     def test_crosses(self):
         s = GeoSeries(
             [
@@ -6130,9 +6154,6 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         df_result = s.to_geoframe().crosses(s2, align=False)
         self.check_pd_series_equal(df_result, expected)
 
-        # The underlying ST_Crosses expression returns NULL for GeometryCollection
-        # (https://github.com/apache/sedona/issues/2417), which the GeoSeries
-        # predicate normalizes to False to match GeoPandas' boolean contract.
         # Ensure M-dimension doesn't break things.
         s = GeoSeries(
             [
diff --git a/python/tests/geopandas/test_match_geopandas_series.py b/python/tests/geopandas/test_match_geopandas_series.py
index 05865674439..641102c0f7b 100644
--- a/python/tests/geopandas/test_match_geopandas_series.py
+++ b/python/tests/geopandas/test_match_geopandas_series.py
@@ -1946,9 +1946,6 @@ class TestMatchGeopandasSeries(TestGeopandasBase):
 
     def test_crosses(self):
         for geom, geom2 in self.pairs:
-            if self.contains_any_geom_collection(geom, geom2):
-                continue
-
             # We explicitly specify align=True to quite warnings in geopandas, despite it being the default
             gpd_result = gpd.GeoSeries(geom).crosses(gpd.GeoSeries(geom2), align=True)
             sgpd_result = GeoSeries(geom).crosses(GeoSeries(geom2), align=True)
diff --git a/spark/common/src/main/java/org/apache/sedona/core/spatialOperator/SpatialPredicateEvaluators.java b/spark/common/src/main/java/org/apache/sedona/core/spatialOperator/SpatialPredicateEvaluators.java
index b559e2b1f7d..c22963c2fef 100644
--- a/spark/common/src/main/java/org/apache/sedona/core/spatialOperator/SpatialPredicateEvaluators.java
+++ b/spark/common/src/main/java/org/apache/sedona/core/spatialOperator/SpatialPredicateEvaluators.java
@@ -19,6 +19,7 @@
 package org.apache.sedona.core.spatialOperator;
 
 import java.io.Serializable;
+import org.apache.sedona.common.Predicates;
 import org.locationtech.jts.geom.Geometry;
 import org.locationtech.jts.geom.prep.PreparedGeometry;
 
@@ -104,11 +105,11 @@ public class SpatialPredicateEvaluators {
 
   public interface CrossesEvaluator extends SpatialPredicateEvaluator {
     default boolean eval(Geometry left, Geometry right) {
-      return left.crosses(right);
+      return Predicates.crosses(left, right);
     }
 
     default boolean eval(PreparedGeometry left, Geometry right) {
-      return left.crosses(right);
+      return Predicates.crosses(left.getGeometry(), right);
     }
   }
 
diff --git a/spark/common/src/test/java/org/apache/sedona/core/spatialOperator/SpatialPredicateEvaluatorsTest.java b/spark/common/src/test/java/org/apache/sedona/core/spatialOperator/SpatialPredicateEvaluatorsTest.java
new file mode 100644
index 00000000000..0ece74e4a0d
--- /dev/null
+++ b/spark/common/src/test/java/org/apache/sedona/core/spatialOperator/SpatialPredicateEvaluatorsTest.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sedona.core.spatialOperator;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+import org.locationtech.jts.geom.Geometry;
+import org.locationtech.jts.geom.prep.PreparedGeometryFactory;
+import org.locationtech.jts.io.ParseException;
+import org.locationtech.jts.io.WKTReader;
+
+public class SpatialPredicateEvaluatorsTest {
+  @Test
+  public void testCrossesGeometryCollections() throws ParseException {
+    SpatialPredicateEvaluators.SpatialPredicateEvaluator evaluator =
+        SpatialPredicateEvaluators.create(SpatialPredicate.CROSSES);
+    WKTReader reader = new WKTReader();
+    Geometry line = reader.read("LINESTRING (0 0, 2 2)");
+    String[] collections = {
+      "GEOMETRYCOLLECTION (POINT (10 10), LINESTRING (0 2, 2 0))",
+      "GEOMETRYCOLLECTION (LINESTRING (1 1, 3 3))",
+      "GEOMETRYCOLLECTION EMPTY"
+    };
+    boolean[] expected = {true, false, false};
+    for (int i = 0; i < collections.length; i++) {
+      Geometry collection = reader.read(collections[i]);
+      assertEquals(expected[i], evaluator.eval(collection, line));
+      assertEquals(expected[i], evaluator.eval(line, collection));
+      assertEquals(expected[i], evaluator.eval(PreparedGeometryFactory.prepare(collection), line));
+      assertEquals(expected[i], evaluator.eval(PreparedGeometryFactory.prepare(line), collection));
+    }
+  }
+}
diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/predicateTestScala.scala b/spark/common/src/test/scala/org/apache/sedona/sql/predicateTestScala.scala
index 6cd0af57a00..84d8354444f 100644
--- a/spark/common/src/test/scala/org/apache/sedona/sql/predicateTestScala.scala
+++ b/spark/common/src/test/scala/org/apache/sedona/sql/predicateTestScala.scala
@@ -291,6 +291,29 @@ class predicateTestScala extends TestBaseScala {
       assert(!notCrosses.take(1)(0).get(0).asInstanceOf[Boolean])
     }
 
+    it("Passed ST_Crosses with GeometryCollections") {
+      val cases = Seq(
+        ("GEOMETRYCOLLECTION (POINT (1 1))", "LINESTRING (1 1, 1 2)", false),
+        (
+          "GEOMETRYCOLLECTION (POINT (10 10), LINESTRING (0 2, 2 0))",
+          "LINESTRING (0 0, 2 2)",
+          true),
+        ("GEOMETRYCOLLECTION EMPTY", "LINESTRING (0 0, 2 2)", false))
+      cases.foreach { case (left, right, expected) =>
+        val result = sparkSession
+          .sql(s"SELECT ST_GeomFromWKT('$left') AS a, ST_GeomFromWKT('$right') AS b")
+          .selectExpr("ST_Crosses(a, b)", "ST_Crosses(b, a)")
+          .first()
+        assert(result.getBoolean(0) == expected)
+        assert(result.getBoolean(1) == expected)
+      }
+      val nullResult = sparkSession
+        .sql("SELECT ST_GeomFromWKT('GEOMETRYCOLLECTION (POINT (1 1))') AS geom")
+        .selectExpr("ST_Crosses(geom, NULL)", "ST_Crosses(NULL, geom)")
+        .first()
+      assert(nullResult.isNullAt(0) && nullResult.isNullAt(1))
+    }
+
     it("Passed ST_Relate") {
       val baseDf = sparkSession.sql(
         "SELECT ST_GeomFromWKT('LINESTRING (1 1, 5 5)') AS g1, ST_GeomFromWKT('POLYGON ((3 3, 3 7, 7 7, 7 3, 3 3))') as g2, '1010F0212' as im")