Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions common/src/main/java/org/apache/sedona/common/Constructors.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions python/sedona/spark/sql/st_constructors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions python/sedona/spark/sql/st_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions python/tests/sql/test_dataframe_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
(
Expand Down Expand Up @@ -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",),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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](),
Expand Down Expand Up @@ -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](),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
Comment thread
jiayuasu marked this conversation as resolved.

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")))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading