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 86890b2d647..2df50eec8ee 100644 --- a/common/src/main/java/org/apache/sedona/common/Predicates.java +++ b/common/src/main/java/org/apache/sedona/common/Predicates.java @@ -19,6 +19,7 @@ package org.apache.sedona.common; import org.apache.sedona.common.geometryObjects.Box2D; +import org.apache.sedona.common.geometryObjects.Box3D; import org.apache.sedona.common.sphere.Spheroid; import org.locationtech.jts.geom.*; import org.locationtech.jts.operation.relate.RelateOp; @@ -77,6 +78,50 @@ private static void requireOrderedPlanarBox(Box2D box, String argName) { } } + private static void requireOrderedBox3D(Box3D box, String argName) { + if (box.getXMin() > box.getXMax() + || box.getYMin() > box.getYMax() + || box.getZMin() > box.getZMax()) { + throw new IllegalArgumentException( + "Box3D argument '" + + argName + + "' has inverted bounds (xmin > xmax, ymin > ymax, or zmin > zmax). Box3D " + + "predicates require ordered intervals on all three axes."); + } + } + + /** + * Closed-interval bbox intersection over two Box3D arguments. Returns true if the boxes overlap + * on all three axes. Mirrors PostGIS {@code &&&} on box3d. Edge-, face-, and + * corner-touching boxes count as intersecting. Throws on inverted bounds. + */ + public static boolean box3dIntersects(Box3D a, Box3D b) { + requireOrderedBox3D(a, "a"); + requireOrderedBox3D(b, "b"); + return !(a.getXMax() < b.getXMin() + || a.getXMin() > b.getXMax() + || a.getYMax() < b.getYMin() + || a.getYMin() > b.getYMax() + || a.getZMax() < b.getZMin() + || a.getZMin() > b.getZMax()); + } + + /** + * Closed-interval bbox containment over two Box3D arguments. Returns true if {@code a} fully + * contains {@code b} on all three axes. Equal boxes contain each other. Throws on + * inverted bounds. + */ + public static boolean box3dContains(Box3D a, Box3D b) { + requireOrderedBox3D(a, "a"); + requireOrderedBox3D(b, "b"); + return a.getXMin() <= b.getXMin() + && a.getYMin() <= b.getYMin() + && a.getZMin() <= b.getZMin() + && a.getXMax() >= b.getXMax() + && a.getYMax() >= b.getYMax() + && a.getZMax() >= b.getZMax(); + } + public static boolean intersects(Geometry leftGeometry, Geometry rightGeometry) { return leftGeometry.intersects(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 9a10a0aaf46..c494a90785f 100644 --- a/common/src/test/java/org/apache/sedona/common/PredicatesTest.java +++ b/common/src/test/java/org/apache/sedona/common/PredicatesTest.java @@ -23,6 +23,7 @@ import static org.junit.Assert.*; import org.apache.sedona.common.geometryObjects.Box2D; +import org.apache.sedona.common.geometryObjects.Box3D; import org.junit.Test; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; @@ -113,6 +114,43 @@ public void testBoxPredicatesRejectInvertedBounds() { assertTrue(ex2.getMessage().contains("inverted bounds")); } + @Test + public void testBox3DIntersects() { + Box3D a = new Box3D(0.0, 0.0, 0.0, 5.0, 5.0, 5.0); + + // Full overlap on all axes + assertTrue(Predicates.box3dIntersects(a, new Box3D(1.0, 1.0, 1.0, 2.0, 2.0, 2.0))); + // Partial overlap on all axes + assertTrue(Predicates.box3dIntersects(a, new Box3D(3.0, 3.0, 3.0, 7.0, 7.0, 7.0))); + // Face-touching (closed intervals) + assertTrue(Predicates.box3dIntersects(a, new Box3D(5.0, 0.0, 0.0, 10.0, 5.0, 5.0))); + // Corner-touching (closed intervals) + assertTrue(Predicates.box3dIntersects(a, new Box3D(5.0, 5.0, 5.0, 10.0, 10.0, 10.0))); + // Disjoint on Z only + assertFalse(Predicates.box3dIntersects(a, new Box3D(0.0, 0.0, 6.0, 5.0, 5.0, 10.0))); + } + + @Test + public void testBox3DContains() { + Box3D outer = new Box3D(0.0, 0.0, 0.0, 10.0, 10.0, 10.0); + + assertTrue(Predicates.box3dContains(outer, new Box3D(2.0, 2.0, 2.0, 5.0, 5.0, 5.0))); + // Equal boxes contain each other + assertTrue(Predicates.box3dContains(outer, new Box3D(0.0, 0.0, 0.0, 10.0, 10.0, 10.0))); + // Crosses on Z + assertFalse(Predicates.box3dContains(outer, new Box3D(2.0, 2.0, 2.0, 5.0, 5.0, 11.0))); + } + + @Test + public void testBox3DRejectInvertedBounds() { + Box3D normal = new Box3D(0.0, 0.0, 0.0, 5.0, 5.0, 5.0); + Box3D wrapZ = new Box3D(0.0, 0.0, 5.0, 5.0, 5.0, 0.0); // zmin > zmax + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, () -> Predicates.box3dIntersects(wrapZ, normal)); + assertTrue(ex.getMessage().contains("inverted bounds")); + } + @Test public void testDWithinSuccess() { Geometry point1 = GEOMETRY_FACTORY.createPoint(new Coordinate(1, 1)); diff --git a/python/sedona/spark/sql/st_predicates.py b/python/sedona/spark/sql/st_predicates.py index b71327f82a7..2cc2232f218 100644 --- a/python/sedona/spark/sql/st_predicates.py +++ b/python/sedona/spark/sql/st_predicates.py @@ -62,6 +62,42 @@ def ST_BoxIntersects(a: ColumnOrName, b: ColumnOrName) -> Column: return _call_predicate_function("ST_BoxIntersects", (a, b)) +@validate_argument_types +def ST_3DBoxContains(a: ColumnOrName, b: ColumnOrName) -> Column: + """Check whether Box3D a fully contains Box3D b (closed intervals on all three axes). + + Mirrors PostGIS ``~~`` on box3d. NULL on null input. Raises + ``IllegalArgumentException`` if either argument has inverted bounds + (``xmin > xmax`` / ``ymin > ymax`` / ``zmin > zmax``). + + :param a: Outer Box3D column. + :type a: ColumnOrName + :param b: Inner Box3D column. + :type b: ColumnOrName + :return: True if a contains b, false otherwise. + :rtype: Column + """ + return _call_predicate_function("ST_3DBoxContains", (a, b)) + + +@validate_argument_types +def ST_3DBoxIntersects(a: ColumnOrName, b: ColumnOrName) -> Column: + """Check whether Box3D a and Box3D b share any point (closed intervals on all three axes). + + Mirrors PostGIS ``&&&`` on box3d. NULL on null input. Raises + ``IllegalArgumentException`` if either argument has inverted bounds + (``xmin > xmax`` / ``ymin > ymax`` / ``zmin > zmax``). + + :param a: First Box3D column. + :type a: ColumnOrName + :param b: Second Box3D column. + :type b: ColumnOrName + :return: True if a and b overlap, false otherwise. + :rtype: Column + """ + return _call_predicate_function("ST_3DBoxIntersects", (a, b)) + + @validate_argument_types def ST_Contains(a: ColumnOrName, b: ColumnOrName) -> Column: """Check whether geometry a contains geometry b. diff --git a/python/tests/sql/test_dataframe_api.py b/python/tests/sql/test_dataframe_api.py index f8121e9bc3f..276b3c22db3 100644 --- a/python/tests/sql/test_dataframe_api.py +++ b/python/tests/sql/test_dataframe_api.py @@ -1210,6 +1210,26 @@ "", True, ), + ( + stp.ST_3DBoxIntersects, + ( + lambda: f.expr("ST_3DMakeBox(ST_PointZ(0, 0, 0), ST_PointZ(5, 5, 5))"), + lambda: f.expr("ST_3DMakeBox(ST_PointZ(1, 1, 1), ST_PointZ(2, 2, 2))"), + ), + "triangle_geom", + "", + True, + ), + ( + stp.ST_3DBoxContains, + ( + lambda: f.expr("ST_3DMakeBox(ST_PointZ(0, 0, 0), ST_PointZ(5, 5, 5))"), + lambda: f.expr("ST_3DMakeBox(ST_PointZ(1, 1, 1), ST_PointZ(2, 2, 2))"), + ), + "triangle_geom", + "", + True, + ), (stp.ST_Crosses, ("line", "poly"), "line_crossing_poly", "", True), (stp.ST_Disjoint, ("a", "b"), "two_points", "", True), ( diff --git a/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala b/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala index be8f6208cc1..2296501cc85 100644 --- a/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala +++ b/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala @@ -166,6 +166,8 @@ object Catalog extends AbstractCatalog with Logging { val predicateExprs: Seq[FunctionDescription] = Seq( function[ST_BoxContains](), function[ST_BoxIntersects](), + function[ST_3DBoxContains](), + function[ST_3DBoxIntersects](), function[ST_Contains](), function[ST_CoveredBy](), function[ST_Covers](), diff --git a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Predicates.scala b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Predicates.scala index 021b06f1626..ae1e562b630 100644 --- a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Predicates.scala +++ b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Predicates.scala @@ -121,6 +121,35 @@ private[apache] case class ST_BoxContains(inputExpressions: Seq[Expression]) } } +/** + * Closed-interval bbox intersection over two Box3D arguments. True if the boxes overlap on all + * three axes. Mirrors PostGIS `&&&` on box3d. Edge/face/corner-touching boxes count as + * intersecting. Throws on inverted bounds on any axis. + * + * @param inputExpressions + */ +private[apache] case class ST_3DBoxIntersects(inputExpressions: Seq[Expression]) + extends InferredExpression(Predicates.box3dIntersects _) { + + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + +/** + * Closed-interval bbox containment over two Box3D arguments. True if `a` fully contains `b` on + * all three axes. Equal boxes contain each other. Throws on inverted bounds on any axis. + * + * @param inputExpressions + */ +private[apache] case class ST_3DBoxContains(inputExpressions: Seq[Expression]) + extends InferredExpression(Predicates.box3dContains _) { + + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + /** * Test if leftGeometry full intersects rightGeometry. Supports both Geometry (JTS) and Geography * (S2) inputs via InferredExpression dual dispatch. diff --git a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_predicates.scala b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_predicates.scala index cd3ec897444..38354bd75f4 100644 --- a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_predicates.scala +++ b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_predicates.scala @@ -30,6 +30,14 @@ object st_predicates { def ST_BoxIntersects(a: Column, b: Column): Column = wrapExpression[ST_BoxIntersects](a, b) def ST_BoxIntersects(a: String, b: String): Column = wrapExpression[ST_BoxIntersects](a, b) + def ST_3DBoxContains(a: Column, b: Column): Column = wrapExpression[ST_3DBoxContains](a, b) + def ST_3DBoxContains(a: String, b: String): Column = wrapExpression[ST_3DBoxContains](a, b) + + def ST_3DBoxIntersects(a: Column, b: Column): Column = + wrapExpression[ST_3DBoxIntersects](a, b) + def ST_3DBoxIntersects(a: String, b: String): Column = + wrapExpression[ST_3DBoxIntersects](a, b) + def ST_Contains(a: Column, b: Column): Column = wrapExpression[ST_Contains](a, b) def ST_Contains(a: String, b: String): Column = wrapExpression[ST_Contains](a, b) diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/Box3DPredicateSuite.scala b/spark/common/src/test/scala/org/apache/sedona/sql/Box3DPredicateSuite.scala new file mode 100644 index 00000000000..4c11530cd13 --- /dev/null +++ b/spark/common/src/test/scala/org/apache/sedona/sql/Box3DPredicateSuite.scala @@ -0,0 +1,106 @@ +/* + * 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.sql + +class Box3DPredicateSuite extends TestBaseScala { + + describe("Box3D predicates") { + + it("ST_3DBoxIntersects covers overlap, face-, edge- and corner-touching") { + val row = sparkSession + .sql(""" + WITH t AS ( + SELECT + ST_3DMakeBox(ST_PointZ(0,0,0), ST_PointZ(5,5,5)) AS a, + ST_3DMakeBox(ST_PointZ(1,1,1), ST_PointZ(2,2,2)) AS inside, + ST_3DMakeBox(ST_PointZ(3,3,3), ST_PointZ(7,7,7)) AS overlap, + ST_3DMakeBox(ST_PointZ(5,0,0), ST_PointZ(10,5,5)) AS face, + ST_3DMakeBox(ST_PointZ(5,5,5), ST_PointZ(10,10,10)) AS corner, + ST_3DMakeBox(ST_PointZ(6,6,6), ST_PointZ(7,7,7)) AS disjoint + ) + SELECT + ST_3DBoxIntersects(a, inside), + ST_3DBoxIntersects(a, overlap), + ST_3DBoxIntersects(a, face), + ST_3DBoxIntersects(a, corner), + ST_3DBoxIntersects(a, disjoint) + FROM t + """) + .collect()(0) + assert(row.getBoolean(0)) + assert(row.getBoolean(1)) + assert(row.getBoolean(2)) + assert(row.getBoolean(3)) + assert(!row.getBoolean(4)) + } + + it("ST_3DBoxContains is closed-interval (equal boxes contain each other)") { + val row = sparkSession + .sql(""" + WITH t AS ( + SELECT + ST_3DMakeBox(ST_PointZ(0,0,0), ST_PointZ(5,5,5)) AS a, + ST_3DMakeBox(ST_PointZ(1,1,1), ST_PointZ(2,2,2)) AS inside, + ST_3DMakeBox(ST_PointZ(3,3,3), ST_PointZ(7,7,7)) AS overlap, + ST_3DMakeBox(ST_PointZ(0,0,0), ST_PointZ(5,5,5)) AS equal + ) + SELECT + ST_3DBoxContains(a, inside), + ST_3DBoxContains(a, overlap), + ST_3DBoxContains(a, equal) + FROM t + """) + .collect()(0) + assert(row.getBoolean(0)) + assert(!row.getBoolean(1)) + assert(row.getBoolean(2)) + } + + it("ST_3DBoxIntersects rejects inverted bounds") { + val ex = intercept[Exception] { + sparkSession + .sql( + "SELECT ST_3DBoxIntersects(" + + "ST_3DMakeBox(ST_PointZ(5,0,0), ST_PointZ(0,5,5)), " + + "ST_3DMakeBox(ST_PointZ(0,0,0), ST_PointZ(1,1,1)))") + .collect() + } + assert( + Iterator + .iterate(ex: Throwable)(_.getCause) + .takeWhile(_ != null) + .exists(_.isInstanceOf[IllegalArgumentException])) + } + + it("Predicates propagate NULL when either argument is NULL") { + val row = sparkSession + .sql(""" + WITH t AS ( + SELECT + ST_3DMakeBox(ST_PointZ(0,0,0), ST_PointZ(5,5,5)) AS a, + ST_3DMakeBox(ST_GeomFromText(NULL), ST_PointZ(1,1,1)) AS n + ) + SELECT ST_3DBoxIntersects(a, n), ST_3DBoxContains(a, n) FROM t + """) + .collect()(0) + assert(row.isNullAt(0)) + assert(row.isNullAt(1)) + } + } +} diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/dataFrameAPITestScala.scala b/spark/common/src/test/scala/org/apache/sedona/sql/dataFrameAPITestScala.scala index ff88f895e51..8501d9e6e8b 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/dataFrameAPITestScala.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/dataFrameAPITestScala.scala @@ -665,6 +665,26 @@ class dataFrameAPITestScala extends TestBaseScala { assert(actual == "BOX3D(0.0 0.0 0.0, 2.0 4.0 6.0)") } + it("Passed ST_3DBoxIntersects") { + val boxesDf = sparkSession.sql( + "SELECT ST_3DMakeBox(ST_PointZ(0, 0, 0), ST_PointZ(5, 5, 5)) AS a, " + + "ST_3DMakeBox(ST_PointZ(1, 1, 1), ST_PointZ(2, 2, 2)) AS b, " + + "ST_3DMakeBox(ST_PointZ(10, 10, 10), ST_PointZ(11, 11, 11)) AS c") + val row = boxesDf.select(ST_3DBoxIntersects("a", "b"), ST_3DBoxIntersects("a", "c")).first() + assert(row.getBoolean(0)) + assert(!row.getBoolean(1)) + } + + it("Passed ST_3DBoxContains") { + val boxesDf = sparkSession.sql( + "SELECT ST_3DMakeBox(ST_PointZ(0, 0, 0), ST_PointZ(5, 5, 5)) AS a, " + + "ST_3DMakeBox(ST_PointZ(1, 1, 1), ST_PointZ(2, 2, 2)) AS b, " + + "ST_3DMakeBox(ST_PointZ(4, 4, 4), ST_PointZ(6, 6, 6)) AS c") + val row = boxesDf.select(ST_3DBoxContains("a", "b"), ST_3DBoxContains("a", "c")).first() + assert(row.getBoolean(0)) + assert(!row.getBoolean(1)) + } + it("Passed ST_Expand") { val baseDf = sparkSession.sql( "SELECT ST_GeomFromWKT('POLYGON ((50 50 1, 50 80 2, 80 80 3, 80 50 2, 50 50 1))') as geom")