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
45 changes: 45 additions & 0 deletions common/src/main/java/org/apache/sedona/common/Predicates.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <em>all three</em> 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 <em>all three</em> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down
36 changes: 36 additions & 0 deletions python/sedona/spark/sql/st_predicates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions python/tests/sql/test_dataframe_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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](),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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