Skip to content

Commit 5f40925

Browse files
authored
[GH-3012] Box3D predicates: ST_3DBoxIntersects and ST_3DBoxContains (#3014)
1 parent 6623b58 commit 5f40925

9 files changed

Lines changed: 304 additions & 0 deletions

File tree

common/src/main/java/org/apache/sedona/common/Predicates.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
package org.apache.sedona.common;
2020

2121
import org.apache.sedona.common.geometryObjects.Box2D;
22+
import org.apache.sedona.common.geometryObjects.Box3D;
2223
import org.apache.sedona.common.sphere.Spheroid;
2324
import org.locationtech.jts.geom.*;
2425
import org.locationtech.jts.operation.relate.RelateOp;
@@ -77,6 +78,50 @@ private static void requireOrderedPlanarBox(Box2D box, String argName) {
7778
}
7879
}
7980

81+
private static void requireOrderedBox3D(Box3D box, String argName) {
82+
if (box.getXMin() > box.getXMax()
83+
|| box.getYMin() > box.getYMax()
84+
|| box.getZMin() > box.getZMax()) {
85+
throw new IllegalArgumentException(
86+
"Box3D argument '"
87+
+ argName
88+
+ "' has inverted bounds (xmin > xmax, ymin > ymax, or zmin > zmax). Box3D "
89+
+ "predicates require ordered intervals on all three axes.");
90+
}
91+
}
92+
93+
/**
94+
* Closed-interval bbox intersection over two Box3D arguments. Returns true if the boxes overlap
95+
* on <em>all three</em> axes. Mirrors PostGIS {@code &&&} on box3d. Edge-, face-, and
96+
* corner-touching boxes count as intersecting. Throws on inverted bounds.
97+
*/
98+
public static boolean box3dIntersects(Box3D a, Box3D b) {
99+
requireOrderedBox3D(a, "a");
100+
requireOrderedBox3D(b, "b");
101+
return !(a.getXMax() < b.getXMin()
102+
|| a.getXMin() > b.getXMax()
103+
|| a.getYMax() < b.getYMin()
104+
|| a.getYMin() > b.getYMax()
105+
|| a.getZMax() < b.getZMin()
106+
|| a.getZMin() > b.getZMax());
107+
}
108+
109+
/**
110+
* Closed-interval bbox containment over two Box3D arguments. Returns true if {@code a} fully
111+
* contains {@code b} on <em>all three</em> axes. Equal boxes contain each other. Throws on
112+
* inverted bounds.
113+
*/
114+
public static boolean box3dContains(Box3D a, Box3D b) {
115+
requireOrderedBox3D(a, "a");
116+
requireOrderedBox3D(b, "b");
117+
return a.getXMin() <= b.getXMin()
118+
&& a.getYMin() <= b.getYMin()
119+
&& a.getZMin() <= b.getZMin()
120+
&& a.getXMax() >= b.getXMax()
121+
&& a.getYMax() >= b.getYMax()
122+
&& a.getZMax() >= b.getZMax();
123+
}
124+
80125
public static boolean intersects(Geometry leftGeometry, Geometry rightGeometry) {
81126
return leftGeometry.intersects(rightGeometry);
82127
}

common/src/test/java/org/apache/sedona/common/PredicatesTest.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import static org.junit.Assert.*;
2424

2525
import org.apache.sedona.common.geometryObjects.Box2D;
26+
import org.apache.sedona.common.geometryObjects.Box3D;
2627
import org.junit.Test;
2728
import org.locationtech.jts.geom.Coordinate;
2829
import org.locationtech.jts.geom.Geometry;
@@ -113,6 +114,43 @@ public void testBoxPredicatesRejectInvertedBounds() {
113114
assertTrue(ex2.getMessage().contains("inverted bounds"));
114115
}
115116

117+
@Test
118+
public void testBox3DIntersects() {
119+
Box3D a = new Box3D(0.0, 0.0, 0.0, 5.0, 5.0, 5.0);
120+
121+
// Full overlap on all axes
122+
assertTrue(Predicates.box3dIntersects(a, new Box3D(1.0, 1.0, 1.0, 2.0, 2.0, 2.0)));
123+
// Partial overlap on all axes
124+
assertTrue(Predicates.box3dIntersects(a, new Box3D(3.0, 3.0, 3.0, 7.0, 7.0, 7.0)));
125+
// Face-touching (closed intervals)
126+
assertTrue(Predicates.box3dIntersects(a, new Box3D(5.0, 0.0, 0.0, 10.0, 5.0, 5.0)));
127+
// Corner-touching (closed intervals)
128+
assertTrue(Predicates.box3dIntersects(a, new Box3D(5.0, 5.0, 5.0, 10.0, 10.0, 10.0)));
129+
// Disjoint on Z only
130+
assertFalse(Predicates.box3dIntersects(a, new Box3D(0.0, 0.0, 6.0, 5.0, 5.0, 10.0)));
131+
}
132+
133+
@Test
134+
public void testBox3DContains() {
135+
Box3D outer = new Box3D(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
136+
137+
assertTrue(Predicates.box3dContains(outer, new Box3D(2.0, 2.0, 2.0, 5.0, 5.0, 5.0)));
138+
// Equal boxes contain each other
139+
assertTrue(Predicates.box3dContains(outer, new Box3D(0.0, 0.0, 0.0, 10.0, 10.0, 10.0)));
140+
// Crosses on Z
141+
assertFalse(Predicates.box3dContains(outer, new Box3D(2.0, 2.0, 2.0, 5.0, 5.0, 11.0)));
142+
}
143+
144+
@Test
145+
public void testBox3DRejectInvertedBounds() {
146+
Box3D normal = new Box3D(0.0, 0.0, 0.0, 5.0, 5.0, 5.0);
147+
Box3D wrapZ = new Box3D(0.0, 0.0, 5.0, 5.0, 5.0, 0.0); // zmin > zmax
148+
IllegalArgumentException ex =
149+
assertThrows(
150+
IllegalArgumentException.class, () -> Predicates.box3dIntersects(wrapZ, normal));
151+
assertTrue(ex.getMessage().contains("inverted bounds"));
152+
}
153+
116154
@Test
117155
public void testDWithinSuccess() {
118156
Geometry point1 = GEOMETRY_FACTORY.createPoint(new Coordinate(1, 1));

python/sedona/spark/sql/st_predicates.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,42 @@ def ST_BoxIntersects(a: ColumnOrName, b: ColumnOrName) -> Column:
6262
return _call_predicate_function("ST_BoxIntersects", (a, b))
6363

6464

65+
@validate_argument_types
66+
def ST_3DBoxContains(a: ColumnOrName, b: ColumnOrName) -> Column:
67+
"""Check whether Box3D a fully contains Box3D b (closed intervals on all three axes).
68+
69+
Mirrors PostGIS ``~~`` on box3d. NULL on null input. Raises
70+
``IllegalArgumentException`` if either argument has inverted bounds
71+
(``xmin > xmax`` / ``ymin > ymax`` / ``zmin > zmax``).
72+
73+
:param a: Outer Box3D column.
74+
:type a: ColumnOrName
75+
:param b: Inner Box3D column.
76+
:type b: ColumnOrName
77+
:return: True if a contains b, false otherwise.
78+
:rtype: Column
79+
"""
80+
return _call_predicate_function("ST_3DBoxContains", (a, b))
81+
82+
83+
@validate_argument_types
84+
def ST_3DBoxIntersects(a: ColumnOrName, b: ColumnOrName) -> Column:
85+
"""Check whether Box3D a and Box3D b share any point (closed intervals on all three axes).
86+
87+
Mirrors PostGIS ``&&&`` on box3d. NULL on null input. Raises
88+
``IllegalArgumentException`` if either argument has inverted bounds
89+
(``xmin > xmax`` / ``ymin > ymax`` / ``zmin > zmax``).
90+
91+
:param a: First Box3D column.
92+
:type a: ColumnOrName
93+
:param b: Second Box3D column.
94+
:type b: ColumnOrName
95+
:return: True if a and b overlap, false otherwise.
96+
:rtype: Column
97+
"""
98+
return _call_predicate_function("ST_3DBoxIntersects", (a, b))
99+
100+
65101
@validate_argument_types
66102
def ST_Contains(a: ColumnOrName, b: ColumnOrName) -> Column:
67103
"""Check whether geometry a contains geometry b.

python/tests/sql/test_dataframe_api.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,6 +1210,26 @@
12101210
"",
12111211
True,
12121212
),
1213+
(
1214+
stp.ST_3DBoxIntersects,
1215+
(
1216+
lambda: f.expr("ST_3DMakeBox(ST_PointZ(0, 0, 0), ST_PointZ(5, 5, 5))"),
1217+
lambda: f.expr("ST_3DMakeBox(ST_PointZ(1, 1, 1), ST_PointZ(2, 2, 2))"),
1218+
),
1219+
"triangle_geom",
1220+
"",
1221+
True,
1222+
),
1223+
(
1224+
stp.ST_3DBoxContains,
1225+
(
1226+
lambda: f.expr("ST_3DMakeBox(ST_PointZ(0, 0, 0), ST_PointZ(5, 5, 5))"),
1227+
lambda: f.expr("ST_3DMakeBox(ST_PointZ(1, 1, 1), ST_PointZ(2, 2, 2))"),
1228+
),
1229+
"triangle_geom",
1230+
"",
1231+
True,
1232+
),
12131233
(stp.ST_Crosses, ("line", "poly"), "line_crossing_poly", "", True),
12141234
(stp.ST_Disjoint, ("a", "b"), "two_points", "", True),
12151235
(

spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ object Catalog extends AbstractCatalog with Logging {
166166
val predicateExprs: Seq[FunctionDescription] = Seq(
167167
function[ST_BoxContains](),
168168
function[ST_BoxIntersects](),
169+
function[ST_3DBoxContains](),
170+
function[ST_3DBoxIntersects](),
169171
function[ST_Contains](),
170172
function[ST_CoveredBy](),
171173
function[ST_Covers](),

spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Predicates.scala

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,35 @@ private[apache] case class ST_BoxContains(inputExpressions: Seq[Expression])
121121
}
122122
}
123123

124+
/**
125+
* Closed-interval bbox intersection over two Box3D arguments. True if the boxes overlap on all
126+
* three axes. Mirrors PostGIS `&&&` on box3d. Edge/face/corner-touching boxes count as
127+
* intersecting. Throws on inverted bounds on any axis.
128+
*
129+
* @param inputExpressions
130+
*/
131+
private[apache] case class ST_3DBoxIntersects(inputExpressions: Seq[Expression])
132+
extends InferredExpression(Predicates.box3dIntersects _) {
133+
134+
protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
135+
copy(inputExpressions = newChildren)
136+
}
137+
}
138+
139+
/**
140+
* Closed-interval bbox containment over two Box3D arguments. True if `a` fully contains `b` on
141+
* all three axes. Equal boxes contain each other. Throws on inverted bounds on any axis.
142+
*
143+
* @param inputExpressions
144+
*/
145+
private[apache] case class ST_3DBoxContains(inputExpressions: Seq[Expression])
146+
extends InferredExpression(Predicates.box3dContains _) {
147+
148+
protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
149+
copy(inputExpressions = newChildren)
150+
}
151+
}
152+
124153
/**
125154
* Test if leftGeometry full intersects rightGeometry. Supports both Geometry (JTS) and Geography
126155
* (S2) inputs via InferredExpression dual dispatch.

spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_predicates.scala

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ object st_predicates {
3030
def ST_BoxIntersects(a: Column, b: Column): Column = wrapExpression[ST_BoxIntersects](a, b)
3131
def ST_BoxIntersects(a: String, b: String): Column = wrapExpression[ST_BoxIntersects](a, b)
3232

33+
def ST_3DBoxContains(a: Column, b: Column): Column = wrapExpression[ST_3DBoxContains](a, b)
34+
def ST_3DBoxContains(a: String, b: String): Column = wrapExpression[ST_3DBoxContains](a, b)
35+
36+
def ST_3DBoxIntersects(a: Column, b: Column): Column =
37+
wrapExpression[ST_3DBoxIntersects](a, b)
38+
def ST_3DBoxIntersects(a: String, b: String): Column =
39+
wrapExpression[ST_3DBoxIntersects](a, b)
40+
3341
def ST_Contains(a: Column, b: Column): Column = wrapExpression[ST_Contains](a, b)
3442
def ST_Contains(a: String, b: String): Column = wrapExpression[ST_Contains](a, b)
3543

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.sedona.sql
20+
21+
class Box3DPredicateSuite extends TestBaseScala {
22+
23+
describe("Box3D predicates") {
24+
25+
it("ST_3DBoxIntersects covers overlap, face-, edge- and corner-touching") {
26+
val row = sparkSession
27+
.sql("""
28+
WITH t AS (
29+
SELECT
30+
ST_3DMakeBox(ST_PointZ(0,0,0), ST_PointZ(5,5,5)) AS a,
31+
ST_3DMakeBox(ST_PointZ(1,1,1), ST_PointZ(2,2,2)) AS inside,
32+
ST_3DMakeBox(ST_PointZ(3,3,3), ST_PointZ(7,7,7)) AS overlap,
33+
ST_3DMakeBox(ST_PointZ(5,0,0), ST_PointZ(10,5,5)) AS face,
34+
ST_3DMakeBox(ST_PointZ(5,5,5), ST_PointZ(10,10,10)) AS corner,
35+
ST_3DMakeBox(ST_PointZ(6,6,6), ST_PointZ(7,7,7)) AS disjoint
36+
)
37+
SELECT
38+
ST_3DBoxIntersects(a, inside),
39+
ST_3DBoxIntersects(a, overlap),
40+
ST_3DBoxIntersects(a, face),
41+
ST_3DBoxIntersects(a, corner),
42+
ST_3DBoxIntersects(a, disjoint)
43+
FROM t
44+
""")
45+
.collect()(0)
46+
assert(row.getBoolean(0))
47+
assert(row.getBoolean(1))
48+
assert(row.getBoolean(2))
49+
assert(row.getBoolean(3))
50+
assert(!row.getBoolean(4))
51+
}
52+
53+
it("ST_3DBoxContains is closed-interval (equal boxes contain each other)") {
54+
val row = sparkSession
55+
.sql("""
56+
WITH t AS (
57+
SELECT
58+
ST_3DMakeBox(ST_PointZ(0,0,0), ST_PointZ(5,5,5)) AS a,
59+
ST_3DMakeBox(ST_PointZ(1,1,1), ST_PointZ(2,2,2)) AS inside,
60+
ST_3DMakeBox(ST_PointZ(3,3,3), ST_PointZ(7,7,7)) AS overlap,
61+
ST_3DMakeBox(ST_PointZ(0,0,0), ST_PointZ(5,5,5)) AS equal
62+
)
63+
SELECT
64+
ST_3DBoxContains(a, inside),
65+
ST_3DBoxContains(a, overlap),
66+
ST_3DBoxContains(a, equal)
67+
FROM t
68+
""")
69+
.collect()(0)
70+
assert(row.getBoolean(0))
71+
assert(!row.getBoolean(1))
72+
assert(row.getBoolean(2))
73+
}
74+
75+
it("ST_3DBoxIntersects rejects inverted bounds") {
76+
val ex = intercept[Exception] {
77+
sparkSession
78+
.sql(
79+
"SELECT ST_3DBoxIntersects(" +
80+
"ST_3DMakeBox(ST_PointZ(5,0,0), ST_PointZ(0,5,5)), " +
81+
"ST_3DMakeBox(ST_PointZ(0,0,0), ST_PointZ(1,1,1)))")
82+
.collect()
83+
}
84+
assert(
85+
Iterator
86+
.iterate(ex: Throwable)(_.getCause)
87+
.takeWhile(_ != null)
88+
.exists(_.isInstanceOf[IllegalArgumentException]))
89+
}
90+
91+
it("Predicates propagate NULL when either argument is NULL") {
92+
val row = sparkSession
93+
.sql("""
94+
WITH t AS (
95+
SELECT
96+
ST_3DMakeBox(ST_PointZ(0,0,0), ST_PointZ(5,5,5)) AS a,
97+
ST_3DMakeBox(ST_GeomFromText(NULL), ST_PointZ(1,1,1)) AS n
98+
)
99+
SELECT ST_3DBoxIntersects(a, n), ST_3DBoxContains(a, n) FROM t
100+
""")
101+
.collect()(0)
102+
assert(row.isNullAt(0))
103+
assert(row.isNullAt(1))
104+
}
105+
}
106+
}

spark/common/src/test/scala/org/apache/sedona/sql/dataFrameAPITestScala.scala

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,26 @@ class dataFrameAPITestScala extends TestBaseScala {
665665
assert(actual == "BOX3D(0.0 0.0 0.0, 2.0 4.0 6.0)")
666666
}
667667

668+
it("Passed ST_3DBoxIntersects") {
669+
val boxesDf = sparkSession.sql(
670+
"SELECT ST_3DMakeBox(ST_PointZ(0, 0, 0), ST_PointZ(5, 5, 5)) AS a, " +
671+
"ST_3DMakeBox(ST_PointZ(1, 1, 1), ST_PointZ(2, 2, 2)) AS b, " +
672+
"ST_3DMakeBox(ST_PointZ(10, 10, 10), ST_PointZ(11, 11, 11)) AS c")
673+
val row = boxesDf.select(ST_3DBoxIntersects("a", "b"), ST_3DBoxIntersects("a", "c")).first()
674+
assert(row.getBoolean(0))
675+
assert(!row.getBoolean(1))
676+
}
677+
678+
it("Passed ST_3DBoxContains") {
679+
val boxesDf = sparkSession.sql(
680+
"SELECT ST_3DMakeBox(ST_PointZ(0, 0, 0), ST_PointZ(5, 5, 5)) AS a, " +
681+
"ST_3DMakeBox(ST_PointZ(1, 1, 1), ST_PointZ(2, 2, 2)) AS b, " +
682+
"ST_3DMakeBox(ST_PointZ(4, 4, 4), ST_PointZ(6, 6, 6)) AS c")
683+
val row = boxesDf.select(ST_3DBoxContains("a", "b"), ST_3DBoxContains("a", "c")).first()
684+
assert(row.getBoolean(0))
685+
assert(!row.getBoolean(1))
686+
}
687+
668688
it("Passed ST_Expand") {
669689
val baseDf = sparkSession.sql(
670690
"SELECT ST_GeomFromWKT('POLYGON ((50 50 1, 50 80 2, 80 80 3, 80 50 2, 50 50 1))') as geom")

0 commit comments

Comments
 (0)