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.
-
+
-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.

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.

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

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.
-
+
-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.

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.

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

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.
-
+
-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.

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.

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

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