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")