diff --git a/python/sedona/spark/geopandas/base.py b/python/sedona/spark/geopandas/base.py index dc249d6036..ba6af7f331 100644 --- a/python/sedona/spark/geopandas/base.py +++ b/python/sedona/spark/geopandas/base.py @@ -994,8 +994,50 @@ def extract_unique_points(self): """ return _delegate_to_geometry_column("extract_unique_points", self) - # def offset_curve(self, distance, quad_segs=8, join_style="round", mitre_limit=5.0): - # raise NotImplementedError("This method is not implemented yet.") + def offset_curve(self, distance, quad_segs=8, join_style="round", mitre_limit=5.0): + """Returns a line at a given offset distance from each linear geometry. + + Positive distance offsets to the left, negative to the right. + + Parameters + ---------- + distance : float + The offset distance. Positive offsets to the left, negative to the + right. + quad_segs : int, default 8 + Number of segments to approximate a quarter circle. + join_style : str, default "round" + Accepted values are "round", "mitre", and "bevel". + + .. note:: + ``join_style`` and ``mitre_limit`` are accepted for API + compatibility but are currently ignored by Sedona's + ``ST_OffsetCurve``. + + mitre_limit : float, default 5.0 + Limit on the mitre ratio. + + Returns + ------- + GeoSeries + + Examples + -------- + >>> from sedona.spark.geopandas import GeoSeries + >>> from shapely.geometry import LineString + >>> s = GeoSeries( + ... [ + ... LineString([(0, 0), (10, 0)]), + ... ] + ... ) + >>> s.offset_curve(1.0) + 0 LINESTRING (0 1, 10 1) + dtype: geometry + + """ + return _delegate_to_geometry_column( + "offset_curve", self, distance, quad_segs, join_style, mitre_limit + ) # @property # def interiors(self): @@ -2822,6 +2864,51 @@ def intersection(self, other, align=None): """ return _delegate_to_geometry_column("intersection", self, other, align) + def shortest_line(self, other, align=None): + """Returns the shortest line between each geometry in the ``GeoSeries`` + and `other`. + + The resulting line starts on this geometry and ends on `other`. + + The operation works on a 1-to-1 row-wise manner: + + Parameters + ---------- + other : GeoSeries or geometric object + The GeoSeries (elementwise) or geometric object to find the + shortest line to. + align : bool | None (default None) + If True, automatically aligns GeoSeries based on their indices. None defaults to True. + If False, the order of elements is preserved. + + Returns + ------- + GeoSeries + + Examples + -------- + >>> from sedona.spark.geopandas import GeoSeries + >>> from shapely.geometry import Point, LineString + >>> s1 = GeoSeries( + ... [ + ... Point(0, 0), + ... LineString([(0, 0), (1, 0)]), + ... ] + ... ) + >>> s2 = GeoSeries( + ... [ + ... Point(1, 1), + ... Point(0, 1), + ... ] + ... ) + >>> s1.shortest_line(s2, align=False) + 0 LINESTRING (0 0, 1 1) + 1 LINESTRING (0 0, 0 1) + dtype: geometry + + """ + return _delegate_to_geometry_column("shortest_line", self, other, align) + def snap(self, other, tolerance, align=None): """Snap the vertices and segments of the geometry to vertices of the reference. diff --git a/python/sedona/spark/geopandas/geoseries.py b/python/sedona/spark/geopandas/geoseries.py index 4b3a87ab9a..3e3215e4e2 100644 --- a/python/sedona/spark/geopandas/geoseries.py +++ b/python/sedona/spark/geopandas/geoseries.py @@ -1058,8 +1058,17 @@ def extract_unique_points(self): ) def offset_curve(self, distance, quad_segs=8, join_style="round", mitre_limit=5.0): - # Implementation of the abstract method. - raise NotImplementedError("This method is not implemented yet.") + # ST_OffsetCurve returns null for empty geometries, but GeoPandas returns LINESTRING EMPTY. + # Preserve the input's SRID on the empty fallback so CRS is not silently dropped. + empty_line = stf.ST_SetSRID( + stc.ST_GeomFromText(F.lit("LINESTRING EMPTY")), + stf.ST_SRID(self.spark.column), + ) + spark_col = F.when( + stf.ST_IsEmpty(self.spark.column), + empty_line, + ).otherwise(stf.ST_OffsetCurve(self.spark.column, distance, quad_segs)) + return self._query_geometry_column(spark_col, returns_geom=True) @property def interiors(self): @@ -1528,6 +1537,18 @@ def intersection( ) return result + def shortest_line(self, other, align=None) -> "GeoSeries": + other_series, extended = self._make_series_of_val(other) + align = False if extended else align + + spark_expr = stf.ST_ShortestLine(F.col("L"), F.col("R")) + return self._row_wise_operation( + spark_expr, + other_series, + align=align, + returns_geom=True, + ) + def snap(self, other, tolerance, align=None) -> "GeoSeries": if not isinstance(tolerance, (float, int)): raise NotImplementedError( diff --git a/python/tests/geopandas/test_geoseries.py b/python/tests/geopandas/test_geoseries.py index c1f97cb52e..abe8db32d4 100644 --- a/python/tests/geopandas/test_geoseries.py +++ b/python/tests/geopandas/test_geoseries.py @@ -1544,7 +1544,41 @@ def test_extract_unique_points(self): self.check_sgpd_equals_gpd(df_result, expected) def test_offset_curve(self): - pass + s = GeoSeries( + [ + LineString([(0, 0), (0, 1), (1, 1)]), + LineString([(0, 0), (10, 0)]), + ] + ) + + result = s.offset_curve(1.0) + expected = gpd.GeoSeries( + [ + LineString([(0, 0), (0, 1), (1, 1)]), + LineString([(0, 0), (10, 0)]), + ] + ).offset_curve(1.0) + self.check_sgpd_equals_gpd(result, expected) + + # Negative distance (right side) + result = s.offset_curve(-1.0) + expected = gpd.GeoSeries( + [ + LineString([(0, 0), (0, 1), (1, 1)]), + LineString([(0, 0), (10, 0)]), + ] + ).offset_curve(-1.0) + self.check_sgpd_equals_gpd(result, expected) + + # Check that GeoDataFrame works too + df_result = s.to_geoframe().offset_curve(1.0) + expected = gpd.GeoSeries( + [ + LineString([(0, 0), (0, 1), (1, 1)]), + LineString([(0, 0), (10, 0)]), + ] + ).offset_curve(1.0) + self.check_sgpd_equals_gpd(df_result, expected) def test_interiors(self): pass @@ -2599,6 +2633,54 @@ def test_snap(self): df_result = s.to_geoframe().snap(s2, tolerance=1, align=False) self.check_sgpd_equals_gpd(df_result, expected) + def test_shortest_line(self): + s1 = GeoSeries( + [ + Point(0, 0), + LineString([(0, 0), (1, 0)]), + Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]), + ] + ) + s2 = GeoSeries( + [ + Point(1, 1), + Point(0, 1), + Point(2, 2), + ] + ) + + result = s1.shortest_line(s2, align=False) + expected = gpd.GeoSeries( + [ + LineString([(0, 0), (1, 1)]), + LineString([(0, 0), (0, 1)]), + LineString([(1, 1), (2, 2)]), + ] + ) + self.check_sgpd_equals_gpd(result, expected) + + # Test with single geometry + result = s1.shortest_line(Point(1, 1)) + expected = gpd.GeoSeries( + [ + LineString([(0, 0), (1, 1)]), + LineString([(1, 0), (1, 1)]), + LineString([(1, 1), (1, 1)]), + ] + ) + self.check_sgpd_equals_gpd(result, expected) + + # Test that GeoDataFrame works too + df_result = s1.to_geoframe().shortest_line(s2, align=False) + expected = gpd.GeoSeries( + [ + LineString([(0, 0), (1, 1)]), + LineString([(0, 0), (0, 1)]), + LineString([(1, 1), (2, 2)]), + ] + ) + self.check_sgpd_equals_gpd(df_result, expected) + def test_intersection_all(self): s = GeoSeries([box(0, 0, 2, 2), box(1, 1, 3, 3)]) result = s.intersection_all() diff --git a/python/tests/geopandas/test_match_geopandas_series.py b/python/tests/geopandas/test_match_geopandas_series.py index 054d2de0d7..74f81f1ec0 100644 --- a/python/tests/geopandas/test_match_geopandas_series.py +++ b/python/tests/geopandas/test_match_geopandas_series.py @@ -832,7 +832,21 @@ def test_extract_unique_points(self): self.check_sgpd_equals_gpd(sgpd_result, gpd_result) def test_offset_curve(self): - pass + for geom in self.geoms: + # offset_curve only works on linear geometries + if not all( + isinstance(g, (LineString, LinearRing, MultiLineString)) + for g in geom + if not g.is_empty + ): + continue + sgpd_result = GeoSeries(geom).offset_curve(1.0) + gpd_result = gpd.GeoSeries(geom).offset_curve(1.0) + self.check_sgpd_equals_gpd(sgpd_result, gpd_result) + + sgpd_result = GeoSeries(geom).offset_curve(-0.5) + gpd_result = gpd.GeoSeries(geom).offset_curve(-0.5) + self.check_sgpd_equals_gpd(sgpd_result, gpd_result) def test_interiors(self): pass @@ -1188,6 +1202,23 @@ def test_snap(self): ) self.check_sgpd_equals_gpd(sgpd_result, gpd_result) + def test_shortest_line(self): + for geom, geom2 in self.pairs: + if self.contains_any_geom_collection(geom, geom2): + continue + sgpd_result = GeoSeries(geom).shortest_line(GeoSeries(geom2)) + gpd_result = gpd.GeoSeries(geom).shortest_line(gpd.GeoSeries(geom2)) + self.check_sgpd_equals_gpd(sgpd_result, gpd_result) + + if len(geom) == len(geom2): + sgpd_result = GeoSeries(geom).shortest_line( + GeoSeries(geom2), align=False + ) + gpd_result = gpd.GeoSeries(geom).shortest_line( + gpd.GeoSeries(geom2), align=False + ) + self.check_sgpd_equals_gpd(sgpd_result, gpd_result) + def test_intersection_all(self): pass