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`.
+


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")