From e148e3642213945d4e5491729aa660eba243caab Mon Sep 17 00:00:00 2001 From: Jia Yu Date: Sat, 25 Jul 2026 00:19:56 -0700 Subject: [PATCH 1/5] [GH-2385][GH-3168][GH-3169] Add distributed GeoSeries ring methods --- .../org/apache/sedona/common/Functions.java | 60 ++++- .../apache/sedona/common/FunctionsTest.java | 133 +++++++++ python/sedona/spark/geopandas/base.py | 91 ++++++- python/sedona/spark/geopandas/geoseries.py | 75 ++++-- python/tests/geopandas/test_geoseries.py | 254 +++++++++++++++++- .../geopandas/test_match_geopandas_series.py | 154 ++++++++++- .../sedona_sql/expressions/Functions.scala | 7 + .../sedona_sql/expressions/st_functions.scala | 5 + 8 files changed, 744 insertions(+), 35 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..4023b75d377 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 static boolean isRing(Geometry geometry) { && 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 static double perimeter(Geometry geometry) { /** * 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 (isHeterogeneousGeometryCollection(geom)) { + return forcePolygonOrientationInCollection((GeometryCollection) geom, true); + } + if (isPolygonCW(geom)) { return geom; } @@ -1658,12 +1673,21 @@ public static Geometry locateAlong(Geometry linear, double measure) { /** * 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 (isHeterogeneousGeometryCollection(geom)) { + return forcePolygonOrientationInCollection((GeometryCollection) geom, false); + } + if (isPolygonCCW(geom)) { return geom; } @@ -1684,6 +1708,34 @@ public static Geometry forcePolygonCCW(Geometry geom) { return geom; } + private static boolean isHeterogeneousGeometryCollection(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 void testForcePolygonCCW() throws ParseException { 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/python/sedona/spark/geopandas/base.py b/python/sedona/spark/geopandas/base.py index efae3de785d..dec955311c0 100644 --- a/python/sedona/spark/geopandas/base.py +++ b/python/sedona/spark/geopandas/base.py @@ -561,9 +561,32 @@ def is_ring(self): """ 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 @@ def offset_curve(self, distance, quad_segs=8, join_style="round", mitre_limit=5. "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 @@ def normalize(self): """ 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. diff --git a/python/sedona/spark/geopandas/geoseries.py b/python/sedona/spark/geopandas/geoseries.py index 9fd4199bdb4..144c792b5e8 100644 --- a/python/sedona/spark/geopandas/geoseries.py +++ b/python/sedona/spark/geopandas/geoseries.py @@ -703,7 +703,13 @@ def _query_geometry_column( 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 @@ def area(self) -> 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 @@ def is_ring(self): @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._call_st_function("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,22 @@ def offset_curve(self, distance, quad_segs=8, join_style="round", mitre_limit=5. @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), + ) + 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 +1437,21 @@ def normalize(self): 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( @@ -4145,8 +4182,4 @@ 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. """ - 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/tests/geopandas/test_geoseries.py b/python/tests/geopandas/test_geoseries.py index fe8a246efcb..3b8e651d3eb 100644 --- a/python/tests/geopandas/test_geoseries.py +++ b/python/tests/geopandas/test_geoseries.py @@ -1381,7 +1381,64 @@ def test_is_ring(self): 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 +1969,100 @@ def test_offset_curve(self): 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 +2267,106 @@ def test_normalize(self): 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( [ diff --git a/python/tests/geopandas/test_match_geopandas_series.py b/python/tests/geopandas/test_match_geopandas_series.py index b9f8c2aa0ef..b8177a53c83 100644 --- a/python/tests/geopandas/test_match_geopandas_series.py +++ b/python/tests/geopandas/test_match_geopandas_series.py @@ -763,7 +763,26 @@ def test_is_ring(self): 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 +1040,68 @@ def test_offset_curve(self): 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 +1168,76 @@ def test_normalize(self): 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/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..b657c44b2b9 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) + // Internal bridge for the distributed GeoPandas is_ccw implementation. + // It is intentionally not registered in the SQL function catalog. + def ST_IsLineStringCCW(geometry: Column): 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) From dc338861fc64c90b95cd212c954a3021dc26018e Mon Sep 17 00:00:00 2001 From: Jia Yu Date: Sat, 25 Jul 2026 01:27:10 -0700 Subject: [PATCH 2/5] Address ring method review feedback --- .../org/apache/sedona/common/Functions.java | 6 ++-- .../Geometry-Editors/ST_ForcePolygonCCW.md | 2 +- .../Geometry-Editors/ST_ForcePolygonCW.md | 2 +- .../api/flink/Geometry-Editors/ST_ForceRHR.md | 2 +- docs/api/flink/Geometry-Functions.md | 6 ++-- .../Geometry-Editors/ST_ForcePolygonCCW.md | 2 +- .../Geometry-Editors/ST_ForcePolygonCW.md | 2 +- .../Geometry-Editors/ST_ForceRHR.md | 2 +- .../vector-data/Geometry-Functions.md | 6 ++-- .../Geometry-Editors/ST_ForcePolygonCCW.md | 2 +- .../sql/Geometry-Editors/ST_ForcePolygonCW.md | 2 +- docs/api/sql/Geometry-Editors/ST_ForceRHR.md | 2 +- docs/api/sql/Geometry-Functions.md | 6 ++-- python/sedona/spark/geopandas/base.py | 7 ++-- python/sedona/spark/geopandas/geoseries.py | 4 ++- python/tests/geopandas/test_geoseries.py | 34 ++++++++++++++++--- .../geopandas/test_match_geopandas_series.py | 5 +++ 17 files changed, 64 insertions(+), 28 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 4023b75d377..62df68a6258 100644 --- a/common/src/main/java/org/apache/sedona/common/Functions.java +++ b/common/src/main/java/org/apache/sedona/common/Functions.java @@ -1561,7 +1561,7 @@ public static Geometry forcePolygonCW(Geometry geom) { return geom; } - if (isHeterogeneousGeometryCollection(geom)) { + if (isPlainGeometryCollection(geom)) { return forcePolygonOrientationInCollection((GeometryCollection) geom, true); } @@ -1684,7 +1684,7 @@ public static Geometry forcePolygonCCW(Geometry geom) { return geom; } - if (isHeterogeneousGeometryCollection(geom)) { + if (isPlainGeometryCollection(geom)) { return forcePolygonOrientationInCollection((GeometryCollection) geom, false); } @@ -1708,7 +1708,7 @@ public static Geometry forcePolygonCCW(Geometry geom) { return geom; } - private static boolean isHeterogeneousGeometryCollection(Geometry geometry) { + private static boolean isPlainGeometryCollection(Geometry geometry) { return geometry instanceof GeometryCollection && !(geometry instanceof MultiPoint) && !(geometry instanceof MultiLineString) 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..928195cf67b 100644 --- a/docs/api/flink/Geometry-Functions.md +++ b/docs/api/flink/Geometry-Functions.md @@ -118,9 +118,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` | 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_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..0811a4e3bdb 100644 --- a/docs/api/snowflake/vector-data/Geometry-Functions.md +++ b/docs/api/snowflake/vector-data/Geometry-Functions.md @@ -111,9 +111,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/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..42d873bd502 100644 --- a/docs/api/sql/Geometry-Functions.md +++ b/docs/api/sql/Geometry-Functions.md @@ -119,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.0.0 | | [ST_LineSegments](Geometry-Editors/ST_LineSegments.md) | `Array` | 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/python/sedona/spark/geopandas/base.py b/python/sedona/spark/geopandas/base.py index dec955311c0..b049b249bea 100644 --- a/python/sedona/spark/geopandas/base.py +++ b/python/sedona/spark/geopandas/base.py @@ -1220,7 +1220,7 @@ def interiors(self): ... ) >>> s.interiors 0 [LINESTRING (1 1, 2 1, 1 2, 1 1)] - 1 [] + 1 [] dtype: object """ return _delegate_to_geometry_column("interiors", self) @@ -2156,7 +2156,10 @@ def crosses(self, other, align=None) -> ps.Series: `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 144c792b5e8..eaf5da9a57c 100644 --- a/python/sedona/spark/geopandas/geoseries.py +++ b/python/sedona/spark/geopandas/geoseries.py @@ -1367,6 +1367,8 @@ def interiors(self): 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", @@ -4180,6 +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. """ return ps_series.fillna(default).astype(bool) diff --git a/python/tests/geopandas/test_geoseries.py b/python/tests/geopandas/test_geoseries.py index 3b8e651d3eb..5eabc6e4623 100644 --- a/python/tests/geopandas/test_geoseries.py +++ b/python/tests/geopandas/test_geoseries.py @@ -23,6 +23,7 @@ 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 @@ def test_empty_list(self): 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 @@ def test_geom_type(self): ), 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 @@ def test_geom_type(self): "MultiPolygon", "GeometryCollection", "LineString", # Note: Sedona returns LineString instead of LinearRing + None, ] ) self.check_pd_series_equal(result, expected) @@ -3522,9 +3548,9 @@ def test_crosses(self): 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( [ @@ -3534,7 +3560,7 @@ def test_crosses(self): ) 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 b8177a53c83..085fedca373 100644 --- a/python/tests/geopandas/test_match_geopandas_series.py +++ b/python/tests/geopandas/test_match_geopandas_series.py @@ -572,6 +572,11 @@ def test_geom_type(self): 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 From 6311c17fb628643981e5a2fa0702ab56212a51a3 Mon Sep 17 00:00:00 2001 From: Jia Yu Date: Sat, 25 Jul 2026 01:25:02 -0700 Subject: [PATCH 3/5] [GH-2385] Add ST_IsLineStringCCW to Flink and Snowflake --- .../Geometry-Accessors/ST_IsLineStringCCW.md | 44 +++++++++++++++++++ docs/api/flink/Geometry-Functions.md | 1 + .../Geometry-Accessors/ST_IsLineStringCCW.md | 42 ++++++++++++++++++ .../vector-data/Geometry-Functions.md | 1 + .../java/org/apache/sedona/flink/Catalog.java | 1 + .../sedona/flink/expressions/Functions.java | 13 ++++++ .../org/apache/sedona/flink/FunctionTest.java | 15 +++++++ .../snowflake/snowsql/TestFunctions.java | 11 +++++ .../snowsql/TestFunctionsGeography.java | 11 +++++ .../snowflake/snowsql/TestFunctionsV2.java | 11 +++++ .../apache/sedona/snowflake/snowsql/UDFs.java | 5 +++ .../sedona/snowflake/snowsql/UDFsV2.java | 7 +++ 12 files changed, 162 insertions(+) create mode 100644 docs/api/flink/Geometry-Accessors/ST_IsLineStringCCW.md create mode 100644 docs/api/snowflake/vector-data/Geometry-Accessors/ST_IsLineStringCCW.md diff --git a/docs/api/flink/Geometry-Accessors/ST_IsLineStringCCW.md b/docs/api/flink/Geometry-Accessors/ST_IsLineStringCCW.md new file mode 100644 index 00000000000..a1c01f0f4bf --- /dev/null +++ b/docs/api/flink/Geometry-Accessors/ST_IsLineStringCCW.md @@ -0,0 +1,44 @@ + + +# ST_IsLineStringCCW + +Introduction: Returns true if the input LineString's coordinate sequence has counter-clockwise ring orientation. + +![ST_IsLineStringCCW](../../../image/ST_IsLineStringCCW/ST_IsLineStringCCW.svg "ST_IsLineStringCCW") + +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. + +Format: `ST_IsLineStringCCW(geom: Geometry)` + +Return type: `Boolean` + +Since: `v1.9.1` + +SQL Example: + +```sql +SELECT ST_IsLineStringCCW(ST_GeomFromWKT('LINESTRING (0 0, 1 0, 1 1, 0 1, 0 0)')) +``` + +Output: + +``` +true +``` diff --git a/docs/api/flink/Geometry-Functions.md b/docs/api/flink/Geometry-Functions.md index 928195cf67b..48defbeb015 100644 --- a/docs/api/flink/Geometry-Functions.md +++ b/docs/api/flink/Geometry-Functions.md @@ -81,6 +81,7 @@ 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_IsRing](Geometry-Accessors/ST_IsRing.md) | Boolean | RETURN true if LINESTRING is ST_IsClosed and ST_IsSimple. | v1.3.0 | diff --git a/docs/api/snowflake/vector-data/Geometry-Accessors/ST_IsLineStringCCW.md b/docs/api/snowflake/vector-data/Geometry-Accessors/ST_IsLineStringCCW.md new file mode 100644 index 00000000000..b11a6e7f0f1 --- /dev/null +++ b/docs/api/snowflake/vector-data/Geometry-Accessors/ST_IsLineStringCCW.md @@ -0,0 +1,42 @@ + + +# ST_IsLineStringCCW + +Introduction: Returns true if the input LineString's coordinate sequence has counter-clockwise ring orientation. + +![ST_IsLineStringCCW](../../../../image/ST_IsLineStringCCW/ST_IsLineStringCCW.svg "ST_IsLineStringCCW") + +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. + +Format: `ST_IsLineStringCCW(geom: Geometry)` + +Return type: `Boolean` + +SQL Example: + +```sql +SELECT ST_IsLineStringCCW(ST_GeomFromWKT('LINESTRING (0 0, 1 0, 1 1, 0 1, 0 0)')) +``` + +Output: + +``` +true +``` diff --git a/docs/api/snowflake/vector-data/Geometry-Functions.md b/docs/api/snowflake/vector-data/Geometry-Functions.md index 0811a4e3bdb..a543f240a37 100644 --- a/docs/api/snowflake/vector-data/Geometry-Functions.md +++ b/docs/api/snowflake/vector-data/Geometry-Functions.md @@ -78,6 +78,7 @@ 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_IsRing](Geometry-Accessors/ST_IsRing.md) | RETURN true if LINESTRING is ST_IsClosed and ST_IsSimple. | 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 static UserDefinedFunction[] getFuncs() { 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 boolean eval( } } + 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 void testIsPolygonCW() { 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/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 void test_ST_IsEmpty() { 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 void test_ST_EqualsExact() { "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 void test_ST_IsEmpty() { 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 static boolean ST_IsEmpty(byte[] geometry) { 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 static boolean ST_IsEmpty(String geometry) { 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"}) From 2957fffd9adbaa7d491250fbf34c729b9ca0ed97 Mon Sep 17 00:00:00 2001 From: Jia Yu Date: Sat, 25 Jul 2026 01:29:51 -0700 Subject: [PATCH 4/5] [GH-2385] Expose line orientation through Spark APIs --- .../Geometry-Accessors/ST_IsLineStringCCW.md | 44 ++++++++++++++++ docs/api/sql/Geometry-Functions.md | 1 + .../ST_IsLineStringCCW/ST_IsLineStringCCW.svg | 20 +++++++ python/sedona/spark/geopandas/geoseries.py | 2 +- python/sedona/spark/sql/st_functions.py | 52 ++++++++++++++----- python/tests/sql/test_dataframe_api.py | 2 + .../org/apache/sedona/sql/UDF/Catalog.scala | 1 + .../sedona_sql/expressions/st_functions.scala | 4 +- .../sedona/sql/dataFrameAPITestScala.scala | 8 +++ .../apache/sedona/sql/functionTestScala.scala | 15 ++++++ 10 files changed, 134 insertions(+), 15 deletions(-) create mode 100644 docs/api/sql/Geometry-Accessors/ST_IsLineStringCCW.md create mode 100644 docs/image/ST_IsLineStringCCW/ST_IsLineStringCCW.svg diff --git a/docs/api/sql/Geometry-Accessors/ST_IsLineStringCCW.md b/docs/api/sql/Geometry-Accessors/ST_IsLineStringCCW.md new file mode 100644 index 00000000000..f75c72d3bcb --- /dev/null +++ b/docs/api/sql/Geometry-Accessors/ST_IsLineStringCCW.md @@ -0,0 +1,44 @@ + + +# ST_IsLineStringCCW + +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_IsLineStringCCW](../../../image/ST_IsLineStringCCW/ST_IsLineStringCCW.svg "ST_IsLineStringCCW") + +Format: `ST_IsLineStringCCW(geom: Geometry)` + +Return type: `Boolean` + +Since: `v1.9.1` + +SQL Example: + +```sql +SELECT ST_IsLineStringCCW( + ST_GeomFromWKT('LINESTRING (0 0, 1 0, 1 1, 0 1, 0 0)') +) +``` + +Output: + +``` +true +``` diff --git a/docs/api/sql/Geometry-Functions.md b/docs/api/sql/Geometry-Functions.md index 42d873bd502..e7586aa22d2 100644 --- a/docs/api/sql/Geometry-Functions.md +++ b/docs/api/sql/Geometry-Functions.md @@ -82,6 +82,7 @@ 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_IsRing](Geometry-Accessors/ST_IsRing.md) | Boolean | RETURN true if LINESTRING is ST_IsClosed and ST_IsSimple. | v1.0.0 | 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 @@ + + + + + + + + + + + + + + + + CCW → true + ST_IsLineStringCCW + diff --git a/python/sedona/spark/geopandas/geoseries.py b/python/sedona/spark/geopandas/geoseries.py index eaf5da9a57c..02d79256fde 100644 --- a/python/sedona/spark/geopandas/geoseries.py +++ b/python/sedona/spark/geopandas/geoseries.py @@ -1156,7 +1156,7 @@ def is_ccw(self): # 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._call_st_function("ST_IsLineStringCCW", geometry) + spark_expr = stf.ST_IsLineStringCCW(geometry) result = self._query_geometry_column( spark_expr, returns_geom=False, 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/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 @@ (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 @@ (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/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/st_functions.scala b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala index b657c44b2b9..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,10 +584,10 @@ object st_functions { def ST_IsPolygonCCW(geometry: Column): Column = wrapExpression[ST_IsPolygonCCW](geometry) def ST_IsPolygonCCW(geometry: String): Column = wrapExpression[ST_IsPolygonCCW](geometry) - // Internal bridge for the distributed GeoPandas is_ccw implementation. - // It is intentionally not registered in the SQL function catalog. 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") From ddee3ac0f7b9d489749d9320cff1bb9d7e62a5ab Mon Sep 17 00:00:00 2001 From: Jia Yu Date: Sat, 25 Jul 2026 14:13:18 -0700 Subject: [PATCH 5/5] Fix polygon orientation summary descriptions --- docs/api/flink/Geometry-Functions.md | 2 +- docs/api/snowflake/vector-data/Geometry-Functions.md | 2 +- docs/api/sql/Geometry-Functions.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api/flink/Geometry-Functions.md b/docs/api/flink/Geometry-Functions.md index 48defbeb015..f3e9d279f90 100644 --- a/docs/api/flink/Geometry-Functions.md +++ b/docs/api/flink/Geometry-Functions.md @@ -83,7 +83,7 @@ These functions extract information and properties from geometry objects. | [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 | diff --git a/docs/api/snowflake/vector-data/Geometry-Functions.md b/docs/api/snowflake/vector-data/Geometry-Functions.md index a543f240a37..cd888855499 100644 --- a/docs/api/snowflake/vector-data/Geometry-Functions.md +++ b/docs/api/snowflake/vector-data/Geometry-Functions.md @@ -80,7 +80,7 @@ These functions extract information and properties from geometry objects. | [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. | diff --git a/docs/api/sql/Geometry-Functions.md b/docs/api/sql/Geometry-Functions.md index e7586aa22d2..c29b9eefe33 100644 --- a/docs/api/sql/Geometry-Functions.md +++ b/docs/api/sql/Geometry-Functions.md @@ -84,7 +84,7 @@ These functions extract information and properties from geometry objects. | [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 |