This is an automated email from the ASF dual-hosted git repository.

jiayu 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 ff2df67a5a [GH-2286] ST_Envelope on Geography (#2285)
ff2df67a5a is described below

commit ff2df67a5ac96a9ccea1602e21708b579fc1affc
Author: Zhuocheng Shang <[email protected]>
AuthorDate: Fri Aug 22 00:41:29 2025 -0700

    [GH-2286] ST_Envelope on Geography (#2285)
    
    * ST_Envelope on Geography
    
    * example of split polygons at antimeridian
    
    * ass SQL and DataFrameAPI integration
    
    * avoid ambiguous in NULL inpur
    
    * update the doc file
    
    * empty commit to retrigger ci
    
    * Apply suggestions from code review
    
    Co-authored-by: Copilot <[email protected]>
    
    * Rename FucntionTest.java to FunctionTest.java
    
    * Remove unused files
    
    ---------
    
    Co-authored-by: Jia Yu <[email protected]>
    Co-authored-by: Copilot <[email protected]>
    Co-authored-by: Kristin Cowalcijk <[email protected]>
---
 .../apache/sedona/common/geography/Functions.java  |  83 +++++++++++
 .../sedona/common/Geography/FunctionTest.java      | 154 +++++++++++++++++++++
 docs/api/sql/geography/Function.md                 |  22 +++
 .../sql/sedona_sql/expressions/Functions.scala     |   4 +-
 .../sql/sedona_sql/expressions/st_functions.scala  |   5 +
 .../sql/geography/FunctionsDataFrameAPITest.scala  |  62 +++++++++
 .../sedona/sql/geography/FunctionsTest.scala       |  57 ++++++++
 7 files changed, 386 insertions(+), 1 deletion(-)

diff --git 
a/common/src/main/java/org/apache/sedona/common/geography/Functions.java 
b/common/src/main/java/org/apache/sedona/common/geography/Functions.java
new file mode 100644
index 0000000000..996c2ec7eb
--- /dev/null
+++ b/common/src/main/java/org/apache/sedona/common/geography/Functions.java
@@ -0,0 +1,83 @@
+/*
+ * 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.common.geography;
+
+import com.google.common.geometry.*;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.sedona.common.S2Geography.*;
+
+public class Functions {
+
+  private static final double EPSILON = 1e-9;
+
+  private static boolean nearlyEqual(double a, double b) {
+    if (Double.isNaN(a) || Double.isNaN(b)) {
+      return false;
+    }
+    return Math.abs(a - b) < EPSILON;
+  }
+
+  public static Geography getEnvelope(Geography geography, boolean 
splitAtAntiMeridian) {
+    if (geography == null) return null;
+    S2LatLngRect rect = geography.region().getRectBound();
+    double lngLo = rect.lngLo().degrees();
+    double latLo = rect.latLo().degrees();
+    double lngHi = rect.lngHi().degrees();
+    double latHi = rect.latHi().degrees();
+
+    if (nearlyEqual(latLo, latHi) && nearlyEqual(lngLo, lngHi)) {
+      S2Point point = S2LatLng.fromDegrees(latLo, lngLo).toPoint();
+      Geography pointGeo = new SinglePointGeography(point);
+      pointGeo.setSRID(geography.getSRID());
+      return pointGeo;
+    }
+
+    Geography envelope;
+    if (splitAtAntiMeridian && rect.lng().isInverted()) {
+      // Crossing → split into two polygons
+      S2Polygon left = rectToPolygon(lngLo, latLo, 180.0, latHi);
+      S2Polygon right = rectToPolygon(-180.0, latLo, lngHi, latHi);
+      envelope =
+          new MultiPolygonGeography(Geography.GeographyKind.MULTIPOLYGON, 
List.of(left, right));
+    } else {
+      envelope = new PolygonGeography(rectToPolygon(lngLo, latLo, lngHi, 
latHi));
+    }
+    envelope.setSRID(geography.getSRID());
+    return envelope;
+  }
+
+  /**
+   * Build an S2Polygon rectangle (lng/lat in degrees), CCW ring: (lo,lo) → 
(hi,lo) → (hi,hi) →
+   * (lo,hi).
+   */
+  private static S2Polygon rectToPolygon(double lngLo, double latLo, double 
lngHi, double latHi) {
+    ArrayList<S2Point> v = new ArrayList<>(4);
+    v.add(S2LatLng.fromDegrees(latLo, lngLo).toPoint());
+    v.add(S2LatLng.fromDegrees(latLo, lngHi).toPoint());
+    v.add(S2LatLng.fromDegrees(latHi, lngHi).toPoint());
+    v.add(S2LatLng.fromDegrees(latHi, lngLo).toPoint());
+
+    S2Loop loop = new S2Loop(v);
+    // Optional: normalize for canonical orientation (keeps the smaller-area 
side)
+    loop.normalize();
+
+    return new S2Polygon(loop);
+  }
+}
diff --git 
a/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java 
b/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java
new file mode 100644
index 0000000000..c7f1f6dcb3
--- /dev/null
+++ b/common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java
@@ -0,0 +1,154 @@
+/*
+ * 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.common.Geography;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.google.common.geometry.S2LatLng;
+import com.google.common.geometry.S2LatLngRect;
+import com.google.common.geometry.S2Loop;
+import com.google.common.geometry.S2Point;
+import org.apache.sedona.common.S2Geography.Geography;
+import org.apache.sedona.common.S2Geography.PolygonGeography;
+import org.apache.sedona.common.geography.Constructors;
+import org.apache.sedona.common.geography.Functions;
+import org.junit.Test;
+import org.locationtech.jts.io.ParseException;
+
+public class FunctionTest {
+  private static final double EPS = 1e-9;
+
+  private static void assertDegAlmostEqual(double a, double b) {
+    assertTrue("exp=" + b + ", got=" + a, Math.abs(a - b) <= EPS);
+  }
+
+  private static void assertLatLng(S2Point p, double expLatDeg, double 
expLngDeg) {
+    S2LatLng ll = new S2LatLng(p).normalized();
+    assertDegAlmostEqual(ll.latDegrees(), expLatDeg);
+    assertDegAlmostEqual(ll.lngDegrees(), expLngDeg);
+  }
+
+  /** Assert a *single* rectangular envelope polygon has these 4 corners in 
SW→SE→NE→NW order. */
+  private static void assertRectLoopVertices(
+      S2Loop loop, double latLo, double lngLo, double latHi, double lngHi) {
+    assertEquals("rect must have 4 vertices", 4, loop.numVertices());
+    // SW
+    assertLatLng(loop.vertex(0), latLo, lngLo);
+    // SE
+    assertLatLng(loop.vertex(1), latLo, lngHi);
+    // NE
+    assertLatLng(loop.vertex(2), latHi, lngHi);
+    // NW
+    assertLatLng(loop.vertex(3), latHi, lngLo);
+  }
+
+  @Test
+  public void envelope_noSplit_antimeridian() throws Exception {
+    String wkt = "MULTIPOINT ((-179 0), (179 1), (-180 10))";
+    Geography g = Constructors.geogFromWKT(wkt, 4326);
+    PolygonGeography env = (PolygonGeography) Functions.getEnvelope(g, 
/*split*/ false);
+
+    S2LatLngRect r = g.region().getRectBound();
+    assertTrue(r.lng().isInverted());
+    assertDegAlmostEqual(r.latLo().degrees(), 0.0);
+    assertDegAlmostEqual(r.latHi().degrees(), 10.0);
+    assertDegAlmostEqual(r.lngLo().degrees(), 179.0);
+    assertDegAlmostEqual(r.lngHi().degrees(), -179.0);
+
+    S2Loop loop = env.polygon.getLoops().get(0);
+    assertRectLoopVertices(loop, /*latLo*/ 0, /*lngLo*/ 179, /*latHi*/ 10, 
/*lngHi*/ -179);
+  }
+
+  @Test
+  public void envelope_netherlands_perVertex() throws Exception {
+    String nl =
+        "POLYGON ((3.314971 50.80372, 7.092053 50.80372, 7.092053 53.5104, 
3.314971 53.5104, 3.314971 50.80372))";
+    Geography g = Constructors.geogFromWKT(nl, 4326);
+    Geography env = Functions.getEnvelope(g, true);
+    String expectedWKT = "SRID=4326; POLYGON ((3.3 50.8, 7.1 50.8, 7.1 53.5, 
3.3 53.5, 3.3 50.8))";
+    assertEquals(expectedWKT, env.toString());
+  }
+
+  @Test
+  public void envelope_fiji_split_perVertex() throws Exception {
+    //    <-------------------- WESTERN HEMISPHERE | EASTERN HEMISPHERE 
-------------------->
+    //
+    //            Longitude: ... -179.8°      -180°| 180°      177.3° ...
+    //    
----------------------------------+--------------------------------------------
+    //            |
+    //            Latitude                         |
+    //            -16°   +------------------------+ +------------------------+
+    //                   |                        | |                        |
+    //                   |       POLYGON 2        | |       POLYGON 1        |
+    //                   |                        | |                        |
+    //            -18.3° +------------------------+ +------------------------+
+    //                                             |
+    //                                             |
+    //                                             ^
+    //                                             |
+    //                                          Antimeridian
+    //                                    (The map's seam at 180°)
+    String fiji =
+        "MULTIPOLYGON ("
+            + "((177.285 -18.28799, 180 -18.28799, 180 -16.02088, 177.285 
-16.02088, 177.285 -18.28799)),"
+            + "((-180 -18.28799, -179.7933 -18.28799, -179.7933 -16.02088, 
-180 -16.02088, -180 -18.28799))"
+            + ")";
+    Geography g = Constructors.geogFromWKT(fiji, 4326);
+    Geography env = Functions.getEnvelope(g, /*split*/ true);
+    String expectedWKT =
+        "SRID=4326; MULTIPOLYGON (((177.3 -18.3, 180 -18.3, 180 -16, 177.3 
-16, 177.3 -18.3)), "
+            + "((-180 -18.3, -179.8 -18.3, -179.8 -16, -180 -16, -180 
-18.3)))";
+    assertEquals(expectedWKT, env.toString());
+
+    expectedWKT =
+        "SRID=4326; POLYGON ((177.3 -18.3, -179.8 -18.3, -179.8 -16, 177.3 
-16, 177.3 -18.3))";
+    env = Functions.getEnvelope(g, /*split*/ false);
+    assertEquals(expectedWKT, env.toString());
+  }
+
+  @Test
+  public void getEnvelopePoint() throws ParseException {
+    String wkt = "POINT (-180 10)";
+    Geography geography = Constructors.geogFromWKT(wkt, 0);
+    Geography envelope = Functions.getEnvelope(geography, false);
+    assertEquals("POINT (180 10)", envelope.toString());
+  }
+
+  @Test
+  public void testEnvelopeWKTCompare() throws Exception {
+    String antarctica = "POLYGON ((-180 -90, -180 -63.27066, 180 -63.27066, 
180 -90, -180 -90))";
+    Geography g = Constructors.geogFromWKT(antarctica, 4326);
+    Geography env = Functions.getEnvelope(g, true);
+
+    String expectedWKT =
+        "SRID=4326; POLYGON ((-180 -63.3, 180 -63.3, 180 -90, -180 -90, -180 
-63.3))";
+    assertEquals((expectedWKT), (env.toString()));
+
+    String multiCountry =
+        "MULTIPOLYGON (((-180 -90, -180 -63.27066, 180 -63.27066, 180 -90, 
-180 -90)),"
+            + "((3.314971 50.80372, 7.092053 50.80372, 7.092053 53.5104, 
3.314971 53.5104, 3.314971 50.80372)))";
+    g = Constructors.geogFromWKT(multiCountry, 4326);
+    env = Functions.getEnvelope(g, true);
+
+    String expectedWKT2 =
+        "SRID=4326; POLYGON ((-180 53.5, 180 53.5, 180 -90, -180 -90, -180 
53.5))";
+    assertEquals((expectedWKT2), (env.toString()));
+  }
+}
diff --git a/docs/api/sql/geography/Function.md 
b/docs/api/sql/geography/Function.md
index ef14affc68..4d8563ed18 100644
--- a/docs/api/sql/geography/Function.md
+++ b/docs/api/sql/geography/Function.md
@@ -16,3 +16,25 @@
  specific language governing permissions and limitations
  under the License.
  -->
+
+## ST_Envelope
+
+Introduction: This function returns the bounding box (envelope) of A. It's 
important to note that the bounding box is calculated using a cylindrical 
topology, not a spherical one. If the envelope crosses the antimeridian (the 
180° longitude line), you can set the split parameter to true. This will return 
a Geography object containing two separate Polygon objects, split along that 
line.
+
+Format:
+
+`ST_Envelope (A: Geography, splitAtAntiMeridian: Boolean)`
+
+Since: `v1.8.0`
+
+SQL Example
+
+```sql
+SELECT ST_Envelope(ST_GeogFromWKT('MULTIPOLYGON (((177.285 -18.28799, 180 
-18.28799, 180 -16.02088, 177.285 -16.02088, 177.285 -18.28799)), ((-180 
-18.28799, -179.7933 -18.28799, -179.7933 -16.02088, -180 -16.02088, -180 
-18.28799)))'), false);
+```
+
+Output:
+
+```
+POLYGON ((177.3 -18.3, -179.8 -18.3, -179.8 -16, 177.3 -16, 177.3 -18.3))
+```
diff --git 
a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala
 
b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala
index d41140d6cc..8c4ad05f4c 100644
--- 
a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala
+++ 
b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala
@@ -215,7 +215,9 @@ private[apache] case class 
ST_ShiftLongitude(inputExpressions: Seq[Expression])
  * @param inputExpressions
  */
 private[apache] case class ST_Envelope(inputExpressions: Seq[Expression])
-    extends InferredExpression(Functions.envelope _) {
+    extends InferredExpression(
+      inferrableFunction1(Functions.envelope),
+      
inferrableFunction2(org.apache.sedona.common.geography.Functions.getEnvelope)) {
 
   protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = 
{
     copy(inputExpressions = newChildren)
diff --git 
a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala
 
b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala
index 5694d897f8..554794fb3b 100644
--- 
a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala
+++ 
b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala
@@ -196,6 +196,11 @@ object st_functions {
   def ST_Envelope(geometry: Column): Column = 
wrapExpression[ST_Envelope](geometry)
   def ST_Envelope(geometry: String): Column = 
wrapExpression[ST_Envelope](geometry)
 
+  def ST_Envelope(geography: Column, split: Boolean): Column =
+    wrapExpression[ST_Envelope](geography, split)
+  def ST_Envelope(geography: String, split: Boolean): Column =
+    wrapExpression[ST_Envelope](geography, split)
+
   def ST_Expand(geometry: Column, uniformDelta: Column) =
     wrapExpression[ST_Expand](geometry, uniformDelta)
   def ST_Expand(geometry: String, uniformDelta: String) =
diff --git 
a/spark/common/src/test/scala/org/apache/sedona/sql/geography/FunctionsDataFrameAPITest.scala
 
b/spark/common/src/test/scala/org/apache/sedona/sql/geography/FunctionsDataFrameAPITest.scala
new file mode 100644
index 0000000000..be21c792c0
--- /dev/null
+++ 
b/spark/common/src/test/scala/org/apache/sedona/sql/geography/FunctionsDataFrameAPITest.scala
@@ -0,0 +1,62 @@
+/*
+ * 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.geography
+
+import org.apache.sedona.common.S2Geography.Geography
+import org.apache.sedona.sql.TestBaseScala
+import org.apache.spark.sql.functions.{col, lit}
+import org.apache.spark.sql.sedona_sql.expressions.{ST_Envelope, 
st_constructors, st_functions}
+import org.junit.Assert.assertEquals
+
+class FunctionsDataFrameAPITest extends TestBaseScala {
+  import sparkSession.implicits._
+
+  it("Passed ST_Envelope antarctica") {
+    val antarctica =
+      "POLYGON ((-180 -90, -180 -63.27066, 180 -63.27066, 180 -90, -180 -90))"
+    val df = sparkSession
+      .sql(s"SELECT '$antarctica' AS wkt")
+      .select(st_constructors.ST_GeogFromWKT(col("wkt"), lit(4326)).as("geog"))
+      .select(st_functions.ST_Envelope(col("geog"), split = true))
+      .as("env")
+
+    val env = df.first().get(0).asInstanceOf[Geography]
+    val expectedWKT =
+      "SRID=4326; POLYGON ((-180 -63.3, 180 -63.3, 180 -90, -180 -90, -180 
-63.3))";
+    assertEquals(expectedWKT, env.toString)
+  }
+
+  it("Passed ST_Envelope Fiji") {
+    val fiji =
+      "MULTIPOLYGON (" + "((177.285 -18.28799, 180 -18.28799, 180 -16.02088, 
177.285 -16.02088, 177.285 -18.28799))," +
+        "((-180 -18.28799, -179.7933 -18.28799, -179.7933 -16.02088, -180 
-16.02088, -180 -18.28799))" + ")"
+
+    val df = sparkSession
+      .sql(s"SELECT '$fiji' AS wkt")
+      .select(st_constructors.ST_GeogFromWKT(col("wkt"), lit(4326)).as("geog"))
+      .select(st_functions.ST_Envelope(col("geog"), split = false))
+      .as("env")
+
+    val env = df.first().get(0).asInstanceOf[Geography]
+    val expectedWKT =
+      "SRID=4326; POLYGON ((177.3 -18.3, -179.8 -18.3, -179.8 -16, 177.3 -16, 
177.3 -18.3))";
+    assertEquals(expectedWKT, env.toString)
+  }
+
+}
diff --git 
a/spark/common/src/test/scala/org/apache/sedona/sql/geography/FunctionsTest.scala
 
b/spark/common/src/test/scala/org/apache/sedona/sql/geography/FunctionsTest.scala
new file mode 100644
index 0000000000..7f3af6eb2e
--- /dev/null
+++ 
b/spark/common/src/test/scala/org/apache/sedona/sql/geography/FunctionsTest.scala
@@ -0,0 +1,57 @@
+/*
+ * 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.geography
+
+import org.apache.sedona.common.S2Geography.Geography
+import org.apache.sedona.common.geography.{Constructors, Functions}
+import org.apache.sedona.sql.TestBaseScala
+import org.junit.Assert.assertEquals
+import org.locationtech.jts.geom.{Geometry, PrecisionModel}
+
+class FunctionsTest extends TestBaseScala {
+
+  import sparkSession.implicits._
+
+  it("Passed ST_Envelope antarctica") {
+    val antarctica =
+      "POLYGON ((-180 -90, -180 -63.27066, 180 -63.27066, 180 -90, -180 -90))"
+    var row =
+      sparkSession.sql(s"SELECT ST_Envelope(ST_GeogFromWKT('$antarctica'), 
true) AS env").first()
+    var env = row.get(0).asInstanceOf[Geography]
+    var expectedWKT = "POLYGON ((-180 -63.3, 180 -63.3, 180 -90, -180 -90, 
-180 -63.3))";
+    assertEquals(expectedWKT, env.toString)
+  }
+
+  it("Passed ST_Envelope Fiji") {
+    val fiji =
+      "MULTIPOLYGON (" + "((177.285 -18.28799, 180 -18.28799, 180 -16.02088, 
177.285 -16.02088, 177.285 -18.28799))," +
+        "((-180 -18.28799, -179.7933 -18.28799, -179.7933 -16.02088, -180 
-16.02088, -180 -18.28799))" + ")"
+
+    val row =
+      sparkSession.sql(s"SELECT ST_Envelope(ST_GeogFromEWKT('$fiji'), false) 
AS env").first()
+    val env = row.get(0).asInstanceOf[Geography]
+    val expectedWKT = "POLYGON ((177.3 -18.3, -179.8 -18.3, -179.8 -16, 177.3 
-16, 177.3 -18.3))"
+    assertEquals(expectedWKT, env.toString)
+  }
+
+  it("Passed ST_Envelope null") {
+    val functionDf = sparkSession.sql("select ST_Envelope(null, false)")
+    assert(functionDf.first().get(0) == null)
+  }
+}

Reply via email to