You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sedona.apache.org by ji...@apache.org on 2026/06/03 05:49:11 UTC
(sedona) branch master updated: [GH-3024] ST_3DDWithin: 3D distance-within predicate (#3026)
This is an automated email from the ASF dual-hosted git repository.
jiayuasu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sedona.git
The following commit(s) were added to refs/heads/master by this push:
new 4752c6197f [GH-3024] ST_3DDWithin: 3D distance-within predicate (#3026)
4752c6197f is described below
commit 4752c6197f8cb25cc2e851c0c4663b22142e0199
Author: Jia Yu <ji...@apache.org>
AuthorDate: Tue Jun 2 22:49:06 2026 -0700
[GH-3024] ST_3DDWithin: 3D distance-within predicate (#3026)
---
.../java/org/apache/sedona/common/Predicates.java | 37 ++++++++
.../org/apache/sedona/common/PredicatesTest.java | 100 ++++++++++++++++++++
python/sedona/spark/sql/st_predicates.py | 25 +++++
python/tests/sql/test_dataframe_api.py | 12 +++
.../scala/org/apache/sedona/sql/UDF/Catalog.scala | 1 +
.../sql/sedona_sql/expressions/Predicates.scala | 14 +++
.../sql/sedona_sql/expressions/st_predicates.scala | 5 +
.../org/apache/sedona/sql/Box3DDWithinSuite.scala | 104 +++++++++++++++++++++
.../apache/sedona/sql/dataFrameAPITestScala.scala | 11 +++
9 files changed, 309 insertions(+)
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 2df50eec8e..916c6204df 100644
--- a/common/src/main/java/org/apache/sedona/common/Predicates.java
+++ b/common/src/main/java/org/apache/sedona/common/Predicates.java
@@ -22,6 +22,7 @@ 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.distance3d.Distance3DOp;
import org.locationtech.jts.operation.relate.RelateOp;
public class Predicates {
@@ -198,6 +199,42 @@ public class Predicates {
return dx * dx + dy * dy <= distance * distance;
}
+ /**
+ * 3D Euclidean distance-within predicate for Geometry inputs. Mirrors PostGIS {@code
+ * ST_3DDWithin}. Returns true iff the minimum 3D Euclidean distance between the two geometries is
+ * less than or equal to {@code distance}. Coordinates without a Z dimension are treated as {@code
+ * z = 0}, matching PostGIS and the {@code ST_Box3D} convention.
+ *
+ * <p>JTS's {@link Distance3DOp} only tolerates NaN Z on point-point inputs; segment-based
+ * distance math throws {@code IllegalArgumentException: Ordinates must not be NaN}. Fold missing
+ * Z to 0 on both arguments before dispatching so XY LineString / Polygon inputs (or any mix of XY
+ * and XYZ geometry kinds) behave as documented.
+ */
+ public static boolean dWithin3D(Geometry leftGeometry, Geometry rightGeometry, double distance) {
+ Geometry left = Functions.force3D(leftGeometry);
+ Geometry right = Functions.force3D(rightGeometry);
+ return new Distance3DOp(left, right).distance() <= distance;
+ }
+
+ /**
+ * Closed-interval 3D Euclidean distance test between two Box3D rectangular cuboids. Returns true
+ * if the minimum 3D Euclidean distance between the cuboids is less than or equal to {@code
+ * distance}.
+ *
+ * <p>Overlapping or edge/face/corner-touching boxes have distance 0 and therefore match for any
+ * {@code distance >= 0}. Inverted bounds throw for the same reason {@link #box3dIntersects(Box3D,
+ * Box3D)} does — Z has no wraparound convention, so all three axes must be ordered.
+ */
+ public static boolean dWithin3D(Box3D a, Box3D b, double distance) {
+ requireOrderedBox3D(a, "a");
+ requireOrderedBox3D(b, "b");
+ double dx = Math.max(0.0, Math.max(a.getXMin() - b.getXMax(), b.getXMin() - a.getXMax()));
+ double dy = Math.max(0.0, Math.max(a.getYMin() - b.getYMax(), b.getYMin() - a.getYMax()));
+ double dz = Math.max(0.0, Math.max(a.getZMin() - b.getZMax(), b.getZMin() - a.getZMax()));
+ if (dx > distance || dy > distance || dz > distance) return false;
+ return dx * dx + dy * dy + dz * dz <= distance * distance;
+ }
+
public static String relate(Geometry leftGeometry, Geometry rightGeometry) {
return RelateOp.relate(leftGeometry, rightGeometry).toString();
}
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 c494a90785..1aac6ee553 100644
--- a/common/src/test/java/org/apache/sedona/common/PredicatesTest.java
+++ b/common/src/test/java/org/apache/sedona/common/PredicatesTest.java
@@ -183,6 +183,106 @@ public class PredicatesTest extends TestBase {
assertTrue(actual);
}
+ @Test
+ public void testDWithin3DGeometryXY() {
+ // XY-only inputs fold to z=0, so 3D distance equals 2D distance.
+ Geometry point1 = GEOMETRY_FACTORY.createPoint(new Coordinate(1, 1));
+ Geometry point2 = GEOMETRY_FACTORY.createPoint(new Coordinate(2, 2));
+ assertTrue(Predicates.dWithin3D(point1, point2, 1.42));
+ assertFalse(Predicates.dWithin3D(point1, point2, 1.41));
+ }
+
+ @Test
+ public void testDWithin3DGeometryXYZ() {
+ // Pure Z offset of 3 between two points at the same XY.
+ Geometry point1 = GEOMETRY_FACTORY.createPoint(new Coordinate(1, 1, 0));
+ Geometry point2 = GEOMETRY_FACTORY.createPoint(new Coordinate(1, 1, 3));
+ assertTrue(Predicates.dWithin3D(point1, point2, 3.0));
+ assertFalse(Predicates.dWithin3D(point1, point2, 2.999));
+ }
+
+ @Test
+ public void testDWithin3DGeometryThresholdEdge() {
+ // 1-1-1 offset gives distance sqrt(3) ≈ 1.7320508; threshold equal to it is inclusive.
+ Geometry point1 = GEOMETRY_FACTORY.createPoint(new Coordinate(0, 0, 0));
+ Geometry point2 = GEOMETRY_FACTORY.createPoint(new Coordinate(1, 1, 1));
+ assertTrue(Predicates.dWithin3D(point1, point2, Math.sqrt(3)));
+ }
+
+ @Test
+ public void testDWithin3DGeometryXYLineToPoint() throws ParseException {
+ // XY LineString vs XY Point — the path that previously hit Distance3DOp's NaN guard
+ // and threw "Ordinates must not be NaN". After force3D folding, distance is 0.
+ Geometry line = geomFromEWKT("LINESTRING(0 0, 3 4)");
+ Geometry point = GEOMETRY_FACTORY.createPoint(new Coordinate(0, 0));
+ assertTrue(Predicates.dWithin3D(line, point, 0.0));
+ assertTrue(Predicates.dWithin3D(line, point, 1.0));
+ }
+
+ @Test
+ public void testDWithin3DGeometryXYPolygonToPoint() throws ParseException {
+ // XY Polygon vs XY Point at the centroid → distance 0 after Z fold.
+ Geometry polygon = geomFromEWKT("POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))");
+ Geometry inside = GEOMETRY_FACTORY.createPoint(new Coordinate(5, 5));
+ Geometry outside = GEOMETRY_FACTORY.createPoint(new Coordinate(12, 5));
+ assertTrue(Predicates.dWithin3D(polygon, inside, 0.0));
+ assertTrue(Predicates.dWithin3D(polygon, outside, 2.0));
+ assertFalse(Predicates.dWithin3D(polygon, outside, 1.999));
+ }
+
+ @Test
+ public void testDWithin3DGeometryMixedDimensions() throws ParseException {
+ // XYZ point vs XY line. The line's Z folds to 0; the point sits 5 units above the
+ // origin, so the 3D distance from (0,0,5) to the nearest point on z=0 line is 5.
+ Geometry pointXYZ = geomFromEWKT("POINT Z(0 0 5)");
+ Geometry lineXY = geomFromEWKT("LINESTRING(0 0, 10 0)");
+ assertTrue(Predicates.dWithin3D(pointXYZ, lineXY, 5.0));
+ assertFalse(Predicates.dWithin3D(pointXYZ, lineXY, 4.999));
+ }
+
+ @Test
+ public void testDWithin3DBoxOverlapping() {
+ Box3D a = new Box3D(0.0, 0.0, 0.0, 5.0, 5.0, 5.0);
+ Box3D b = new Box3D(4.0, 4.0, 4.0, 6.0, 6.0, 6.0);
+ // Overlap → distance 0, matches any non-negative radius.
+ assertTrue(Predicates.dWithin3D(a, b, 0.0));
+ }
+
+ @Test
+ public void testDWithin3DBoxFaceTouching() {
+ Box3D a = new Box3D(0.0, 0.0, 0.0, 5.0, 5.0, 5.0);
+ // Face touching on x = 5 → distance 0.
+ assertTrue(Predicates.dWithin3D(a, new Box3D(5.0, 0.0, 0.0, 10.0, 5.0, 5.0), 0.0));
+ }
+
+ @Test
+ public void testDWithin3DBoxSeparatedAlongZ() {
+ // Separated only on Z by 3 units (Y and X intervals overlap).
+ Box3D a = new Box3D(0.0, 0.0, 0.0, 5.0, 5.0, 5.0);
+ Box3D b = new Box3D(0.0, 0.0, 8.0, 5.0, 5.0, 10.0);
+ assertTrue(Predicates.dWithin3D(a, b, 3.0));
+ assertFalse(Predicates.dWithin3D(a, b, 2.999));
+ }
+
+ @Test
+ public void testDWithin3DBoxDiagonalThreshold() {
+ // Diagonally separated by (3, 4, 12) → distance 13 exactly.
+ Box3D a = new Box3D(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
+ Box3D b = new Box3D(4.0, 5.0, 13.0, 5.0, 6.0, 14.0);
+ assertTrue(Predicates.dWithin3D(a, b, 13.0));
+ assertFalse(Predicates.dWithin3D(a, b, 12.999));
+ }
+
+ @Test
+ public void testDWithin3DBoxRejectInvertedBounds() {
+ 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);
+ IllegalArgumentException ex =
+ assertThrows(
+ IllegalArgumentException.class, () -> Predicates.dWithin3D(wrapZ, normal, 1.0));
+ assertTrue(ex.getMessage().contains("inverted bounds"));
+ }
+
@Test
public void testDWithinSpheroid() {
Geometry seattlePoint = GEOMETRY_FACTORY.createPoint(new Coordinate(-122.335167, 47.608013));
diff --git a/python/sedona/spark/sql/st_predicates.py b/python/sedona/spark/sql/st_predicates.py
index 2cc2232f21..068a227b53 100644
--- a/python/sedona/spark/sql/st_predicates.py
+++ b/python/sedona/spark/sql/st_predicates.py
@@ -313,6 +313,31 @@ def ST_DWithin(
return _call_predicate_function("ST_DWithin", args)
+@validate_argument_types
+def ST_3DDWithin(
+ a: ColumnOrName,
+ b: ColumnOrName,
+ distance: Union[ColumnOrName, float],
+) -> Column:
+ """Check whether two geometries (or two Box3D values) are within ``distance`` units using 3D
+ Euclidean distance.
+
+ Mirrors PostGIS ``ST_3DDWithin``. Coordinates without a Z dimension are treated as
+ ``z = 0``. For Box3D inputs the test is a closed-interval 3D distance between the two
+ cuboids; inverted bounds raise ``IllegalArgumentException``. NULL on null input.
+
+ :param a: First geometry or Box3D column.
+ :type a: ColumnOrName
+ :param b: Second geometry or Box3D column.
+ :type b: ColumnOrName
+ :param distance: Distance threshold.
+ :type distance: Union[ColumnOrName, float]
+ :return: True if the 3D distance between a and b is ≤ ``distance``.
+ :rtype: Column
+ """
+ return _call_predicate_function("ST_3DDWithin", (a, b, distance))
+
+
# Automatically populate __all__
__all__ = [
name
diff --git a/python/tests/sql/test_dataframe_api.py b/python/tests/sql/test_dataframe_api.py
index 3a3520119d..834e40831a 100644
--- a/python/tests/sql/test_dataframe_api.py
+++ b/python/tests/sql/test_dataframe_api.py
@@ -1230,6 +1230,18 @@ test_configurations = [
"",
True,
),
+ (
+ stp.ST_3DDWithin,
+ (
+ lambda: f.expr("ST_PointZ(0.0, 0.0, 0.0)"),
+ lambda: f.expr("ST_PointZ(1.0, 1.0, 1.0)"),
+ # 3D distance is sqrt(3) ≈ 1.732; 2.0 includes it.
+ 2.0,
+ ),
+ "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 1740f2df3f..0358d8e1e3 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
@@ -174,6 +174,7 @@ object Catalog extends AbstractCatalog with Logging {
function[ST_Crosses](),
function[ST_Disjoint](),
function[ST_DWithin](),
+ function[ST_3DDWithin](),
function[ST_Equals](),
function[ST_Intersects](),
function[ST_OrderingEquals](),
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 ae1e562b63..1c474cc2ed 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
@@ -358,6 +358,20 @@ private[apache] case class ST_DWithin(inputExpressions: Seq[Expression])
}
}
+private[apache] case class ST_3DDWithin(inputExpressions: Seq[Expression])
+ extends InferredExpression(
+ inferrableFunction3((l: Geometry, r: Geometry, d: Double) => Predicates.dWithin3D(l, r, d)),
+ inferrableFunction3(
+ (
+ a: org.apache.sedona.common.geometryObjects.Box3D,
+ b: org.apache.sedona.common.geometryObjects.Box3D,
+ distance: Double) => Predicates.dWithin3D(a, b, distance))) {
+
+ protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
+ copy(inputExpressions = newChildren)
+ }
+}
+
/**
* Test if leftGeometry is one of the k nearest neighbors (KNN) of rightGeometry based on
* approximate distance metric.
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 38354bd75f..3504d952f0 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
@@ -89,6 +89,11 @@ object st_predicates {
def ST_DWithin(a: String, b: String, distance: Double, useSphere: Boolean): Column =
wrapExpression[ST_DWithin](a, b, distance, useSphere)
+ def ST_3DDWithin(a: Column, b: Column, distance: Column): Column =
+ wrapExpression[ST_3DDWithin](a, b, distance)
+ def ST_3DDWithin(a: String, b: String, distance: Double): Column =
+ wrapExpression[ST_3DDWithin](a, b, distance)
+
def ST_KNN(a: Column, b: Column, distance: Column): Column =
wrapExpression[ST_KNN](a, b, distance)
def ST_KNN(a: String, b: String, distance: Double): Column =
diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/Box3DDWithinSuite.scala b/spark/common/src/test/scala/org/apache/sedona/sql/Box3DDWithinSuite.scala
new file mode 100644
index 0000000000..444f2dfa26
--- /dev/null
+++ b/spark/common/src/test/scala/org/apache/sedona/sql/Box3DDWithinSuite.scala
@@ -0,0 +1,104 @@
+/*
+ * 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 Box3DDWithinSuite extends TestBaseScala {
+
+ describe("ST_3DDWithin") {
+
+ it("returns true for two POINT Z values within the supplied 3D distance") {
+ // 3D distance from (0,0,0) to (1,1,1) is sqrt(3) ≈ 1.7320508075688772.
+ val row = sparkSession
+ .sql("SELECT ST_3DDWithin(ST_PointZ(0, 0, 0), ST_PointZ(1, 1, 1), 2.0) AS within")
+ .first()
+ assert(row.getBoolean(0))
+ }
+
+ it("returns false when the 3D distance exceeds the supplied threshold") {
+ val row = sparkSession
+ .sql("SELECT ST_3DDWithin(ST_PointZ(0, 0, 0), ST_PointZ(1, 1, 1), 1.7) AS within")
+ .first()
+ assert(!row.getBoolean(0))
+ }
+
+ it("handles XY LineString and Polygon inputs without NaN errors") {
+ // Distance3DOp throws on NaN Z for non-point inputs; the implementation folds missing
+ // Z to 0 before dispatching, so XY LineString and Polygon arguments work as documented.
+ val row = sparkSession
+ .sql(
+ "SELECT ST_3DDWithin(ST_GeomFromText('LINESTRING(0 0, 3 4)'), ST_Point(0, 0), 1.0)" +
+ " AS line_point, " +
+ "ST_3DDWithin(ST_GeomFromText('POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))'), " +
+ "ST_Point(5, 5), 0.0) AS polygon_point")
+ .first()
+ assert(row.getBoolean(0))
+ assert(row.getBoolean(1))
+ }
+
+ it("folds Z = 0 for XY-only geometry inputs") {
+ val row = sparkSession
+ .sql("SELECT ST_3DDWithin(ST_Point(0, 0), ST_Point(3, 4), 5.0) AS edge, " +
+ "ST_3DDWithin(ST_Point(0, 0), ST_Point(3, 4), 4.999) AS just_outside")
+ .first()
+ assert(row.getBoolean(0))
+ assert(!row.getBoolean(1))
+ }
+
+ it("returns true for overlapping Box3D values") {
+ val row = sparkSession
+ .sql(
+ "SELECT ST_3DDWithin(" +
+ "ST_3DMakeBox(ST_PointZ(0, 0, 0), ST_PointZ(5, 5, 5)), " +
+ "ST_3DMakeBox(ST_PointZ(4, 4, 4), ST_PointZ(6, 6, 6)), 0.0) AS within")
+ .first()
+ assert(row.getBoolean(0))
+ }
+
+ it("returns true at the threshold edge for Box3D inputs (3-4-12 diagonal)") {
+ val row = sparkSession
+ .sql(
+ "SELECT ST_3DDWithin(" +
+ "ST_3DMakeBox(ST_PointZ(0, 0, 0), ST_PointZ(1, 1, 1)), " +
+ "ST_3DMakeBox(ST_PointZ(4, 5, 13), ST_PointZ(5, 6, 14)), 13.0) AS edge")
+ .first()
+ assert(row.getBoolean(0))
+ }
+
+ it("throws on inverted Box3D bounds") {
+ val ex = intercept[Exception] {
+ sparkSession
+ .sql(
+ "SELECT ST_3DDWithin(" +
+ "ST_3DMakeBox(ST_PointZ(0, 0, 5), ST_PointZ(5, 5, 0)), " +
+ "ST_3DMakeBox(ST_PointZ(0, 0, 0), ST_PointZ(5, 5, 5)), 1.0)")
+ .collect()
+ }
+ val messages =
+ Iterator.iterate[Throwable](ex)(_.getCause).takeWhile(_ != null).map(_.getMessage)
+ assert(messages.exists(m => m != null && m.contains("inverted bounds")))
+ }
+
+ it("returns NULL on null input") {
+ val row = sparkSession
+ .sql("SELECT ST_3DDWithin(ST_GeomFromText(NULL), ST_PointZ(0, 0, 0), 1.0) AS within")
+ .first()
+ assert(row.isNullAt(0))
+ }
+ }
+}
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 82f5913746..91251873b8 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
@@ -685,6 +685,17 @@ class dataFrameAPITestScala extends TestBaseScala {
assert(!row.getBoolean(1))
}
+ it("Passed ST_3DDWithin") {
+ val ptsDf = sparkSession.sql(
+ "SELECT ST_PointZ(0, 0, 0) AS a, ST_PointZ(1, 1, 1) AS b, ST_PointZ(5, 5, 5) AS c")
+ // 3D distance from a to b is sqrt(3) ≈ 1.732; threshold 2.0 includes, 1.0 excludes the a→c pair.
+ val row = ptsDf
+ .select(ST_3DDWithin("a", "b", 2.0), ST_3DDWithin("a", "c", 1.0))
+ .first()
+ assert(row.getBoolean(0))
+ assert(!row.getBoolean(1))
+ }
+
it("Passed ST_3DExtent") {
val pointsDf = sparkSession.sql("SELECT explode(array(" +
"ST_PointZ(0.0, 0.0, -1.0), ST_PointZ(2.0, 4.0, 6.0), ST_PointZ(1.0, 1.0, 1.0))) AS geom")