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/07/26 01:59:31 UTC

(sedona) branch master updated: [GH-2385][GH-3168][GH-3169] GeoPandas: Implement GeoSeries is_ccw, interiors, and orient_polygons (#3170)

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 039214ecf5d [GH-2385][GH-3168][GH-3169] GeoPandas: Implement GeoSeries is_ccw, interiors, and orient_polygons (#3170)
039214ecf5d is described below

commit 039214ecf5d1b6ab9f2356f7fac661463e30e2f6
Author: Jia Yu <ji...@apache.org>
AuthorDate: Sat Jul 25 18:59:25 2026 -0700

    [GH-2385][GH-3168][GH-3169] GeoPandas: Implement GeoSeries is_ccw, interiors, and orient_polygons (#3170)
---
 .../java/org/apache/sedona/common/Functions.java   |  60 ++++-
 .../org/apache/sedona/common/FunctionsTest.java    | 133 ++++++++++
 .../Geometry-Accessors/ST_IsLineStringCCW.md}      |  18 +-
 .../flink/Geometry-Editors/ST_ForcePolygonCCW.md   |   2 +-
 .../flink/Geometry-Editors/ST_ForcePolygonCW.md    |   2 +-
 docs/api/flink/Geometry-Editors/ST_ForceRHR.md     |   2 +-
 docs/api/flink/Geometry-Functions.md               |   9 +-
 .../ST_IsLineStringCCW.md}                         |  16 +-
 .../Geometry-Editors/ST_ForcePolygonCCW.md         |   2 +-
 .../Geometry-Editors/ST_ForcePolygonCW.md          |   2 +-
 .../vector-data/Geometry-Editors/ST_ForceRHR.md    |   2 +-
 .../snowflake/vector-data/Geometry-Functions.md    |   9 +-
 .../Geometry-Accessors/ST_IsLineStringCCW.md}      |  18 +-
 .../api/sql/Geometry-Editors/ST_ForcePolygonCCW.md |   2 +-
 docs/api/sql/Geometry-Editors/ST_ForcePolygonCW.md |   2 +-
 docs/api/sql/Geometry-Editors/ST_ForceRHR.md       |   2 +-
 docs/api/sql/Geometry-Functions.md                 |   9 +-
 .../ST_IsLineStringCCW/ST_IsLineStringCCW.svg      |  20 ++
 .../main/java/org/apache/sedona/flink/Catalog.java |   1 +
 .../apache/sedona/flink/expressions/Functions.java |  13 +
 .../java/org/apache/sedona/flink/FunctionTest.java |  15 ++
 python/sedona/spark/geopandas/base.py              |  96 ++++++-
 python/sedona/spark/geopandas/geoseries.py         |  79 ++++--
 python/sedona/spark/sql/st_functions.py            |  52 +++-
 python/tests/geopandas/test_geoseries.py           | 288 ++++++++++++++++++++-
 .../tests/geopandas/test_match_geopandas_series.py | 159 +++++++++++-
 python/tests/sql/test_dataframe_api.py             |   2 +
 .../sedona/snowflake/snowsql/TestFunctions.java    |  11 +
 .../snowflake/snowsql/TestFunctionsGeography.java  |  11 +
 .../sedona/snowflake/snowsql/TestFunctionsV2.java  |  11 +
 .../org/apache/sedona/snowflake/snowsql/UDFs.java  |   5 +
 .../apache/sedona/snowflake/snowsql/UDFsV2.java    |   7 +
 .../scala/org/apache/sedona/sql/UDF/Catalog.scala  |   1 +
 .../sql/sedona_sql/expressions/Functions.scala     |   7 +
 .../sql/sedona_sql/expressions/st_functions.scala  |   5 +
 .../apache/sedona/sql/dataFrameAPITestScala.scala  |   8 +
 .../org/apache/sedona/sql/functionTestScala.scala  |  15 ++
 37 files changed, 1000 insertions(+), 96 deletions(-)

diff --git a/common/src/main/java/org/apache/sedona/common/Functions.java b/common/src/main/java/org/apache/sedona/common/Functions.java
index b1200de111b..62df68a6258 100644
--- a/common/src/main/java/org/apache/sedona/common/Functions.java
+++ b/common/src/main/java/org/apache/sedona/common/Functions.java
@@ -1136,6 +1136,12 @@ public class Functions {
         && geometry.isSimple();
   }
 
+  public static boolean isLineStringCCW(Geometry geometry) {
+    return geometry instanceof LineString
+        && geometry.getNumPoints() >= 4
+        && Orientation.isCCW(((LineString) geometry).getCoordinateSequence());
+  }
+
   public static boolean isSimple(Geometry geometry) {
     return new IsSimpleOp(geometry).isSimple();
   }
@@ -1544,12 +1550,21 @@ public class Functions {
 
   /**
    * Forces a Polygon/MultiPolygon to use clockwise orientation for the exterior ring and a
-   * counter-clockwise for the interior ring(s).
+   * counter-clockwise for the interior ring(s). Polygonal members inside GeometryCollections are
+   * transformed recursively.
    *
    * @param geom
-   * @return a clockwise orientated (Multi)Polygon
+   * @return the geometry with polygonal components oriented clockwise
    */
   public static Geometry forcePolygonCW(Geometry geom) {
+    if (geom == null || geom.isEmpty()) {
+      return geom;
+    }
+
+    if (isPlainGeometryCollection(geom)) {
+      return forcePolygonOrientationInCollection((GeometryCollection) geom, true);
+    }
+
     if (isPolygonCW(geom)) {
       return geom;
     }
@@ -1658,12 +1673,21 @@ public class Functions {
 
   /**
    * Forces a Polygon/MultiPolygon to use counter-clockwise orientation for the exterior ring and a
-   * clockwise for the interior ring(s).
+   * clockwise for the interior ring(s). Polygonal members inside GeometryCollections are
+   * transformed recursively.
    *
    * @param geom
-   * @return a counter-clockwise orientated (Multi)Polygon
+   * @return the geometry with polygonal components oriented counter-clockwise
    */
   public static Geometry forcePolygonCCW(Geometry geom) {
+    if (geom == null || geom.isEmpty()) {
+      return geom;
+    }
+
+    if (isPlainGeometryCollection(geom)) {
+      return forcePolygonOrientationInCollection((GeometryCollection) geom, false);
+    }
+
     if (isPolygonCCW(geom)) {
       return geom;
     }
@@ -1684,6 +1708,34 @@ public class Functions {
     return geom;
   }
 
+  private static boolean isPlainGeometryCollection(Geometry geometry) {
+    return geometry instanceof GeometryCollection
+        && !(geometry instanceof MultiPoint)
+        && !(geometry instanceof MultiLineString)
+        && !(geometry instanceof MultiPolygon);
+  }
+
+  private static Geometry forcePolygonOrientationInCollection(
+      GeometryCollection collection, boolean clockwise) {
+    Geometry[] orientedGeometries = new Geometry[collection.getNumGeometries()];
+    boolean changed = false;
+    for (int i = 0; i < collection.getNumGeometries(); i++) {
+      Geometry geometry = collection.getGeometryN(i);
+      Geometry orientedGeometry = clockwise ? forcePolygonCW(geometry) : forcePolygonCCW(geometry);
+      orientedGeometries[i] = orientedGeometry;
+      changed |= orientedGeometry != geometry;
+    }
+
+    if (!changed) {
+      return collection;
+    }
+    GeometryCollection orientedCollection =
+        collection.getFactory().createGeometryCollection(orientedGeometries);
+    orientedCollection.setSRID(collection.getSRID());
+    orientedCollection.setUserData(collection.getUserData());
+    return orientedCollection;
+  }
+
   private static Geometry transformCCW(Polygon polygon) {
     LinearRing exteriorRing = polygon.getExteriorRing();
     LinearRing exteriorRingEnforced = transformCCW(exteriorRing, true);
diff --git a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java
index 267894efb74..4c8bbfe8060 100644
--- a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java
+++ b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java
@@ -1772,6 +1772,139 @@ public class FunctionsTest extends TestBase {
     assertEquals(expected, actual);
   }
 
+  @Test
+  public void testForcePolygonCWRecursesIntoNestedGeometryCollections() throws ParseException {
+    Geometry source =
+        Constructors.geomFromWKT(
+            "GEOMETRYCOLLECTION ("
+                + "POINT (9 9), "
+                + "GEOMETRYCOLLECTION ("
+                + "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0)), "
+                + "LINESTRING (3 3, 4 4), "
+                + "GEOMETRYCOLLECTION EMPTY, "
+                + "MULTIPOLYGON (((10 0, 12 0, 12 2, 10 2, 10 0)))), "
+                + "POLYGON ((20 0, 22 0, 22 2, 20 2, 20 0)))",
+            4326);
+
+    Geometry result = Functions.forcePolygonCW(source);
+
+    assertNestedCollectionOrientation(source, result, true);
+  }
+
+  @Test
+  public void testForcePolygonCCWRecursesIntoNestedGeometryCollections() throws ParseException {
+    Geometry source =
+        Constructors.geomFromWKT(
+            "GEOMETRYCOLLECTION ("
+                + "POINT (9 9), "
+                + "GEOMETRYCOLLECTION ("
+                + "POLYGON ((0 0, 0 2, 2 2, 2 0, 0 0)), "
+                + "LINESTRING (3 3, 4 4), "
+                + "GEOMETRYCOLLECTION EMPTY, "
+                + "MULTIPOLYGON (((10 0, 10 2, 12 2, 12 0, 10 0)))), "
+                + "POLYGON ((20 0, 20 2, 22 2, 22 0, 20 0)))",
+            4326);
+
+    Geometry result = Functions.forcePolygonCCW(source);
+
+    assertNestedCollectionOrientation(source, result, false);
+  }
+
+  private void assertNestedCollectionOrientation(
+      Geometry source, Geometry result, boolean clockwise) {
+    assertTrue(result instanceof GeometryCollection);
+    assertEquals(source.getSRID(), result.getSRID());
+    assertEquals(source.getNumGeometries(), result.getNumGeometries());
+
+    assertEquals(source.getGeometryN(0), result.getGeometryN(0));
+    assertTrue(result.getGeometryN(1) instanceof GeometryCollection);
+    assertTrue(result.getGeometryN(2) instanceof Polygon);
+
+    GeometryCollection sourceNested = (GeometryCollection) source.getGeometryN(1);
+    GeometryCollection resultNested = (GeometryCollection) result.getGeometryN(1);
+    assertEquals(sourceNested.getSRID(), resultNested.getSRID());
+    assertEquals(sourceNested.getNumGeometries(), resultNested.getNumGeometries());
+
+    assertTrue(resultNested.getGeometryN(0) instanceof Polygon);
+    assertEquals(sourceNested.getGeometryN(1), resultNested.getGeometryN(1));
+    assertEquals(sourceNested.getGeometryN(2), resultNested.getGeometryN(2));
+    assertTrue(resultNested.getGeometryN(2).isEmpty());
+    assertTrue(resultNested.getGeometryN(3) instanceof MultiPolygon);
+
+    Geometry nestedPolygon = resultNested.getGeometryN(0);
+    Geometry nestedMultiPolygon = resultNested.getGeometryN(3);
+    Geometry topLevelPolygon = result.getGeometryN(2);
+    assertEquals(source.getSRID(), nestedPolygon.getSRID());
+    assertEquals(source.getSRID(), nestedMultiPolygon.getSRID());
+    assertEquals(source.getSRID(), topLevelPolygon.getSRID());
+
+    if (clockwise) {
+      assertTrue(Functions.isPolygonCW(nestedPolygon));
+      assertTrue(Functions.isPolygonCW(nestedMultiPolygon));
+      assertTrue(Functions.isPolygonCW(topLevelPolygon));
+      assertTrue(Functions.isPolygonCCW(sourceNested.getGeometryN(0)));
+      assertTrue(Functions.isPolygonCCW(sourceNested.getGeometryN(3)));
+      assertTrue(Functions.isPolygonCCW(source.getGeometryN(2)));
+    } else {
+      assertTrue(Functions.isPolygonCCW(nestedPolygon));
+      assertTrue(Functions.isPolygonCCW(nestedMultiPolygon));
+      assertTrue(Functions.isPolygonCCW(topLevelPolygon));
+      assertTrue(Functions.isPolygonCW(sourceNested.getGeometryN(0)));
+      assertTrue(Functions.isPolygonCW(sourceNested.getGeometryN(3)));
+      assertTrue(Functions.isPolygonCW(source.getGeometryN(2)));
+    }
+  }
+
+  @Test
+  public void testForcePolygonOrientationPreservesCollectionMetadata() throws ParseException {
+    Geometry source =
+        Constructors.geomFromWKT(
+            "GEOMETRYCOLLECTION ("
+                + "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0)), "
+                + "POLYGON ((10 0, 10 2, 12 2, 12 0, 10 0)))",
+            0);
+    source.setSRID(4326);
+    source.setUserData("metadata");
+
+    Geometry clockwise = Functions.forcePolygonCW(source);
+    Geometry counterClockwise = Functions.forcePolygonCCW(source);
+
+    assertEquals(0, source.getFactory().getSRID());
+    assertEquals(4326, clockwise.getSRID());
+    assertEquals(4326, counterClockwise.getSRID());
+    assertEquals("metadata", clockwise.getUserData());
+    assertEquals("metadata", counterClockwise.getUserData());
+  }
+
+  @Test
+  public void testForcePolygonOrientationPreservesNull() {
+    assertNull(Functions.forcePolygonCW(null));
+    assertNull(Functions.forcePolygonCCW(null));
+  }
+
+  @Test
+  public void testIsLineStringCCW() throws ParseException {
+    Geometry closedCCW = Constructors.geomFromWKT("LINESTRING (0 0, 1 0, 1 1, 0 1, 0 0)", 0);
+    Geometry closedCW = Constructors.geomFromWKT("LINESTRING (0 0, 0 1, 1 1, 1 0, 0 0)", 0);
+    Geometry openCCW = Constructors.geomFromWKT("LINESTRING (0 0, 1 0, 1 1, 0 1)", 0);
+    Geometry asymmetricOpen = Constructors.geomFromWKT("LINESTRING (0 1, 0 -1, -1 -2, 3 -2)", 0);
+    Geometry asymmetricClosed =
+        Constructors.geomFromWKT("LINESTRING (0 1, 0 -1, -1 -2, 3 -2, 0 1)", 0);
+
+    assertTrue(Functions.isLineStringCCW(closedCCW));
+    assertFalse(Functions.isLineStringCCW(closedCW));
+    assertTrue(Functions.isLineStringCCW(openCCW));
+    assertFalse(Functions.isLineStringCCW(asymmetricOpen));
+    assertTrue(Functions.isLineStringCCW(asymmetricClosed));
+    assertFalse(
+        Functions.isLineStringCCW(Constructors.geomFromWKT("LINESTRING (0 0, 1 0, 0 1)", 0)));
+    assertFalse(
+        Functions.isLineStringCCW(Constructors.geomFromWKT("LINESTRING (0 0, 1 0, 0 0)", 0)));
+    assertFalse(Functions.isLineStringCCW(Constructors.geomFromWKT("LINESTRING EMPTY", 0)));
+    assertFalse(Functions.isLineStringCCW(Constructors.geomFromWKT("POINT (0 0)", 0)));
+    assertFalse(Functions.isLineStringCCW(null));
+  }
+
   @Test
   public void testIsPolygonCW() throws ParseException {
     Geometry polyCCW =
diff --git a/docs/api/sql/Geometry-Editors/ST_ForcePolygonCCW.md b/docs/api/flink/Geometry-Accessors/ST_IsLineStringCCW.md
similarity index 56%
copy from docs/api/sql/Geometry-Editors/ST_ForcePolygonCCW.md
copy to docs/api/flink/Geometry-Accessors/ST_IsLineStringCCW.md
index 4d2eae510db..a1c01f0f4bf 100644
--- a/docs/api/sql/Geometry-Editors/ST_ForcePolygonCCW.md
+++ b/docs/api/flink/Geometry-Accessors/ST_IsLineStringCCW.md
@@ -17,26 +17,28 @@
  under the License.
  -->
 
-# ST_ForcePolygonCCW
+# ST_IsLineStringCCW
 
-Introduction: For (Multi)Polygon geometries, this function sets the exterior ring orientation to counter-clockwise and interior rings to clockwise orientation. Non-polygonal geometries are returned unchanged.
+Introduction: Returns true if the input LineString's coordinate sequence has counter-clockwise ring orientation.
 
-![ST_ForcePolygonCCW](../../../image/ST_ForcePolygonCCW/ST_ForcePolygonCCW.svg "ST_ForcePolygonCCW")
+![ST_IsLineStringCCW](../../../image/ST_IsLineStringCCW/ST_IsLineStringCCW.svg "ST_IsLineStringCCW")
 
-Format: `ST_ForcePolygonCCW(geom: Geometry)`
+The input LineString does not need to be closed. Its existing coordinate sequence is evaluated without adding a closing coordinate. The function returns false for non-LineString geometries and LineStrings with fewer than four points.
 
-Return type: `Geometry`
+Format: `ST_IsLineStringCCW(geom: Geometry)`
 
-Since: `v1.6.0`
+Return type: `Boolean`
+
+Since: `v1.9.1`
 
 SQL Example:
 
 ```sql
-SELECT ST_AsText(ST_ForcePolygonCCW(ST_GeomFromText('POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))')))
+SELECT ST_IsLineStringCCW(ST_GeomFromWKT('LINESTRING (0 0, 1 0, 1 1, 0 1, 0 0)'))
 ```
 
 Output:
 
 ```
-POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 30 20))
+true
 ```
diff --git a/docs/api/flink/Geometry-Editors/ST_ForcePolygonCCW.md b/docs/api/flink/Geometry-Editors/ST_ForcePolygonCCW.md
index 4d2eae510db..773b98540df 100644
--- a/docs/api/flink/Geometry-Editors/ST_ForcePolygonCCW.md
+++ b/docs/api/flink/Geometry-Editors/ST_ForcePolygonCCW.md
@@ -19,7 +19,7 @@
 
 # ST_ForcePolygonCCW
 
-Introduction: For (Multi)Polygon geometries, this function sets the exterior ring orientation to counter-clockwise and interior rings to clockwise orientation. Non-polygonal geometries are returned unchanged.
+Introduction: Sets Polygon and MultiPolygon exterior rings counter-clockwise and interior rings clockwise. Polygonal members of GeometryCollections are oriented recursively, while non-polygonal members are preserved unchanged.
 
 ![ST_ForcePolygonCCW](../../../image/ST_ForcePolygonCCW/ST_ForcePolygonCCW.svg "ST_ForcePolygonCCW")
 
diff --git a/docs/api/flink/Geometry-Editors/ST_ForcePolygonCW.md b/docs/api/flink/Geometry-Editors/ST_ForcePolygonCW.md
index 2737e9d3ea3..8b21113e305 100644
--- a/docs/api/flink/Geometry-Editors/ST_ForcePolygonCW.md
+++ b/docs/api/flink/Geometry-Editors/ST_ForcePolygonCW.md
@@ -19,7 +19,7 @@
 
 # ST_ForcePolygonCW
 
-Introduction: For (Multi)Polygon geometries, this function sets the exterior ring orientation to clockwise and interior rings to counter-clockwise orientation. Non-polygonal geometries are returned unchanged.
+Introduction: Sets Polygon and MultiPolygon exterior rings clockwise and interior rings counter-clockwise. Polygonal members of GeometryCollections are oriented recursively, while non-polygonal members are preserved unchanged.
 
 ![ST_ForcePolygonCW](../../../image/ST_ForcePolygonCW/ST_ForcePolygonCW.svg "ST_ForcePolygonCW")
 
diff --git a/docs/api/flink/Geometry-Editors/ST_ForceRHR.md b/docs/api/flink/Geometry-Editors/ST_ForceRHR.md
index 0056ba405d7..967f2bb4d5a 100644
--- a/docs/api/flink/Geometry-Editors/ST_ForceRHR.md
+++ b/docs/api/flink/Geometry-Editors/ST_ForceRHR.md
@@ -19,7 +19,7 @@
 
 # ST_ForceRHR
 
-Introduction: Sets the orientation of polygon vertex orderings to follow the Right-Hand-Rule convention. The exterior ring will have a clockwise winding order, while any interior rings are oriented counter-clockwise. This ensures the area bounded by the polygon falls on the right-hand side relative to the ring directions. The function is an alias for [ST_ForcePolygonCW](ST_ForcePolygonCW.md).
+Introduction: Sets polygon vertex orderings to follow the Right-Hand-Rule convention: exterior rings are clockwise and interior rings are counter-clockwise. Polygonal members of GeometryCollections are oriented recursively, while non-polygonal members are preserved unchanged. The function is an alias for [ST_ForcePolygonCW](ST_ForcePolygonCW.md).
 
 ![ST_ForceRHR](../../../image/ST_ForceRHR/ST_ForceRHR.svg "ST_ForceRHR")
 
diff --git a/docs/api/flink/Geometry-Functions.md b/docs/api/flink/Geometry-Functions.md
index 8c633700cdb..f3e9d279f90 100644
--- a/docs/api/flink/Geometry-Functions.md
+++ b/docs/api/flink/Geometry-Functions.md
@@ -81,8 +81,9 @@ These functions extract information and properties from geometry objects.
 | [ST_IsClosed](Geometry-Accessors/ST_IsClosed.md) | Boolean | RETURNS true if the LINESTRING start and end point are the same. | v1.3.0 |
 | [ST_IsCollection](Geometry-Accessors/ST_IsCollection.md) | Boolean | Returns `TRUE` if the geometry type of the input is a geometry collection type. Collection types are the following: | v1.5.0 |
 | [ST_IsEmpty](Geometry-Accessors/ST_IsEmpty.md) | Boolean | Test if a geometry is empty geometry | v1.2.1 |
+| [ST_IsLineStringCCW](Geometry-Accessors/ST_IsLineStringCCW.md) | Boolean | Returns true if the input LineString's coordinate sequence has counter-clockwise ring orientation. | v1.9.1 |
 | [ST_IsPolygonCCW](Geometry-Accessors/ST_IsPolygonCCW.md) | Boolean | Returns true if all polygonal components in the input geometry have their exterior rings oriented counter-clockwise and interior rings oriented clockwise. | v1.6.0 |
-| [ST_IsPolygonCW](Geometry-Accessors/ST_IsPolygonCW.md) | Boolean | Returns true if all polygonal components in the input geometry have their exterior rings oriented counter-clockwise and interior rings oriented clockwise. | v1.6.0 |
+| [ST_IsPolygonCW](Geometry-Accessors/ST_IsPolygonCW.md) | Boolean | Returns true if all polygonal components in the input geometry have their exterior rings oriented clockwise and interior rings oriented counter-clockwise. | v1.6.0 |
 | [ST_IsRing](Geometry-Accessors/ST_IsRing.md) | Boolean | RETURN true if LINESTRING is ST_IsClosed and ST_IsSimple. | v1.3.0 |
 | [ST_IsSimple](Geometry-Accessors/ST_IsSimple.md) | Boolean | Test if geometry's only self-intersections are at boundary points. | v1.3.0 |
 | [ST_M](Geometry-Accessors/ST_M.md) | Double | Returns M Coordinate of given Point, null otherwise. | v1.6.1 |
@@ -118,9 +119,9 @@ These functions create modified geometries by changing type, structure, or verti
 | [ST_Force4D](Geometry-Editors/ST_Force4D.md) | Geometry | Converts the input geometry to 4D XYZM representation. Retains original Z and M values if present. Assigning 0.0 defaults if `mValue` and `zValue` aren't specified. The output contains X, Y, Z, and... | v1.6.1 |
 | [ST_Force_2D](Geometry-Editors/ST_Force_2D.md) | Geometry | Forces the geometries into a "2-dimensional mode" so that all output representations will only have the X and Y coordinates. This function is an alias of [ST_Force2D](Geometry-Editors/ST_Force2D.md). | v1.2.1 |
 | [ST_ForceCollection](Geometry-Editors/ST_ForceCollection.md) | Geometry | This function converts the input geometry into a GeometryCollection, regardless of the original geometry type. If the input is a multipart geometry, such as a MultiPolygon or MultiLineString, it wi... | v1.6.1 |
-| [ST_ForcePolygonCCW](Geometry-Editors/ST_ForcePolygonCCW.md) | Geometry | For (Multi)Polygon geometries, this function sets the exterior ring orientation to counter-clockwise and interior rings to clockwise orientation. Non-polygonal geometries are returned unchanged. | v1.6.0 |
-| [ST_ForcePolygonCW](Geometry-Editors/ST_ForcePolygonCW.md) | Geometry | For (Multi)Polygon geometries, this function sets the exterior ring orientation to clockwise and interior rings to counter-clockwise orientation. Non-polygonal geometries are returned unchanged. | v1.6.0 |
-| [ST_ForceRHR](Geometry-Editors/ST_ForceRHR.md) | Geometry | Sets the orientation of polygon vertex orderings to follow the Right-Hand-Rule convention. The exterior ring will have a clockwise winding order, while any interior rings are oriented counter-clock... | v1.6.1 |
+| [ST_ForcePolygonCCW](Geometry-Editors/ST_ForcePolygonCCW.md) | Geometry | Sets polygon exterior rings counter-clockwise and interior rings clockwise, including polygonal members nested in GeometryCollections. Non-polygonal members are preserved unchanged. | v1.6.0 |
+| [ST_ForcePolygonCW](Geometry-Editors/ST_ForcePolygonCW.md) | Geometry | Sets polygon exterior rings clockwise and interior rings counter-clockwise, including polygonal members nested in GeometryCollections. Non-polygonal members are preserved unchanged. | v1.6.0 |
+| [ST_ForceRHR](Geometry-Editors/ST_ForceRHR.md) | Geometry | Applies right-hand-rule ring orientation recursively to polygonal members, including those nested in GeometryCollections. Non-polygonal members are preserved unchanged. | v1.6.1 |
 | [ST_LineFromMultiPoint](Geometry-Editors/ST_LineFromMultiPoint.md) | Geometry | Creates a LineString from a MultiPoint geometry. | v1.3.0 |
 | [ST_LineMerge](Geometry-Editors/ST_LineMerge.md) | Geometry | Returns a LineString or MultiLineString formed by sewing together the constituent line work of a MULTILINESTRING. | v1.5.0 |
 | [ST_LineSegments](Geometry-Editors/ST_LineSegments.md) | `Array<Geometry>` | This function transforms a LineString containing multiple coordinates into an array of LineStrings, each with precisely two coordinates. The `lenient` argument, true by default, prevents an excepti... | v1.7.1 |
diff --git a/docs/api/snowflake/vector-data/Geometry-Editors/ST_ForcePolygonCW.md b/docs/api/snowflake/vector-data/Geometry-Accessors/ST_IsLineStringCCW.md
similarity index 56%
copy from docs/api/snowflake/vector-data/Geometry-Editors/ST_ForcePolygonCW.md
copy to docs/api/snowflake/vector-data/Geometry-Accessors/ST_IsLineStringCCW.md
index 7d65aa2fff9..b11a6e7f0f1 100644
--- a/docs/api/snowflake/vector-data/Geometry-Editors/ST_ForcePolygonCW.md
+++ b/docs/api/snowflake/vector-data/Geometry-Accessors/ST_IsLineStringCCW.md
@@ -17,24 +17,26 @@
  under the License.
  -->
 
-# ST_ForcePolygonCW
+# ST_IsLineStringCCW
 
-Introduction: For (Multi)Polygon geometries, this function sets the exterior ring orientation to clockwise and interior rings to counter-clockwise orientation. Non-polygonal geometries are returned unchanged.
+Introduction: Returns true if the input LineString's coordinate sequence has counter-clockwise ring orientation.
 
-![ST_ForcePolygonCW](../../../../image/ST_ForcePolygonCW/ST_ForcePolygonCW.svg "ST_ForcePolygonCW")
+![ST_IsLineStringCCW](../../../../image/ST_IsLineStringCCW/ST_IsLineStringCCW.svg "ST_IsLineStringCCW")
 
-Format: `ST_ForcePolygonCW(geom: Geometry)`
+The input LineString does not need to be closed. Its existing coordinate sequence is evaluated without adding a closing coordinate. The function returns false for non-LineString geometries and LineStrings with fewer than four points.
 
-Return type: `Geometry`
+Format: `ST_IsLineStringCCW(geom: Geometry)`
+
+Return type: `Boolean`
 
 SQL Example:
 
 ```sql
-SELECT ST_AsText(ST_ForcePolygonCW(ST_GeomFromText('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))')))
+SELECT ST_IsLineStringCCW(ST_GeomFromWKT('LINESTRING (0 0, 1 0, 1 1, 0 1, 0 0)'))
 ```
 
 Output:
 
 ```
-POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))
+true
 ```
diff --git a/docs/api/snowflake/vector-data/Geometry-Editors/ST_ForcePolygonCCW.md b/docs/api/snowflake/vector-data/Geometry-Editors/ST_ForcePolygonCCW.md
index 7821e307988..05caf15c635 100644
--- a/docs/api/snowflake/vector-data/Geometry-Editors/ST_ForcePolygonCCW.md
+++ b/docs/api/snowflake/vector-data/Geometry-Editors/ST_ForcePolygonCCW.md
@@ -19,7 +19,7 @@
 
 # ST_ForcePolygonCCW
 
-Introduction: For (Multi)Polygon geometries, this function sets the exterior ring orientation to counter-clockwise and interior rings to clockwise orientation. Non-polygonal geometries are returned unchanged.
+Introduction: Sets Polygon and MultiPolygon exterior rings counter-clockwise and interior rings clockwise. Polygonal members of GeometryCollections are oriented recursively, while non-polygonal members are preserved unchanged.
 
 ![ST_ForcePolygonCCW](../../../../image/ST_ForcePolygonCCW/ST_ForcePolygonCCW.svg "ST_ForcePolygonCCW")
 
diff --git a/docs/api/snowflake/vector-data/Geometry-Editors/ST_ForcePolygonCW.md b/docs/api/snowflake/vector-data/Geometry-Editors/ST_ForcePolygonCW.md
index 7d65aa2fff9..70f5a5129d4 100644
--- a/docs/api/snowflake/vector-data/Geometry-Editors/ST_ForcePolygonCW.md
+++ b/docs/api/snowflake/vector-data/Geometry-Editors/ST_ForcePolygonCW.md
@@ -19,7 +19,7 @@
 
 # ST_ForcePolygonCW
 
-Introduction: For (Multi)Polygon geometries, this function sets the exterior ring orientation to clockwise and interior rings to counter-clockwise orientation. Non-polygonal geometries are returned unchanged.
+Introduction: Sets Polygon and MultiPolygon exterior rings clockwise and interior rings counter-clockwise. Polygonal members of GeometryCollections are oriented recursively, while non-polygonal members are preserved unchanged.
 
 ![ST_ForcePolygonCW](../../../../image/ST_ForcePolygonCW/ST_ForcePolygonCW.svg "ST_ForcePolygonCW")
 
diff --git a/docs/api/snowflake/vector-data/Geometry-Editors/ST_ForceRHR.md b/docs/api/snowflake/vector-data/Geometry-Editors/ST_ForceRHR.md
index 45b7282c0fb..e41e236d9a3 100644
--- a/docs/api/snowflake/vector-data/Geometry-Editors/ST_ForceRHR.md
+++ b/docs/api/snowflake/vector-data/Geometry-Editors/ST_ForceRHR.md
@@ -19,7 +19,7 @@
 
 # ST_ForceRHR
 
-Introduction: Sets the orientation of polygon vertex orderings to follow the Right-Hand-Rule convention. The exterior ring will have a clockwise winding order, while any interior rings are oriented counter-clockwise. This ensures the area bounded by the polygon falls on the right-hand side relative to the ring directions. The function is an alias for [ST_ForcePolygonCW](ST_ForcePolygonCW.md).
+Introduction: Sets polygon vertex orderings to follow the Right-Hand-Rule convention: exterior rings are clockwise and interior rings are counter-clockwise. Polygonal members of GeometryCollections are oriented recursively, while non-polygonal members are preserved unchanged. The function is an alias for [ST_ForcePolygonCW](ST_ForcePolygonCW.md).
 
 ![ST_ForceRHR](../../../../image/ST_ForceRHR/ST_ForceRHR.svg "ST_ForceRHR")
 
diff --git a/docs/api/snowflake/vector-data/Geometry-Functions.md b/docs/api/snowflake/vector-data/Geometry-Functions.md
index b0f6945c617..cd888855499 100644
--- a/docs/api/snowflake/vector-data/Geometry-Functions.md
+++ b/docs/api/snowflake/vector-data/Geometry-Functions.md
@@ -78,8 +78,9 @@ These functions extract information and properties from geometry objects.
 | [ST_IsClosed](Geometry-Accessors/ST_IsClosed.md) | RETURNS true if the LINESTRING start and end point are the same. |
 | [ST_IsCollection](Geometry-Accessors/ST_IsCollection.md) | Returns `TRUE` if the geometry type of the input is a geometry collection type. Collection types are the following: |
 | [ST_IsEmpty](Geometry-Accessors/ST_IsEmpty.md) | Test if a geometry is empty geometry |
+| [ST_IsLineStringCCW](Geometry-Accessors/ST_IsLineStringCCW.md) | Returns true if the input LineString's coordinate sequence has counter-clockwise ring orientation. |
 | [ST_IsPolygonCCW](Geometry-Accessors/ST_IsPolygonCCW.md) | Returns true if all polygonal components in the input geometry have their exterior rings oriented counter-clockwise and interior rings oriented clockwise. |
-| [ST_IsPolygonCW](Geometry-Accessors/ST_IsPolygonCW.md) | Returns true if all polygonal components in the input geometry have their exterior rings oriented counter-clockwise and interior rings oriented clockwise. |
+| [ST_IsPolygonCW](Geometry-Accessors/ST_IsPolygonCW.md) | Returns true if all polygonal components in the input geometry have their exterior rings oriented clockwise and interior rings oriented counter-clockwise. |
 | [ST_IsRing](Geometry-Accessors/ST_IsRing.md) | RETURN true if LINESTRING is ST_IsClosed and ST_IsSimple. |
 | [ST_IsSimple](Geometry-Accessors/ST_IsSimple.md) | Test if geometry's only self-intersections are at boundary points. |
 | [ST_NDims](Geometry-Accessors/ST_NDims.md) | Returns the coordinate dimension of the geometry. |
@@ -111,9 +112,9 @@ These functions create modified geometries by changing type, structure, or verti
 | [ST_Force3DZ](Geometry-Editors/ST_Force3DZ.md) | Forces the geometry into a 3-dimensional model so that all output representations will have X, Y and Z coordinates. An optionally given zValue is tacked onto the geometry if the geometry is 2-dimen... |
 | [ST_Force_2D](Geometry-Editors/ST_Force_2D.md) | Forces the geometries into a "2-dimensional mode" so that all output representations will only have the X and Y coordinates. This function is an alias of [ST_Force2D](Geometry-Editors/ST_Force2D.md). |
 | [ST_ForceCollection](Geometry-Editors/ST_ForceCollection.md) | This function converts the input geometry into a GeometryCollection, regardless of the original geometry type. If the input is a multipart geometry, such as a MultiPolygon or MultiLineString, it wi... |
-| [ST_ForcePolygonCCW](Geometry-Editors/ST_ForcePolygonCCW.md) | For (Multi)Polygon geometries, this function sets the exterior ring orientation to counter-clockwise and interior rings to clockwise orientation. Non-polygonal geometries are returned unchanged. |
-| [ST_ForcePolygonCW](Geometry-Editors/ST_ForcePolygonCW.md) | For (Multi)Polygon geometries, this function sets the exterior ring orientation to clockwise and interior rings to counter-clockwise orientation. Non-polygonal geometries are returned unchanged. |
-| [ST_ForceRHR](Geometry-Editors/ST_ForceRHR.md) | Sets the orientation of polygon vertex orderings to follow the Right-Hand-Rule convention. The exterior ring will have a clockwise winding order, while any interior rings are oriented counter-clock... |
+| [ST_ForcePolygonCCW](Geometry-Editors/ST_ForcePolygonCCW.md) | Sets polygon exterior rings counter-clockwise and interior rings clockwise, including polygonal members nested in GeometryCollections. Non-polygonal members are preserved unchanged. |
+| [ST_ForcePolygonCW](Geometry-Editors/ST_ForcePolygonCW.md) | Sets polygon exterior rings clockwise and interior rings counter-clockwise, including polygonal members nested in GeometryCollections. Non-polygonal members are preserved unchanged. |
+| [ST_ForceRHR](Geometry-Editors/ST_ForceRHR.md) | Applies right-hand-rule ring orientation recursively to polygonal members, including those nested in GeometryCollections. Non-polygonal members are preserved unchanged. |
 | [ST_LineFromMultiPoint](Geometry-Editors/ST_LineFromMultiPoint.md) | Creates a LineString from a MultiPoint geometry. |
 | [ST_LineMerge](Geometry-Editors/ST_LineMerge.md) | Returns a LineString or MultiLineString formed by sewing together the constituent line work of a MULTILINESTRING. |
 | [ST_MakeLine](Geometry-Editors/ST_MakeLine.md) | Creates a LineString containing the points of Point, MultiPoint, or LineString geometries. Other geometry types cause an error. |
diff --git a/docs/api/snowflake/vector-data/Geometry-Editors/ST_ForcePolygonCW.md b/docs/api/sql/Geometry-Accessors/ST_IsLineStringCCW.md
similarity index 57%
copy from docs/api/snowflake/vector-data/Geometry-Editors/ST_ForcePolygonCW.md
copy to docs/api/sql/Geometry-Accessors/ST_IsLineStringCCW.md
index 7d65aa2fff9..f75c72d3bcb 100644
--- a/docs/api/snowflake/vector-data/Geometry-Editors/ST_ForcePolygonCW.md
+++ b/docs/api/sql/Geometry-Accessors/ST_IsLineStringCCW.md
@@ -17,24 +17,28 @@
  under the License.
  -->
 
-# ST_ForcePolygonCW
+# ST_IsLineStringCCW
 
-Introduction: For (Multi)Polygon geometries, this function sets the exterior ring orientation to clockwise and interior rings to counter-clockwise orientation. Non-polygonal geometries are returned unchanged.
+Introduction: Returns true if the input is a LineString whose coordinate sequence has counter-clockwise ring orientation. Open LineStrings are evaluated as supplied, without appending the start coordinate. Non-LineString geometries and LineStrings with fewer than four points return false.
 
-![ST_ForcePolygonCW](../../../../image/ST_ForcePolygonCW/ST_ForcePolygonCW.svg "ST_ForcePolygonCW")
+![ST_IsLineStringCCW](../../../image/ST_IsLineStringCCW/ST_IsLineStringCCW.svg "ST_IsLineStringCCW")
 
-Format: `ST_ForcePolygonCW(geom: Geometry)`
+Format: `ST_IsLineStringCCW(geom: Geometry)`
 
-Return type: `Geometry`
+Return type: `Boolean`
+
+Since: `v1.9.1`
 
 SQL Example:
 
 ```sql
-SELECT ST_AsText(ST_ForcePolygonCW(ST_GeomFromText('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))')))
+SELECT ST_IsLineStringCCW(
+    ST_GeomFromWKT('LINESTRING (0 0, 1 0, 1 1, 0 1, 0 0)')
+)
 ```
 
 Output:
 
 ```
-POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))
+true
 ```
diff --git a/docs/api/sql/Geometry-Editors/ST_ForcePolygonCCW.md b/docs/api/sql/Geometry-Editors/ST_ForcePolygonCCW.md
index 4d2eae510db..773b98540df 100644
--- a/docs/api/sql/Geometry-Editors/ST_ForcePolygonCCW.md
+++ b/docs/api/sql/Geometry-Editors/ST_ForcePolygonCCW.md
@@ -19,7 +19,7 @@
 
 # ST_ForcePolygonCCW
 
-Introduction: For (Multi)Polygon geometries, this function sets the exterior ring orientation to counter-clockwise and interior rings to clockwise orientation. Non-polygonal geometries are returned unchanged.
+Introduction: Sets Polygon and MultiPolygon exterior rings counter-clockwise and interior rings clockwise. Polygonal members of GeometryCollections are oriented recursively, while non-polygonal members are preserved unchanged.
 
 ![ST_ForcePolygonCCW](../../../image/ST_ForcePolygonCCW/ST_ForcePolygonCCW.svg "ST_ForcePolygonCCW")
 
diff --git a/docs/api/sql/Geometry-Editors/ST_ForcePolygonCW.md b/docs/api/sql/Geometry-Editors/ST_ForcePolygonCW.md
index 2737e9d3ea3..8b21113e305 100644
--- a/docs/api/sql/Geometry-Editors/ST_ForcePolygonCW.md
+++ b/docs/api/sql/Geometry-Editors/ST_ForcePolygonCW.md
@@ -19,7 +19,7 @@
 
 # ST_ForcePolygonCW
 
-Introduction: For (Multi)Polygon geometries, this function sets the exterior ring orientation to clockwise and interior rings to counter-clockwise orientation. Non-polygonal geometries are returned unchanged.
+Introduction: Sets Polygon and MultiPolygon exterior rings clockwise and interior rings counter-clockwise. Polygonal members of GeometryCollections are oriented recursively, while non-polygonal members are preserved unchanged.
 
 ![ST_ForcePolygonCW](../../../image/ST_ForcePolygonCW/ST_ForcePolygonCW.svg "ST_ForcePolygonCW")
 
diff --git a/docs/api/sql/Geometry-Editors/ST_ForceRHR.md b/docs/api/sql/Geometry-Editors/ST_ForceRHR.md
index 0056ba405d7..967f2bb4d5a 100644
--- a/docs/api/sql/Geometry-Editors/ST_ForceRHR.md
+++ b/docs/api/sql/Geometry-Editors/ST_ForceRHR.md
@@ -19,7 +19,7 @@
 
 # ST_ForceRHR
 
-Introduction: Sets the orientation of polygon vertex orderings to follow the Right-Hand-Rule convention. The exterior ring will have a clockwise winding order, while any interior rings are oriented counter-clockwise. This ensures the area bounded by the polygon falls on the right-hand side relative to the ring directions. The function is an alias for [ST_ForcePolygonCW](ST_ForcePolygonCW.md).
+Introduction: Sets polygon vertex orderings to follow the Right-Hand-Rule convention: exterior rings are clockwise and interior rings are counter-clockwise. Polygonal members of GeometryCollections are oriented recursively, while non-polygonal members are preserved unchanged. The function is an alias for [ST_ForcePolygonCW](ST_ForcePolygonCW.md).
 
 ![ST_ForceRHR](../../../image/ST_ForceRHR/ST_ForceRHR.svg "ST_ForceRHR")
 
diff --git a/docs/api/sql/Geometry-Functions.md b/docs/api/sql/Geometry-Functions.md
index 33b4366b403..c29b9eefe33 100644
--- a/docs/api/sql/Geometry-Functions.md
+++ b/docs/api/sql/Geometry-Functions.md
@@ -82,8 +82,9 @@ These functions extract information and properties from geometry objects.
 | [ST_IsClosed](Geometry-Accessors/ST_IsClosed.md) | Boolean | RETURNS true if the LINESTRING start and end point are the same. | v1.0.0 |
 | [ST_IsCollection](Geometry-Accessors/ST_IsCollection.md) | Boolean | Returns `TRUE` if the geometry type of the input is a geometry collection type. Collection types are the following: | v1.5.0 |
 | [ST_IsEmpty](Geometry-Accessors/ST_IsEmpty.md) | Boolean | Test if a geometry is empty geometry | v1.2.1 |
+| [ST_IsLineStringCCW](Geometry-Accessors/ST_IsLineStringCCW.md) | Boolean | Returns true if the input is a LineString whose coordinate sequence has counter-clockwise ring orientation. Open LineStrings are evaluated as supplied, without appending the start coordinate. | v1.9.1 |
 | [ST_IsPolygonCCW](Geometry-Accessors/ST_IsPolygonCCW.md) | Boolean | Returns true if all polygonal components in the input geometry have their exterior rings oriented counter-clockwise and interior rings oriented clockwise. | v1.6.0 |
-| [ST_IsPolygonCW](Geometry-Accessors/ST_IsPolygonCW.md) | Boolean | Returns true if all polygonal components in the input geometry have their exterior rings oriented counter-clockwise and interior rings oriented clockwise. | v1.6.0 |
+| [ST_IsPolygonCW](Geometry-Accessors/ST_IsPolygonCW.md) | Boolean | Returns true if all polygonal components in the input geometry have their exterior rings oriented clockwise and interior rings oriented counter-clockwise. | v1.6.0 |
 | [ST_IsRing](Geometry-Accessors/ST_IsRing.md) | Boolean | RETURN true if LINESTRING is ST_IsClosed and ST_IsSimple. | v1.0.0 |
 | [ST_IsSimple](Geometry-Accessors/ST_IsSimple.md) | Boolean | Test if geometry's only self-intersections are at boundary points. | v1.0.0 |
 | [ST_M](Geometry-Accessors/ST_M.md) | Double | Returns M Coordinate of given Point, null otherwise. | v1.6.1 |
@@ -119,9 +120,9 @@ These functions create modified geometries by changing type, structure, or verti
 | [ST_Force4D](Geometry-Editors/ST_Force4D.md) | Geometry | Converts the input geometry to 4D XYZM representation. Retains original Z and M values if present. Assigning 0.0 defaults if `mValue` and `zValue` aren't specified. The output contains X, Y, Z, and... | v1.6.1 |
 | [ST_Force_2D](Geometry-Editors/ST_Force_2D.md) | Geometry | Forces the geometries into a "2-dimensional mode" so that all output representations will only have the X and Y coordinates. This function is an alias of [ST_Force2D](Geometry-Editors/ST_Force2D.md). | v1.2.1 |
 | [ST_ForceCollection](Geometry-Editors/ST_ForceCollection.md) | Geometry | This function converts the input geometry into a GeometryCollection, regardless of the original geometry type. If the input is a multipart geometry, such as a MultiPolygon or MultiLineString, it wi... | v1.6.1 |
-| [ST_ForcePolygonCCW](Geometry-Editors/ST_ForcePolygonCCW.md) | Geometry | For (Multi)Polygon geometries, this function sets the exterior ring orientation to counter-clockwise and interior rings to clockwise orientation. Non-polygonal geometries are returned unchanged. | v1.6.0 |
-| [ST_ForcePolygonCW](Geometry-Editors/ST_ForcePolygonCW.md) | Geometry | For (Multi)Polygon geometries, this function sets the exterior ring orientation to clockwise and interior rings to counter-clockwise orientation. Non-polygonal geometries are returned unchanged. | v1.6.0 |
-| [ST_ForceRHR](Geometry-Editors/ST_ForceRHR.md) | Geometry | Sets the orientation of polygon vertex orderings to follow the Right-Hand-Rule convention. The exterior ring will have a clockwise winding order, while any interior rings are oriented counter-clock... | v1.6.1 |
+| [ST_ForcePolygonCCW](Geometry-Editors/ST_ForcePolygonCCW.md) | Geometry | Sets polygon exterior rings counter-clockwise and interior rings clockwise, including polygonal members nested in GeometryCollections. Non-polygonal members are preserved unchanged. | v1.6.0 |
+| [ST_ForcePolygonCW](Geometry-Editors/ST_ForcePolygonCW.md) | Geometry | Sets polygon exterior rings clockwise and interior rings counter-clockwise, including polygonal members nested in GeometryCollections. Non-polygonal members are preserved unchanged. | v1.6.0 |
+| [ST_ForceRHR](Geometry-Editors/ST_ForceRHR.md) | Geometry | Applies right-hand-rule ring orientation recursively to polygonal members, including those nested in GeometryCollections. Non-polygonal members are preserved unchanged. | v1.6.1 |
 | [ST_LineFromMultiPoint](Geometry-Editors/ST_LineFromMultiPoint.md) | Geometry | Creates a LineString from a MultiPoint geometry. | v1.3.0 |
 | [ST_LineMerge](Geometry-Editors/ST_LineMerge.md) | Geometry | Returns a LineString or MultiLineString formed by sewing together the constituent line work of a MULTILINESTRING. | v1.0.0 |
 | [ST_LineSegments](Geometry-Editors/ST_LineSegments.md) | `Array<Geometry>` | This function transforms a LineString containing multiple coordinates into an array of LineStrings, each with precisely two coordinates. The `lenient` argument, true by default, prevents an excepti... | v1.7.1 |
diff --git a/docs/image/ST_IsLineStringCCW/ST_IsLineStringCCW.svg b/docs/image/ST_IsLineStringCCW/ST_IsLineStringCCW.svg
new file mode 100644
index 00000000000..90a56ac51b7
--- /dev/null
+++ b/docs/image/ST_IsLineStringCCW/ST_IsLineStringCCW.svg
@@ -0,0 +1,20 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="500" height="300" viewBox="0 0 500 300">
+  <defs>
+    <style>
+      text { font-family: Arial, Helvetica, sans-serif; }
+    </style>
+  </defs>
+  <rect width="500" height="300" fill="white" rx="8"/>
+  <polyline points="140,250 360,250 360,70 140,70 140,250" fill="none" stroke="#4a90d9" stroke-width="4" stroke-linejoin="round"/>
+  <circle cx="140" cy="250" r="6" fill="#4a90d9"/>
+  <circle cx="360" cy="250" r="6" fill="#4a90d9"/>
+  <circle cx="360" cy="70" r="6" fill="#4a90d9"/>
+  <circle cx="140" cy="70" r="6" fill="#4a90d9"/>
+  <polygon points="250,250 238,243 238,257" fill="#4a90d9"/>
+  <polygon points="360,160 353,172 367,172" fill="#4a90d9"/>
+  <polygon points="250,70 262,63 262,77" fill="#4a90d9"/>
+  <polygon points="140,160 133,148 147,148" fill="#4a90d9"/>
+  <rect x="180" y="8" width="140" height="28" rx="14" fill="#2ecc71" fill-opacity="0.15" stroke="#2ecc71" stroke-width="1.5"/>
+  <text x="250" y="27" text-anchor="middle" fill="#2ecc71" font-size="14" font-weight="bold">CCW → true</text>
+  <text x="490" y="290" text-anchor="end" fill="#999" font-size="11">ST_IsLineStringCCW</text>
+</svg>
diff --git a/flink/src/main/java/org/apache/sedona/flink/Catalog.java b/flink/src/main/java/org/apache/sedona/flink/Catalog.java
index d1528069878..ca5ed46ea61 100644
--- a/flink/src/main/java/org/apache/sedona/flink/Catalog.java
+++ b/flink/src/main/java/org/apache/sedona/flink/Catalog.java
@@ -181,6 +181,7 @@ public class Catalog {
       new Functions.ST_SetSRID(),
       new Functions.ST_SRID(),
       new Functions.ST_IsClosed(),
+      new Functions.ST_IsLineStringCCW(),
       new Functions.ST_IsPolygonCW(),
       new Functions.ST_IsRing(),
       new Functions.ST_IsSimple(),
diff --git a/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java b/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java
index 87059f99078..d0a583713a0 100644
--- a/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java
+++ b/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java
@@ -1903,6 +1903,19 @@ public class Functions {
     }
   }
 
+  public static class ST_IsLineStringCCW extends ScalarFunction {
+    @DataTypeHint("Boolean")
+    public boolean eval(
+        @DataTypeHint(
+                value = "RAW",
+                rawSerializer = GeometryTypeSerializer.class,
+                bridgedTo = Geometry.class)
+            Object o) {
+      Geometry geom = (Geometry) o;
+      return org.apache.sedona.common.Functions.isLineStringCCW(geom);
+    }
+  }
+
   public static class ST_IsPolygonCW extends ScalarFunction {
     @DataTypeHint("Boolean")
     public boolean eval(
diff --git a/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java b/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java
index e0621869e3e..5e265efd0bd 100644
--- a/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java
+++ b/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java
@@ -2595,6 +2595,21 @@ public class FunctionTest extends TestBase {
     assertTrue(actual);
   }
 
+  @Test
+  public void testIsLineStringCCW() {
+    Table result =
+        tableEnv.sqlQuery(
+            "SELECT "
+                + "ST_IsLineStringCCW(ST_GeomFromWKT('LINESTRING (0 0, 1 0, 1 1, 0 1, 0 0)')), "
+                + "ST_IsLineStringCCW(ST_GeomFromWKT('LINESTRING (0 1, 0 -1, -1 -2, 3 -2)')), "
+                + "ST_IsLineStringCCW(ST_GeomFromWKT('POINT (0 0)'))");
+    Row row = first(result);
+
+    assertTrue((boolean) row.getField(0));
+    assertFalse((boolean) row.getField(1));
+    assertFalse((boolean) row.getField(2));
+  }
+
   @Test
   public void testGeneratePoints() {
     Table polyTable =
diff --git a/python/sedona/spark/geopandas/base.py b/python/sedona/spark/geopandas/base.py
index efae3de785d..b049b249bea 100644
--- a/python/sedona/spark/geopandas/base.py
+++ b/python/sedona/spark/geopandas/base.py
@@ -561,9 +561,32 @@ class GeoFrame(metaclass=ABCMeta):
         """
         return _delegate_to_geometry_column("is_ring", self)
 
-    # @property
-    # def is_ccw(self):
-    #     raise NotImplementedError("This method is not implemented yet.")
+    @property
+    def is_ccw(self):
+        """Return a ``Series`` of ``dtype('bool')`` with value ``True`` if a
+        LineString or LinearRing is counter-clockwise.
+
+        This property returns ``False`` for non-linear geometries and for lines
+        with fewer than four points.
+
+        Examples
+        --------
+        >>> from sedona.spark.geopandas import GeoSeries
+        >>> from shapely.geometry import LineString, LinearRing, Point
+        >>> s = GeoSeries(
+        ...     [
+        ...         LinearRing([(0, 0), (1, 0), (1, 1), (0, 1)]),
+        ...         LineString([(0, 0), (1, 1), (1, 0), (0, 0)]),
+        ...         Point(0, 0),
+        ...     ]
+        ... )
+        >>> s.is_ccw
+        0     True
+        1    False
+        2    False
+        dtype: bool
+        """
+        return _delegate_to_geometry_column("is_ccw", self)
 
     @property
     def is_closed(self):
@@ -1175,9 +1198,32 @@ class GeoFrame(metaclass=ABCMeta):
             "offset_curve", self, distance, quad_segs, join_style, mitre_limit
         )
 
-    # @property
-    # def interiors(self):
-    #     raise NotImplementedError("This method is not implemented yet.")
+    @property
+    def interiors(self):
+        """Return a ``Series`` of lists containing polygon interior rings.
+
+        Polygons without holes return an empty list. Non-polygon geometries
+        and null values return ``None``.
+
+        Examples
+        --------
+        >>> from sedona.spark.geopandas import GeoSeries
+        >>> from shapely.geometry import Polygon
+        >>> s = GeoSeries(
+        ...     [
+        ...         Polygon(
+        ...             [(0, 0), (0, 5), (5, 5), (5, 0)],
+        ...             [[(1, 1), (2, 1), (1, 2)]],
+        ...         ),
+        ...         Polygon([(0, 0), (0, 1), (1, 0)]),
+        ...     ]
+        ... )
+        >>> s.interiors
+        0    [LINESTRING (1 1, 2 1, 1 2, 1 1)]
+        1                                   []
+        dtype: object
+        """
+        return _delegate_to_geometry_column("interiors", self)
 
     def remove_repeated_points(self, tolerance=0.0):
         """Return a ``GeoSeries`` with duplicate points removed.
@@ -1398,6 +1444,39 @@ class GeoFrame(metaclass=ABCMeta):
         """
         return _delegate_to_geometry_column("normalize", self)
 
+    def orient_polygons(self, *, exterior_cw=False):
+        """Return geometries with a consistent polygon ring orientation.
+
+        By default, polygon exterior rings are oriented counter-clockwise and
+        interior rings clockwise. Set ``exterior_cw=True`` to use the opposite
+        orientation. Polygonal members of GeometryCollections are processed
+        recursively, while non-polygonal members are left unchanged.
+
+        Parameters
+        ----------
+        exterior_cw : bool, default False
+            If ``True``, orient exterior rings clockwise and interior rings
+            counter-clockwise.
+
+        Returns
+        -------
+        GeoSeries
+
+        Examples
+        --------
+        >>> from sedona.spark.geopandas import GeoSeries
+        >>> from shapely.geometry import Polygon
+        >>> s = GeoSeries(
+        ...     [Polygon([(0, 0), (0, 1), (1, 0), (0, 0)])]
+        ... )
+        >>> s.orient_polygons()
+        0    POLYGON ((0 0, 1 0, 0 1, 0 0))
+        dtype: object
+        """
+        return _delegate_to_geometry_column(
+            "orient_polygons", self, exterior_cw=exterior_cw
+        )
+
     def make_valid(self, *, method="linework", keep_collapsed=True):
         """Repairs invalid geometries.
 
@@ -2077,7 +2156,10 @@ class GeoFrame(metaclass=ABCMeta):
         `interior` of the other but does not contain it, and the dimension of
         the intersection is less than the dimension of the one or the other.
 
-        Note: Unlike Geopandas, Sedona's implementation always return NULL when GeometryCollection is involved.
+        The underlying Sedona expression returns ``NULL`` when a
+        GeometryCollection is involved. This GeoSeries method normalizes that
+        result to ``False`` to preserve GeoPandas' non-nullable boolean
+        contract.
 
         The operation works on a 1-to-1 row-wise manner.
 
diff --git a/python/sedona/spark/geopandas/geoseries.py b/python/sedona/spark/geopandas/geoseries.py
index 9fd4199bdb4..02d79256fde 100644
--- a/python/sedona/spark/geopandas/geoseries.py
+++ b/python/sedona/spark/geopandas/geoseries.py
@@ -703,7 +703,13 @@ class GeoSeries(GeoFrame, pspd.Series):
             index_names=index_names if index_spark_columns else [None],
             column_labels=([(rename,)] if is_aggr else self._internal.column_labels),
             data_spark_columns=[scol_for(sdf, rename)],
-            data_fields=[self._internal.data_fields[0].copy(name=rename)],
+            data_fields=[
+                (
+                    self._internal.data_fields[0].copy(name=rename)
+                    if returns_geom
+                    else InternalField.from_struct_field(sdf.schema[rename])
+                )
+            ],
             column_label_names=[(rename,)],
         )
         ps_series = first_series(PandasOnSparkDataFrame(internal))
@@ -902,17 +908,16 @@ class GeoSeries(GeoFrame, pspd.Series):
 
     @property
     def geom_type(self) -> pspd.Series:
-        spark_col = stf.ST_GeometryType(self.spark.column)
-        result = self._query_geometry_column(
+        # ST_GeometryType returns string as 'ST_Point'
+        # so strip the prefix to get 'Point'.
+        spark_col = F.regexp_replace(
+            stf.ST_GeometryType(self.spark.column), r"^ST_", ""
+        )
+        return self._query_geometry_column(
             spark_col,
             returns_geom=False,
         )
 
-        # ST_GeometryType returns string as 'ST_Point'
-        # we crop the prefix off to get 'Point'
-        result = result.map(lambda x: x[3:])
-        return result
-
     @property
     def type(self):
         return self.geom_type
@@ -1147,13 +1152,16 @@ class GeoSeries(GeoFrame, pspd.Series):
 
     @property
     def is_ccw(self):
-        # Implementation of the abstract method.
-        raise NotImplementedError(
-            _not_implemented_error(
-                "is_ccw",
-                "Tests if LinearRing geometries are oriented counter-clockwise.",
-            )
+        geometry = self.spark.column
+        # Apply JTS' ring-orientation algorithm to the original coordinate
+        # sequence. Open lines must not be closed first because GEOS does not
+        # do so when implementing GeoPandas' is_ccw property.
+        spark_expr = stf.ST_IsLineStringCCW(geometry)
+        result = self._query_geometry_column(
+            spark_expr,
+            returns_geom=False,
         )
+        return _to_bool(result)
 
     @property
     def is_closed(self):
@@ -1352,8 +1360,24 @@ class GeoSeries(GeoFrame, pspd.Series):
 
     @property
     def interiors(self):
-        # Implementation of the abstract method.
-        raise NotImplementedError("This method is not implemented yet.")
+        geometry = self.spark.column
+        ring_count = stf.ST_NumInteriorRings(geometry)
+        empty_rings = F.slice(stf.ST_Dump(geometry), 1, 0)
+        rings = F.transform(
+            F.sequence(F.lit(0), ring_count - 1),
+            lambda index: stf.ST_InteriorRingN(geometry, index),
+        )
+        # Spark creates a descending sequence for sequence(0, -1), so this guard
+        # is required to keep polygons without interior rings as an empty array.
+        polygon_rings = F.when(ring_count > 0, rings).otherwise(empty_rings)
+        spark_expr = F.when(
+            stf.ST_GeometryType(geometry) == "ST_Polygon",
+            polygon_rings,
+        )
+        return self._query_geometry_column(
+            spark_expr,
+            returns_geom=False,
+        )
 
     def remove_repeated_points(self, tolerance=0.0):
         args = (self.spark.column, tolerance) if tolerance else (self.spark.column,)
@@ -1415,6 +1439,21 @@ class GeoSeries(GeoFrame, pspd.Series):
             returns_geom=True,
         )
 
+    def orient_polygons(self, *, exterior_cw=False):
+        if not isinstance(exterior_cw, (bool, np.bool_, int, np.integer)):
+            raise TypeError("'exterior_cw' must be a boolean")
+
+        geometry = self.spark.column
+        oriented = (
+            stf.ST_ForcePolygonCW(geometry)
+            if bool(exterior_cw)
+            else stf.ST_ForcePolygonCCW(geometry)
+        )
+        return self._query_geometry_column(
+            oriented,
+            returns_geom=True,
+        )
+
     def make_valid(self, *, method="linework", keep_collapsed=True) -> "GeoSeries":
         if method != "structure":
             raise ValueError(
@@ -4143,10 +4182,6 @@ def _get_series_col_name(ps_series: pspd.Series) -> str:
 
 def _to_bool(ps_series: pspd.Series, default: bool = False) -> pspd.Series:
     """
-    Cast a ps.Series to bool type if it's not one, converting None values to the default value.
+    Convert null values to the default and return a non-nullable boolean Series.
     """
-    if ps_series.dtype.name != "bool":
-        # fill None values with the default value
-        ps_series.fillna(default, inplace=True)
-
-    return ps_series
+    return ps_series.fillna(default).astype(bool)
diff --git a/python/sedona/spark/sql/st_functions.py b/python/sedona/spark/sql/st_functions.py
index 9fe302e7845..668bfc48ba7 100644
--- a/python/sedona/spark/sql/st_functions.py
+++ b/python/sedona/spark/sql/st_functions.py
@@ -1141,6 +1141,22 @@ def ST_IsClosed(geometry: ColumnOrName) -> Column:
     return _call_st_function("ST_IsClosed", geometry)
 
 
+@validate_argument_types
+def ST_IsLineStringCCW(line_string: ColumnOrName) -> Column:
+    """Check whether a LineString coordinate sequence has counter-clockwise ring orientation.
+
+    Open LineStrings are evaluated as supplied, without appending the start
+    coordinate. Non-LineString geometries and LineStrings with fewer than four
+    points return False.
+
+    :param line_string: LineString geometry column to check.
+    :type line_string: ColumnOrName
+    :return: True if the coordinate sequence is counter-clockwise and False otherwise.
+    :rtype: Column
+    """
+    return _call_st_function("ST_IsLineStringCCW", line_string)
+
+
 @validate_argument_types
 def ST_IsEmpty(geometry: ColumnOrName) -> Column:
     """Check if the geometry in a geometry column is an empty geometry.
@@ -2020,10 +2036,14 @@ def ST_IsPolygonCCW(geometry: ColumnOrName) -> Column:
 
 @validate_argument_types
 def ST_ForcePolygonCCW(geometry: ColumnOrName) -> Column:
-    """
-    Returns a geometry with counter-clockwise oriented exterior ring and clockwise oriented interior rings
-    :param geometry: Geometry column to change orientation
-    :return: counter-clockwise oriented geometry
+    """Orient polygonal components with counter-clockwise exterior rings.
+
+    Interior rings are oriented clockwise. Polygonal members of
+    GeometryCollections are processed recursively, while non-polygonal members
+    are preserved unchanged.
+
+    :param geometry: Geometry column to orient.
+    :return: Geometry with counter-clockwise polygon exterior rings.
     """
     return _call_st_function("ST_ForcePolygonCCW", geometry)
 
@@ -2504,20 +2524,28 @@ def ST_ForceCollection(geometry: ColumnOrName) -> Column:
 
 @validate_argument_types
 def ST_ForcePolygonCW(geometry: ColumnOrName) -> Column:
-    """
-    Returns
-    :param geometry: Geometry column to change orientation
-    :return: Clockwise oriented geometry
+    """Orient polygonal components with clockwise exterior rings.
+
+    Interior rings are oriented counter-clockwise. Polygonal members of
+    GeometryCollections are processed recursively, while non-polygonal members
+    are preserved unchanged.
+
+    :param geometry: Geometry column to orient.
+    :return: Geometry with clockwise polygon exterior rings.
     """
     return _call_st_function("ST_ForcePolygonCW", geometry)
 
 
 @validate_argument_types
 def ST_ForceRHR(geometry: ColumnOrName) -> Column:
-    """
-    Returns
-    :param geometry: Geometry column to change orientation
-    :return: Clockwise oriented geometry
+    """Apply right-hand-rule orientation to polygonal components.
+
+    This is an alias for :func:`ST_ForcePolygonCW`. Polygonal members of
+    GeometryCollections are processed recursively, while non-polygonal members
+    are preserved unchanged.
+
+    :param geometry: Geometry column to orient.
+    :return: Geometry with clockwise polygon exterior rings.
     """
     return _call_st_function("ST_ForceRHR", geometry)
 
diff --git a/python/tests/geopandas/test_geoseries.py b/python/tests/geopandas/test_geoseries.py
index fe8a246efcb..5eabc6e4623 100644
--- a/python/tests/geopandas/test_geoseries.py
+++ b/python/tests/geopandas/test_geoseries.py
@@ -23,6 +23,7 @@ import geopandas as gpd
 import pyspark.pandas as ps
 import sedona.spark.geopandas as sgpd
 from sedona.spark.geopandas import GeoSeries, GeoDataFrame
+from sedona.spark.geopandas.geoseries import _to_bool
 from sedona.spark.sql import st_functions as stf
 from tests.geopandas.test_geopandas_base import TestGeopandasBase
 from shapely import wkt
@@ -67,6 +68,29 @@ class TestGeoSeries(TestGeopandasBase):
         s = sgpd.GeoSeries([])
         assert s.count() == 0
 
+    def test_to_bool_fills_nullable_boolean_series(self):
+        nullable = self.spark.createDataFrame(
+            [(0, True), (1, None), (2, False)],
+            "id long, value boolean",
+        ).pandas_api(index_col="id")["value"]
+
+        self.check_pd_series_equal(
+            _to_bool(nullable),
+            pd.Series(
+                [True, False, False],
+                index=pd.Index([0, 1, 2], name="id"),
+                name="value",
+            ),
+        )
+        self.check_pd_series_equal(
+            _to_bool(nullable, default=True),
+            pd.Series(
+                [True, True, False],
+                index=pd.Index([0, 1, 2], name="id"),
+                name="value",
+            ),
+        )
+
     def test_non_geom_fails(self):
         with pytest.raises(TypeError):
             GeoSeries([0, 1, 2])
@@ -737,6 +761,7 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
                 ),
                 GeometryCollection([Point(0, 0), LineString([(0, 0), (1, 1)])]),
                 LinearRing([(0, 0), (1, 1), (1, 0), (0, 1), (0, 0)]),
+                None,
             ]
         )
         result = geoseries.geom_type
@@ -750,6 +775,7 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
                 "MultiPolygon",
                 "GeometryCollection",
                 "LineString",  # Note: Sedona returns LineString instead of LinearRing
+                None,
             ]
         )
         self.check_pd_series_equal(result, expected)
@@ -1381,7 +1407,64 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         self.check_pd_series_equal(result, expected)
 
     def test_is_ccw(self):
-        pass
+        index = pd.Index(
+            [
+                "ccw-line",
+                "cw-line",
+                "ccw-open-line",
+                "asymmetric-open-line",
+                "open-three-point-line",
+                "closed-three-point-line",
+                "ccw-ring",
+                "polygon",
+                "point",
+                "empty-line",
+                "empty-polygon",
+                "null",
+            ],
+            name="feature_id",
+        )
+        s = GeoSeries(
+            [
+                LineString([(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]),
+                LineString([(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)]),
+                LineString([(0, 0), (1, 0), (1, 1), (0, 1)]),
+                LineString([(0, 1), (0, -1), (-1, -2), (3, -2)]),
+                LineString([(0, 0), (1, 0), (0, 1)]),
+                LineString([(0, 0), (1, 0), (0, 0)]),
+                LinearRing([(0, 0), (1, 0), (1, 1), (0, 1)]),
+                Polygon([(0, 0), (1, 0), (0, 1), (0, 0)]),
+                Point(0, 0),
+                LineString(),
+                Polygon(),
+                None,
+            ],
+            index=index,
+        )
+        expected = pd.Series(
+            [
+                True,
+                False,
+                True,
+                False,
+                False,
+                False,
+                True,
+                False,
+                False,
+                False,
+                False,
+                False,
+            ],
+            index=index,
+        )
+
+        result = s.is_ccw
+        self.check_pd_series_equal(result, expected)
+
+        # Check that GeoDataFrame works too.
+        frame_result = s.to_geoframe().is_ccw
+        self.check_pd_series_equal(frame_result, expected)
 
     def test_is_closed(self):
         s = GeoSeries(
@@ -1912,7 +1995,100 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         self.check_sgpd_equals_gpd(df_result, expected)
 
     def test_interiors(self):
-        pass
+        polygon_with_holes = Polygon(
+            [(0, 0), (0, 5), (5, 5), (5, 0), (0, 0)],
+            [
+                [(1, 1), (2, 1), (1, 2), (1, 1)],
+                [(3, 3), (4, 3), (4, 4), (3, 3)],
+            ],
+        )
+        polygon_without_holes = Polygon([(10, 10), (10, 12), (12, 10), (10, 10)])
+        index = pd.MultiIndex.from_tuples(
+            [
+                ("polygon", "holes"),
+                ("polygon", "no-holes"),
+                ("polygon", "empty"),
+                ("point", "value"),
+                ("point", "empty"),
+                ("line", "empty"),
+                ("multipolygon", "value"),
+                ("collection", "value"),
+                ("null", "value"),
+            ],
+            names=["geometry_type", "case"],
+        )
+        source = GeoSeries(
+            [
+                polygon_with_holes,
+                polygon_without_holes,
+                Polygon(),
+                Point(0, 0),
+                Point(),
+                LineString(),
+                MultiPolygon([polygon_without_holes]),
+                GeometryCollection([polygon_with_holes]),
+                None,
+            ],
+            index=index,
+        )
+
+        result = source.interiors
+
+        assert isinstance(result, ps.Series)
+        actual = result.to_pandas()
+        pd.testing.assert_index_equal(actual.index, index)
+        assert actual.dtype == object
+
+        rings = actual.iloc[0]
+        assert isinstance(rings, list)
+        assert all(isinstance(ring, (LineString, LinearRing)) for ring in rings)
+        assert [list(ring.coords) for ring in rings] == [
+            [(1.0, 1.0), (2.0, 1.0), (1.0, 2.0), (1.0, 1.0)],
+            [(3.0, 3.0), (4.0, 3.0), (4.0, 4.0), (3.0, 3.0)],
+        ]
+        assert actual.iloc[1] == []
+        assert actual.iloc[2] == []
+        assert all(value is None for value in actual.iloc[3:])
+
+        # Check GeoDataFrame delegation separately; GeoSeries.to_geoframe()
+        # does not currently accept a MultiIndex.
+        delegated_index = pd.Index(
+            ["holes", "no-holes", "point", "null"],
+            name="feature_id",
+        )
+        delegated_source = GeoSeries(
+            [
+                polygon_with_holes,
+                polygon_without_holes,
+                Point(0, 0),
+                None,
+            ],
+            index=delegated_index,
+        )
+        frame_result = delegated_source.to_geoframe().interiors
+        assert isinstance(frame_result, ps.Series)
+        frame_actual = frame_result.to_pandas()
+        pd.testing.assert_index_equal(frame_actual.index, delegated_index)
+        assert frame_actual.dtype == object
+        assert [
+            (
+                None
+                if value is None
+                else [
+                    tuple(tuple(coordinate) for coordinate in ring.coords)
+                    for ring in value
+                ]
+            )
+            for value in frame_actual
+        ] == [
+            [
+                ((1.0, 1.0), (2.0, 1.0), (1.0, 2.0), (1.0, 1.0)),
+                ((3.0, 3.0), (4.0, 3.0), (4.0, 4.0), (3.0, 3.0)),
+            ],
+            [],
+            None,
+            None,
+        ]
 
     def test_remove_repeated_points(self):
         s = GeoSeries(
@@ -2117,6 +2293,106 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         df_result = s.to_geoframe().normalize()
         self.check_sgpd_equals_gpd(df_result, expected)
 
+    def test_orient_polygons(self):
+        clockwise_polygon = Polygon(
+            [(0, 0), (0, 5), (5, 5), (5, 0), (0, 0)],
+            [[(1, 1), (4, 1), (4, 4), (1, 4), (1, 1)]],
+        )
+        second_polygon = Polygon([(10, 0), (10, 2), (12, 2), (12, 0), (10, 0)])
+        multipolygon = MultiPolygon([clockwise_polygon, second_polygon])
+        nested_collection = GeometryCollection(
+            [
+                Point(20, 20),
+                GeometryCollection(
+                    [
+                        clockwise_polygon,
+                        MultiPolygon([second_polygon]),
+                    ]
+                ),
+            ]
+        )
+        geoms = [
+            clockwise_polygon,
+            multipolygon,
+            nested_collection,
+            Point(1, 1),
+            LineString([(0, 0), (1, 1)]),
+            Point(),
+            LineString(),
+            Polygon(),
+            MultiPolygon(),
+            GeometryCollection(),
+            None,
+        ]
+        index = pd.Index(
+            [
+                "polygon",
+                "multipolygon",
+                "nested",
+                "point",
+                "line",
+                "empty-point",
+                "empty-line",
+                "empty-polygon",
+                "empty-multipolygon",
+                "empty-collection",
+                "null",
+            ],
+            name="feature_id",
+        )
+        source = GeoSeries(geoms, index=index, crs="EPSG:3857")
+        expected = gpd.GeoSeries(geoms, index=index, crs="EPSG:3857")
+
+        def assert_oriented(geometry, exterior_cw):
+            if geometry is None or geometry.is_empty:
+                return
+            if isinstance(geometry, Polygon):
+                assert bool(geometry.exterior.is_ccw) is not exterior_cw
+                assert all(
+                    bool(ring.is_ccw) is exterior_cw for ring in geometry.interiors
+                )
+            elif isinstance(geometry, (MultiPolygon, GeometryCollection)):
+                for part in geometry.geoms:
+                    assert_oriented(part, exterior_cw)
+
+        for exterior_cw in (False, True):
+            result = source.orient_polygons(exterior_cw=exterior_cw)
+
+            self.check_sgpd_equals_gpd(result, expected)
+            assert result.crs == source.crs
+            actual = result.to_geopandas().sort_index()
+            for geometry in actual:
+                assert_oriented(geometry, exterior_cw)
+
+            nested = actual.loc["nested"]
+            assert isinstance(nested, GeometryCollection)
+            assert isinstance(nested.geoms[1], GeometryCollection)
+            assert isinstance(nested.geoms[1].geoms[1], MultiPolygon)
+
+            for label, geometry_type in [
+                ("empty-point", "Point"),
+                ("empty-line", "LineString"),
+                ("empty-polygon", "Polygon"),
+                ("empty-multipolygon", "MultiPolygon"),
+                ("empty-collection", "GeometryCollection"),
+            ]:
+                assert actual.loc[label].is_empty
+                assert actual.loc[label].geom_type == geometry_type
+            assert actual.loc["null"] is None
+
+            srids = result._internal.spark_frame.select(
+                stf.ST_SRID(result.spark.column).alias("srid")
+            ).collect()
+            assert {row.srid for row in srids if row.srid is not None} == {3857}
+
+            # Check that GeoDataFrame works too.
+            frame_result = source.to_geoframe().orient_polygons(exterior_cw=exterior_cw)
+            self.check_sgpd_equals_gpd(frame_result, expected)
+            assert frame_result.crs == source.crs
+            frame_actual = frame_result.to_geopandas().sort_index()
+            for geometry in frame_actual:
+                assert_oriented(geometry, exterior_cw)
+
     def test_make_valid(self):
         s = sgpd.GeoSeries(
             [
@@ -3272,9 +3548,9 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         df_result = s.to_geoframe().crosses(s2, align=False)
         self.check_pd_series_equal(df_result, expected)
 
-        # Sedona ST_Crosses doesn't support GeometryCollection, so it returns NULL for now.
-        # https://github.com/apache/sedona/issues/2417
-        # Once this is resolved, we can update the expected result of this test.
+        # The underlying ST_Crosses expression returns NULL for GeometryCollection
+        # (https://github.com/apache/sedona/issues/2417), which the GeoSeries
+        # predicate normalizes to False to match GeoPandas' boolean contract.
         # Ensure M-dimension doesn't break things.
         s = GeoSeries(
             [
@@ -3284,7 +3560,7 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         )
         line = LineString([(0, 0), (1, 1)])
         result = s.crosses(line)
-        expected = pd.Series([None, False])
+        expected = pd.Series([False, False], dtype=bool)
         self.check_pd_series_equal(result, expected)
 
     def test_disjoint(self):
diff --git a/python/tests/geopandas/test_match_geopandas_series.py b/python/tests/geopandas/test_match_geopandas_series.py
index b9f8c2aa0ef..085fedca373 100644
--- a/python/tests/geopandas/test_match_geopandas_series.py
+++ b/python/tests/geopandas/test_match_geopandas_series.py
@@ -572,6 +572,11 @@ class TestMatchGeopandasSeries(TestGeopandasBase):
             gpd_result = gpd.GeoSeries(geom).geom_type
             self.check_pd_series_equal(sgpd_result, gpd_result)
 
+        geometries_with_null = [Point(0, 0), None]
+        sgpd_result = GeoSeries(geometries_with_null).geom_type
+        gpd_result = gpd.GeoSeries(geometries_with_null).geom_type
+        self.check_pd_series_equal(sgpd_result, gpd_result)
+
     def test_type(self):
         for geom in self.geoms:
             # Sedona converts LinearRing to LineString
@@ -763,7 +768,26 @@ class TestMatchGeopandasSeries(TestGeopandasBase):
             self.check_pd_series_equal(sgpd_result, gpd_result)
 
     def test_is_ccw(self):
-        pass
+        data = [
+            LineString([(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]),
+            LineString([(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)]),
+            LineString([(0, 0), (1, 0), (1, 1), (0, 1)]),
+            LineString([(0, 1), (0, -1), (-1, -2), (3, -2)]),
+            LineString([(0, 0), (1, 0), (0, 1)]),
+            LineString([(0, 0), (1, 0), (0, 0)]),
+            LinearRing([(0, 0), (1, 0), (1, 1), (0, 1)]),
+            Polygon([(0, 0), (1, 0), (0, 1), (0, 0)]),
+            Point(0, 0),
+            MultiLineString([[(0, 0), (1, 0)], [(1, 0), (1, 1)]]),
+            LineString(),
+            Polygon(),
+            None,
+        ]
+        index = pd.Index(range(10, 23), name="feature_id")
+
+        sgpd_result = GeoSeries(data, index=index).is_ccw
+        gpd_result = gpd.GeoSeries(data, index=index).is_ccw
+        self.check_pd_series_equal(sgpd_result, gpd_result)
 
     def test_is_closed(self):
         if parse_version(gpd.__version__) < parse_version("1.0.0"):
@@ -1021,7 +1045,68 @@ class TestMatchGeopandasSeries(TestGeopandasBase):
             self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
 
     def test_interiors(self):
-        pass
+        polygon_with_holes = Polygon(
+            [(0, 0), (0, 5), (5, 5), (5, 0), (0, 0)],
+            [
+                [(1, 1), (2, 1), (1, 2), (1, 1)],
+                [(3, 3), (4, 3), (4, 4), (3, 3)],
+            ],
+        )
+        polygon_without_holes = Polygon([(10, 10), (10, 12), (12, 10), (10, 10)])
+        data = [
+            polygon_with_holes,
+            polygon_without_holes,
+            Polygon(),
+            Point(0, 0),
+            Point(),
+            LineString(),
+            MultiPolygon([polygon_without_holes]),
+            GeometryCollection([polygon_with_holes]),
+            None,
+        ]
+        index = pd.MultiIndex.from_tuples(
+            [
+                ("polygon", "holes"),
+                ("polygon", "no-holes"),
+                ("polygon", "empty"),
+                ("point", "value"),
+                ("point", "empty"),
+                ("line", "empty"),
+                ("multipolygon", "value"),
+                ("collection", "value"),
+                ("null", "value"),
+            ],
+            names=["geometry_type", "case"],
+        )
+
+        sgpd_result = GeoSeries(data, index=index).interiors.to_pandas()
+        gpd_result = gpd.GeoSeries(data, index=index).interiors
+
+        def normalized(series):
+            return pd.Series(
+                [
+                    (
+                        None
+                        if rings is None
+                        else [
+                            tuple(tuple(coordinate) for coordinate in ring.coords)
+                            for ring in rings
+                        ]
+                    )
+                    for rings in series
+                ],
+                index=series.index,
+                name=series.name,
+                dtype=object,
+            )
+
+        # Sedona serializes rings as LineStrings, while GeoPandas exposes
+        # LinearRings. Coordinate sequences and object-Series null/list
+        # semantics must still match.
+        pd.testing.assert_series_equal(
+            normalized(sgpd_result),
+            normalized(gpd_result),
+        )
 
     def test_remove_repeated_points(self):
         for geom in self.geoms:
@@ -1088,6 +1173,76 @@ class TestMatchGeopandasSeries(TestGeopandasBase):
             gpd_result = gpd.GeoSeries(geom).normalize()
             self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
 
+    def test_orient_polygons(self):
+        if parse_version(gpd.__version__) < parse_version("1.1.0"):
+            pytest.skip("geopandas orient_polygons requires version 1.1.0 or higher")
+        if parse_version(shapely.__version__) < parse_version("2.1.0"):
+            pytest.skip("geopandas orient_polygons requires shapely 2.1.0 or higher")
+
+        clockwise_polygon = Polygon(
+            [(0, 0), (0, 5), (5, 5), (5, 0), (0, 0)],
+            [[(1, 1), (4, 1), (4, 4), (1, 4), (1, 1)]],
+        )
+        second_polygon = Polygon([(10, 0), (10, 2), (12, 2), (12, 0), (10, 0)])
+        nested_collection = GeometryCollection(
+            [
+                Point(20, 20),
+                GeometryCollection(
+                    [
+                        clockwise_polygon,
+                        MultiPolygon([second_polygon]),
+                    ]
+                ),
+            ]
+        )
+        geoms = [
+            clockwise_polygon,
+            MultiPolygon([clockwise_polygon, second_polygon]),
+            nested_collection,
+            Point(1, 1),
+            LineString([(0, 0), (1, 1)]),
+            Point(),
+            LineString(),
+            Polygon(),
+            MultiPolygon(),
+            GeometryCollection(),
+            None,
+        ]
+        index = pd.Index(range(100, 111), name="feature_id")
+
+        def orientation_signature(geometry):
+            if geometry is None:
+                return None
+            if isinstance(geometry, Polygon):
+                if geometry.is_empty:
+                    return ("Polygon", "empty")
+                return (
+                    "Polygon",
+                    bool(geometry.exterior.is_ccw),
+                    tuple(bool(ring.is_ccw) for ring in geometry.interiors),
+                )
+            if isinstance(geometry, (MultiPolygon, GeometryCollection)):
+                return (
+                    geometry.geom_type,
+                    tuple(orientation_signature(part) for part in geometry.geoms),
+                )
+            return (geometry.geom_type, bool(geometry.is_empty))
+
+        for exterior_cw in (False, True):
+            sgpd_result = GeoSeries(geoms, index=index).orient_polygons(
+                exterior_cw=exterior_cw
+            )
+            gpd_result = gpd.GeoSeries(geoms, index=index).orient_polygons(
+                exterior_cw=exterior_cw
+            )
+
+            self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
+            actual = sgpd_result.to_geopandas().sort_index()
+            expected = gpd_result.sort_index()
+            assert [orientation_signature(geometry) for geometry in actual] == [
+                orientation_signature(geometry) for geometry in expected
+            ]
+
     def test_make_valid(self):
         import shapely
 
diff --git a/python/tests/sql/test_dataframe_api.py b/python/tests/sql/test_dataframe_api.py
index dc9c998d8f5..8ca732b5b62 100644
--- a/python/tests/sql/test_dataframe_api.py
+++ b/python/tests/sql/test_dataframe_api.py
@@ -717,6 +717,7 @@ test_configurations = [
     (stf.ST_IsCollection, ("geom",), "geom_collection", "", True),
     (stf.ST_IsClosed, ("geom",), "closed_linestring_geom", "", True),
     (stf.ST_IsEmpty, ("geom",), "empty_geom", "", True),
+    (stf.ST_IsLineStringCCW, ("geom",), "closed_linestring_geom", "", True),
     (stf.ST_IsPolygonCW, ("geom",), "geom_with_hole", "", False),
     (stf.ST_IsPolygonCCW, ("geom",), "geom_with_hole", "", True),
     (stf.ST_IsRing, ("line",), "linestring_geom", "", False),
@@ -1518,6 +1519,7 @@ wrong_type_configurations = [
     (stf.ST_Intersection, ("", None)),
     (stf.ST_IsClosed, (None,)),
     (stf.ST_IsEmpty, (None,)),
+    (stf.ST_IsLineStringCCW, (None,)),
     (stf.ST_IsPolygonCW, (None,)),
     (stf.ST_IsPolygonCCW, (None,)),
     (stf.ST_IsRing, (None,)),
diff --git a/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctions.java b/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctions.java
index 0e66423c10c..e28d2ca2d14 100644
--- a/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctions.java
+++ b/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctions.java
@@ -552,6 +552,17 @@ public class TestFunctions extends TestBase {
     verifySqlSingleRes("select sedona.ST_IsEmpty(sedona.ST_GeomFromText('POINT(1 2)'))", false);
   }
 
+  @Test
+  public void test_ST_IsLineStringCCW() {
+    registerUDF("ST_IsLineStringCCW", byte[].class);
+    verifySqlSingleRes(
+        "select sedona.ST_IsLineStringCCW(sedona.ST_GeomFromText('LINESTRING (0 0, 1 0, 1 1, 0 1, 0 0)'))",
+        true);
+    verifySqlSingleRes(
+        "select sedona.ST_IsLineStringCCW(sedona.ST_GeomFromText('LINESTRING (0 1, 0 -1, -1 -2, 3 -2)'))",
+        false);
+  }
+
   @Test
   public void test_ST_IsRing() {
     registerUDF("ST_IsRing", byte[].class);
diff --git a/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsGeography.java b/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsGeography.java
index 5f71f9437cc..ecdc31813fa 100644
--- a/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsGeography.java
+++ b/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsGeography.java
@@ -39,4 +39,15 @@ public class TestFunctionsGeography extends TestBase {
         "SELECT SEDONA.ST_EqualsExact(ST_GeographyFromWKT('POINT (0 0)'), ST_GeographyFromWKT('POINT (0.03 0.04)'), 0.049)",
         false);
   }
+
+  @Test
+  public void test_ST_IsLineStringCCW() {
+    registerUDFGeography("ST_IsLineStringCCW", String.class);
+    verifySqlSingleRes(
+        "SELECT SEDONA.ST_IsLineStringCCW(ST_GeographyFromWKT('LINESTRING (0 0, 1 0, 1 1, 0 1, 0 0)'))",
+        true);
+    verifySqlSingleRes(
+        "SELECT SEDONA.ST_IsLineStringCCW(ST_GeographyFromWKT('LINESTRING (0 0, 0 1, 1 1, 1 0, 0 0)'))",
+        false);
+  }
 }
diff --git a/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsV2.java b/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsV2.java
index faaf5849e64..8cb5e75391e 100644
--- a/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsV2.java
+++ b/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsV2.java
@@ -532,6 +532,17 @@ public class TestFunctionsV2 extends TestBase {
     verifySqlSingleRes("select sedona.ST_IsEmpty(ST_GeometryFromWKT('POINT(1 2)'))", false);
   }
 
+  @Test
+  public void test_ST_IsLineStringCCW() {
+    registerUDFV2("ST_IsLineStringCCW", String.class);
+    verifySqlSingleRes(
+        "select sedona.ST_IsLineStringCCW(ST_GeometryFromWKT('LINESTRING (0 0, 1 0, 1 1, 0 1, 0 0)'))",
+        true);
+    verifySqlSingleRes(
+        "select sedona.ST_IsLineStringCCW(ST_GeometryFromWKT('LINESTRING (0 1, 0 -1, -1 -2, 3 -2)'))",
+        false);
+  }
+
   @Test
   public void test_ST_IsRing() {
     registerUDFV2("ST_IsRing", String.class);
diff --git a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java
index 205bc4f0a5e..46390a8bfa5 100644
--- a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java
+++ b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java
@@ -579,6 +579,11 @@ public class UDFs {
     return Functions.isEmpty(GeometrySerde.deserialize(geometry));
   }
 
+  @UDFAnnotations.ParamMeta(argNames = {"geometry"})
+  public static boolean ST_IsLineStringCCW(byte[] geometry) {
+    return Functions.isLineStringCCW(GeometrySerde.deserialize(geometry));
+  }
+
   @UDFAnnotations.ParamMeta(argNames = {"geometry"})
   public static boolean ST_IsPolygonCW(byte[] geom) {
     return Functions.isPolygonCW(GeometrySerde.deserialize(geom));
diff --git a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java
index ce938d5ef7e..f7977a3b418 100644
--- a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java
+++ b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java
@@ -732,6 +732,13 @@ public class UDFsV2 {
     return Functions.isEmpty(GeometrySerde.deserGeoJson(geometry));
   }
 
+  @UDFAnnotations.ParamMeta(
+      argNames = {"geometry"},
+      argTypes = {"Geometry"})
+  public static boolean ST_IsLineStringCCW(String geometry) {
+    return Functions.isLineStringCCW(GeometrySerde.deserGeoJson(geometry));
+  }
+
   @UDFAnnotations.ParamMeta(
       argNames = {"geom"},
       argTypes = {"Geometry"})
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 7c1ec41f459..bdb1335bf7c 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
@@ -98,6 +98,7 @@ object Catalog extends AbstractCatalog with Logging {
     function[ST_IsClosed](),
     function[ST_IsCollection](),
     function[ST_IsEmpty](),
+    function[ST_IsLineStringCCW](),
     function[ST_IsPolygonCCW](),
     function[ST_IsPolygonCW](),
     function[ST_IsRing](),
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 1a00dbcadc6..a80e2eada81 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
@@ -1934,6 +1934,13 @@ private[apache] case class ST_IsPolygonCCW(inputExpressions: Seq[Expression])
   }
 }
 
+private[apache] case class ST_IsLineStringCCW(inputExpressions: Seq[Expression])
+    extends InferredExpression(Functions.isLineStringCCW _) {
+  protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
+    copy(inputExpressions = newChildren)
+  }
+}
+
 private[apache] case class ST_ForcePolygonCCW(inputExpressions: Seq[Expression])
     extends InferredExpression(Functions.forcePolygonCCW _) {
   protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = {
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 4d93f5440f4..df2c8f94a00 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
@@ -584,6 +584,11 @@ object st_functions {
   def ST_IsPolygonCCW(geometry: Column): Column = wrapExpression[ST_IsPolygonCCW](geometry)
   def ST_IsPolygonCCW(geometry: String): Column = wrapExpression[ST_IsPolygonCCW](geometry)
 
+  def ST_IsLineStringCCW(geometry: Column): Column =
+    wrapExpression[ST_IsLineStringCCW](geometry)
+  def ST_IsLineStringCCW(geometry: String): Column =
+    wrapExpression[ST_IsLineStringCCW](geometry)
+
   def ST_ForcePolygonCCW(geometry: Column): Column = wrapExpression[ST_ForcePolygonCCW](geometry)
   def ST_ForcePolygonCCW(geometry: String): Column = wrapExpression[ST_ForcePolygonCCW](geometry)
 
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 813463c5109..d88fa1457b9 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
@@ -2276,6 +2276,14 @@ class dataFrameAPITestScala extends TestBaseScala {
       assertFalse(actual)
     }
 
+    it("Passed ST_IsLineStringCCW") {
+      val baseDf =
+        sparkSession.sql("SELECT ST_GeomFromWKT('LINESTRING (0 0, 1 0, 1 1, 0 1, 0 0)') AS line")
+
+      assertTrue(baseDf.select(ST_IsLineStringCCW("line")).first().getBoolean(0))
+      assertTrue(baseDf.select(ST_IsLineStringCCW(col("line"))).first().getBoolean(0))
+    }
+
     it("Passed ST_Translate") {
       val polyDf = sparkSession.sql(
         "SELECT ST_GeomFromWKT('POLYGON ((1 0 1, 1 1 1, 2 1 1, 2 0 1, 1 0 1))') AS geom")
diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala b/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala
index dd1d0f626e0..b06a9012554 100644
--- a/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala
+++ b/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala
@@ -1563,6 +1563,21 @@ class functionTestScala
       assert(actual == false)
     }
 
+    it("Should pass ST_IsLineStringCCW") {
+      val actual = sparkSession
+        .sql("""
+            |SELECT
+            |  ST_IsLineStringCCW(ST_GeomFromWKT('LINESTRING (0 0, 1 0, 1 1, 0 1, 0 0)')),
+            |  ST_IsLineStringCCW(ST_GeomFromWKT('LINESTRING (0 0, 0 1, 1 1, 1 0, 0 0)')),
+            |  ST_IsLineStringCCW(ST_GeomFromWKT('POINT (0 0)'))
+            |""".stripMargin)
+        .first()
+
+      assertTrue(actual.getBoolean(0))
+      assertFalse(actual.getBoolean(1))
+      assertFalse(actual.getBoolean(2))
+    }
+
     it("Should pass ST_Snap") {
       val baseDf = sparkSession.sql(
         "SELECT ST_GeomFromWKT('POLYGON((2.6 12.5, 2.6 20.0, 12.6 20.0, 12.6 12.5, 2.6 12.5 ))') AS poly, ST_GeomFromWKT('LINESTRING (0.5 10.7, 5.4 8.4, 10.1 10.0)') AS line")