Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 89 additions & 2 deletions python/sedona/spark/geopandas/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.

Expand Down
21 changes: 19 additions & 2 deletions python/sedona/spark/geopandas/geoseries.py
Original file line number Diff line number Diff line change
Expand Up @@ -1058,8 +1058,13 @@ 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
empty_line = stc.ST_GeomFromText(F.lit("LINESTRING EMPTY"))
spark_col = F.when(
stf.ST_IsEmpty(self.spark.column),
empty_line,
).otherwise(stf.ST_OffsetCurve(self.spark.column, distance, quad_segs))
Comment on lines +1061 to +1066
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

offset_curve replaces empty input geometries with ST_GeomFromText('LINESTRING EMPTY'), which will have SRID=0. If the input GeoSeries has CRS set (via SRID), this can silently drop CRS for results (e.g., if the first non-null row is empty, GeoSeries.crs will become None). Consider constructing the empty geometry with the input SRID (e.g., pass ST_SRID(self.spark.column) as the srid arg to ST_GeomFromText, or ST_SetSRID on the empty geometry) so CRS/SRID is preserved even for empty rows.

Copilot uses AI. Check for mistakes.
return self._query_geometry_column(spark_col, returns_geom=True)

@property
def interiors(self):
Expand Down Expand Up @@ -1528,6 +1533,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(
Expand Down
84 changes: 83 additions & 1 deletion python/tests/geopandas/test_geoseries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
33 changes: 32 additions & 1 deletion python/tests/geopandas/test_match_geopandas_series.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading