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