diff --git a/common/src/main/java/org/apache/sedona/common/Constructors.java b/common/src/main/java/org/apache/sedona/common/Constructors.java index d22dd62f411..e04a0a9caaa 100644 --- a/common/src/main/java/org/apache/sedona/common/Constructors.java +++ b/common/src/main/java/org/apache/sedona/common/Constructors.java @@ -25,6 +25,7 @@ import org.apache.sedona.common.enums.FileDataSplitter; import org.apache.sedona.common.enums.GeometryType; import org.apache.sedona.common.geometryObjects.Box2D; +import org.apache.sedona.common.geometryObjects.Box3D; import org.apache.sedona.common.utils.FormatUtils; import org.apache.sedona.common.utils.GeoHashDecoder; import org.locationtech.jts.geom.*; @@ -342,6 +343,29 @@ public static Box2D makeBox2D(Geometry lowerLeft, Geometry upperRight) { return new Box2D(ll.getX(), ll.getY(), ur.getX(), ur.getY()); } + /** + * Build a {@link Box3D} from two corner POINT Z geometries. Mirrors PostGIS's {@code + * ST_3DMakeBox}. The corners are taken verbatim — no swapping or validation of ordering — so + * inverted bounds are preserved as supplied. POINT inputs without a Z dimension contribute {@code + * z = 0}, matching PostGIS. NULL or empty point inputs return NULL. + */ + public static Box3D make3DBox(Geometry lowerLeft, Geometry upperRight) { + if (lowerLeft == null || upperRight == null) { + return null; + } + if (!(lowerLeft instanceof Point) || !(upperRight instanceof Point)) { + throw new IllegalArgumentException("ST_3DMakeBox requires two POINT geometries"); + } + if (lowerLeft.isEmpty() || upperRight.isEmpty()) { + return null; + } + Point ll = (Point) lowerLeft; + Point ur = (Point) upperRight; + double llZ = Double.isNaN(ll.getCoordinate().getZ()) ? 0.0 : ll.getCoordinate().getZ(); + double urZ = Double.isNaN(ur.getCoordinate().getZ()) ? 0.0 : ur.getCoordinate().getZ(); + return new Box3D(ll.getX(), ll.getY(), llZ, ur.getX(), ur.getY(), urZ); + } + public static Geometry geomFromGeoHash(String geoHash, Integer precision) { try { return GeoHashDecoder.decode(geoHash, precision); diff --git a/common/src/main/java/org/apache/sedona/common/Functions.java b/common/src/main/java/org/apache/sedona/common/Functions.java index 325abcef51d..50129cfccbc 100644 --- a/common/src/main/java/org/apache/sedona/common/Functions.java +++ b/common/src/main/java/org/apache/sedona/common/Functions.java @@ -31,6 +31,7 @@ import org.apache.sedona.common.S2Geography.Geography; import org.apache.sedona.common.approximate.StraightSkeleton; import org.apache.sedona.common.geometryObjects.Box2D; +import org.apache.sedona.common.geometryObjects.Box3D; import org.apache.sedona.common.geometryObjects.Circle; import org.apache.sedona.common.jts2geojson.GeoJSONWriter; import org.apache.sedona.common.sphere.Spheroid; @@ -626,6 +627,10 @@ public static Box2D box2D(Geometry geometry) { return Box2D.fromGeometry(geometry); } + public static Box3D box3D(Geometry geometry) { + return Box3D.fromGeometry(geometry); + } + public static Double distance(Geometry left, Geometry right) { if (left.isEmpty() || right.isEmpty()) { return null; diff --git a/python/sedona/spark/sql/st_constructors.py b/python/sedona/spark/sql/st_constructors.py index b0a5b366108..51253b8c9e3 100644 --- a/python/sedona/spark/sql/st_constructors.py +++ b/python/sedona/spark/sql/st_constructors.py @@ -570,6 +570,27 @@ def ST_MakeBox2D( return _call_constructor_function("ST_MakeBox2D", (lower_left, upper_right)) +@validate_argument_types +def ST_3DMakeBox( + lower_left: ColumnOrName, + upper_right: ColumnOrName, +) -> Column: + """Construct a Box3D from two corner POINTZ geometries. + + Coordinates are taken verbatim — no swapping or ordering validation. + Point inputs without a Z dimension contribute ``z = 0``. NULL or empty + point inputs return NULL. Non-point inputs raise an error. + + :param lower_left: Lower-left corner Point (POINTZ; missing Z treated as 0). + :type lower_left: ColumnOrName + :param upper_right: Upper-right corner Point (POINTZ; missing Z treated as 0). + :type upper_right: ColumnOrName + :return: Box3D column. + :rtype: Column + """ + return _call_constructor_function("ST_3DMakeBox", (lower_left, upper_right)) + + @validate_argument_types def ST_GeomFromBox2D(box: ColumnOrName) -> Column: """Convert a Box2D to a Geometry. diff --git a/python/sedona/spark/sql/st_functions.py b/python/sedona/spark/sql/st_functions.py index 373b1433b83..9fe302e7845 100644 --- a/python/sedona/spark/sql/st_functions.py +++ b/python/sedona/spark/sql/st_functions.py @@ -663,6 +663,22 @@ def ST_Box2D(geometry: ColumnOrName) -> Column: return _call_st_function("ST_Box2D", geometry) +@validate_argument_types +def ST_Box3D(geometry: ColumnOrName) -> Column: + """Get the 3D bounding box (Box3D) of a geometry. + + Geometries without a Z dimension are treated as having ``z = 0``, matching + PostGIS's flat-XY-treated-as-XY[Z=0] convention. Returns NULL for null or + empty input. + + :param geometry: Geometry column to compute the 3D bounding box of. + :type geometry: ColumnOrName + :return: Box3D bounding box of the geometry. + :rtype: Column + """ + return _call_st_function("ST_Box3D", geometry) + + @validate_argument_types def ST_Envelope(geometry: ColumnOrName) -> Column: """Calculate the envelope boundary of a geometry column. diff --git a/python/tests/sql/test_dataframe_api.py b/python/tests/sql/test_dataframe_api.py index 3d11e23471e..f8121e9bc3f 100644 --- a/python/tests/sql/test_dataframe_api.py +++ b/python/tests/sql/test_dataframe_api.py @@ -177,6 +177,14 @@ # two_points has a=(0,0,0), b=(3,0,4); ST_MakeBox2D drops Z, so y is 0 for both. Box2D(0.0, 0.0, 3.0, 0.0), ), + ( + stc.ST_3DMakeBox, + ("a", "b"), + "two_points", + # Box3DType has no Python UDT yet; cast to STRING uses Box3D.toString for comparison. + "CAST(geom AS STRING)", + "BOX3D(0.0 0.0 0.0, 3.0 0.0 4.0)", + ), ( stc.ST_GeomFromBox2D, ( @@ -547,6 +555,15 @@ "", Box2D(0.0, 0.0, 5.0, 0.0), ), + ( + stf.ST_Box3D, + ("line",), + "linestring_geom", + # Box3DType has no Python UDT yet; cast to STRING uses Box3D.toString for comparison. + "CAST(geom AS STRING)", + # linestring_geom is 2D so Z folds to 0 per PostGIS semantics. + "BOX3D(0.0 0.0 0.0, 5.0 0.0 0.0)", + ), ( stf.ST_Envelope, ("geom",), 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 8119fb4d064..be8f6208cc1 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 @@ -58,6 +58,7 @@ object Catalog extends AbstractCatalog with Logging { function[ST_GeomFromWKB](), function[ST_GeomFromEWKB](), function[ST_GeomFromBox2D](), + function[ST_3DMakeBox](), function[ST_GeomFromGeoJSON](), function[ST_GeomFromGML](), function[ST_GeomFromKML](), @@ -283,6 +284,7 @@ object Catalog extends AbstractCatalog with Logging { val boundingBoxExprs: Seq[FunctionDescription] = Seq( function[ST_BoundingDiagonal](), function[ST_Box2D](), + function[ST_Box3D](), function[ST_Envelope](), function[ST_Expand](), function[ST_MMax](), diff --git a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Constructors.scala b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Constructors.scala index beac34727dd..5b5ebde8de5 100644 --- a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Constructors.scala +++ b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Constructors.scala @@ -552,6 +552,21 @@ private[apache] case class ST_MakeBox2D(inputExpressions: Seq[Expression]) } } +/** + * Construct a Box3D from two corner POINT Z geometries. Mirrors PostGIS `ST_3DMakeBox`. + * Coordinates are taken verbatim; ordering is not validated. POINT inputs without a Z dimension + * contribute `z = 0`. + * + * @param inputExpressions + */ +private[apache] case class ST_3DMakeBox(inputExpressions: Seq[Expression]) + extends InferredExpression(Constructors.make3DBox _) { + + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + /** * Convert a Box2D to a closed rectangular polygon Geometry. Equivalent to PostGIS {@code * box2d::geometry}. `CAST(box AS geometry)` is also accepted (resolved to this expression by the diff --git a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala index 6af47e51603..78dede700ed 100644 --- a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala +++ b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala @@ -19,7 +19,7 @@ package org.apache.spark.sql.sedona_sql.expressions import org.apache.sedona.common.{Functions, FunctionsGeoTools, FunctionsProj4} -import org.apache.sedona.common.geometryObjects.Box2D +import org.apache.sedona.common.geometryObjects.{Box2D, Box3D} import org.apache.sedona.common.sphere.{Haversine, Spheroid} import org.apache.sedona.common.utils.{InscribedCircle, ValidDetail} import org.apache.sedona.core.utils.SedonaConf @@ -258,6 +258,21 @@ private[apache] case class ST_Box2D(inputExpressions: Seq[Expression]) } } +/** + * Return the 3D bounding box (Box3D) of a Geometry. Mirrors PostGIS `Box3D(geometry)`. Returns + * NULL for null or empty input. Geometries that have no Z dimension are treated as having `z = 0` + * (PostGIS-compatible). + * + * @param inputExpressions + */ +private[apache] case class ST_Box3D(inputExpressions: Seq[Expression]) + extends InferredExpression(Functions.box3D _) { + + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + private[apache] case class ST_Expand(inputExpressions: Seq[Expression]) extends InferredExpression( inferrableFunction4((g: Geometry, dx: Double, dy: Double, dz: Double) => diff --git a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_constructors.scala b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_constructors.scala index 8b53c508596..7ea930186db 100644 --- a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_constructors.scala +++ b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_constructors.scala @@ -258,6 +258,11 @@ object st_constructors { def ST_MakeBox2D(lowerLeft: String, upperRight: String): Column = wrapExpression[ST_MakeBox2D](lowerLeft, upperRight) + def ST_3DMakeBox(lowerLeft: Column, upperRight: Column): Column = + wrapExpression[ST_3DMakeBox](lowerLeft, upperRight) + def ST_3DMakeBox(lowerLeft: String, upperRight: String): Column = + wrapExpression[ST_3DMakeBox](lowerLeft, upperRight) + def ST_GeomFromBox2D(box: Column): Column = wrapExpression[ST_GeomFromBox2D](box) def ST_GeomFromBox2D(box: String): Column = wrapExpression[ST_GeomFromBox2D](box) diff --git a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala index 71fceeaad32..4d93f5440f4 100644 --- a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala +++ b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala @@ -208,6 +208,9 @@ object st_functions { def ST_Box2D(geometry: Column): Column = wrapExpression[ST_Box2D](geometry) def ST_Box2D(geometry: String): Column = wrapExpression[ST_Box2D](geometry) + def ST_Box3D(geometry: Column): Column = wrapExpression[ST_Box3D](geometry) + def ST_Box3D(geometry: String): Column = wrapExpression[ST_Box3D](geometry) + def ST_Envelope(geometry: Column): Column = wrapExpression[ST_Envelope](geometry) def ST_Envelope(geometry: String): Column = wrapExpression[ST_Envelope](geometry) diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/Box3DConstructorSuite.scala b/spark/common/src/test/scala/org/apache/sedona/sql/Box3DConstructorSuite.scala new file mode 100644 index 00000000000..bc9b8ec8a33 --- /dev/null +++ b/spark/common/src/test/scala/org/apache/sedona/sql/Box3DConstructorSuite.scala @@ -0,0 +1,84 @@ +/* + * 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 + +import org.apache.sedona.common.geometryObjects.Box3D + +class Box3DConstructorSuite extends TestBaseScala { + + describe("Box3D constructors") { + + it("ST_Box3D returns the 3D bbox, defaulting Z=0 for XY input") { + val row = sparkSession + .sql("SELECT ST_Box3D(ST_GeomFromText('LINESTRING(0 0, 10 20)')) AS box3d_xy, " + + "ST_Box3D(ST_GeomFromWKT('LINESTRING Z(0 0 -3, 5 10 7)')) AS box3d_xyz") + .collect()(0) + assert(row.getAs[Box3D]("box3d_xy") == new Box3D(0, 0, 0, 10, 20, 0)) + assert(row.getAs[Box3D]("box3d_xyz") == new Box3D(0, 0, -3, 5, 10, 7)) + } + + it("ST_Box3D returns NULL for null and empty input") { + val row = sparkSession + .sql("SELECT ST_Box3D(ST_GeomFromText(NULL)) AS b_null, " + + "ST_Box3D(ST_GeomFromText('LINESTRING EMPTY')) AS b_empty") + .collect()(0) + assert(row.isNullAt(0)) + assert(row.isNullAt(1)) + } + + it("ST_3DMakeBox builds a Box3D from two POINTZ corners") { + val row = sparkSession + .sql("SELECT ST_3DMakeBox(ST_PointZ(0, 0, 0), ST_PointZ(2, 4, 6)) AS b") + .collect()(0) + assert(row.getAs[Box3D]("b") == new Box3D(0, 0, 0, 2, 4, 6)) + } + + it("ST_3DMakeBox treats missing Z as 0") { + val row = sparkSession + .sql("SELECT ST_3DMakeBox(ST_Point(0, 0), ST_Point(2, 4)) AS b") + .collect()(0) + assert(row.getAs[Box3D]("b") == new Box3D(0, 0, 0, 2, 4, 0)) + } + + it("ST_3DMakeBox returns NULL for null point input") { + val row = sparkSession + .sql("SELECT ST_3DMakeBox(ST_GeomFromText(NULL), ST_PointZ(2, 4, 6)) AS b") + .collect()(0) + assert(row.isNullAt(0)) + } + + it("ST_3DMakeBox returns NULL for empty point input") { + val row = sparkSession + .sql("SELECT ST_3DMakeBox(ST_GeomFromText('POINT EMPTY'), ST_PointZ(2, 4, 6)) AS b") + .collect()(0) + assert(row.isNullAt(0)) + } + + it("ST_3DMakeBox throws for non-POINT input") { + val thrown = intercept[Exception] { + sparkSession + .sql("SELECT ST_3DMakeBox(ST_GeomFromText('LINESTRING(0 0, 1 1)'), ST_PointZ(2, 4, 6))") + .collect() + } + val messages = + Iterator.iterate[Throwable](thrown)(_.getCause).takeWhile(_ != null).map(_.getMessage) + assert(messages.exists(m => m != null && m.contains("requires two POINT geometries"))) + } + } +} 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 c14608bc31c..ff88f895e51 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 @@ -651,6 +651,20 @@ class dataFrameAPITestScala extends TestBaseScala { assert(actualResult == expectedResult) } + it("Passed ST_Box3D") { + val geomDf = + sparkSession.sql("SELECT ST_GeomFromWKT('LINESTRING Z(0 0 -3, 5 10 7)') AS geom") + val actual = geomDf.select(ST_Box3D("geom")).first().get(0).toString + assert(actual == "BOX3D(0.0 0.0 -3.0, 5.0 10.0 7.0)") + } + + it("Passed ST_3DMakeBox") { + val pointsDf = + sparkSession.sql("SELECT ST_PointZ(0.0, 0.0, 0.0) AS ll, ST_PointZ(2.0, 4.0, 6.0) AS ur") + val actual = pointsDf.select(ST_3DMakeBox("ll", "ur")).first().get(0).toString + assert(actual == "BOX3D(0.0 0.0 0.0, 2.0 4.0 6.0)") + } + 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")