Repository: geopandas/geopandas
Pull request #2902 (2023-05-25 15:27:06)

ENH: add offset_curve as GeoSeries method
Implements the shapely offset_curve method as part of #2010

JamesGardiner: @martinfleis do you want to explicitly add shapely parameters to the Geopandas methods, or pass them through as kwargs and point users to the shapely docs?
martinfleis: Better ton be explicit. We risk getting out of sync but it is easier for a user to see the whole signature rather that fishing in shapely docs which kwargs are allowed.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f1f8492..e301b30a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,8 +2,11 @@
 
 ## Development version
 
-New features and improvements:
+New methods:
 
+- Added ``offset_curve`` method from shapely to GeoSeries/GeoDataframe.
+
+New features and improvements:
 
 Bug fixes:
 
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index bd05245b..e661a330 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -22,6 +22,7 @@ General methods and attributes
    GeoSeries.total_bounds
    GeoSeries.length
    GeoSeries.geom_type
+   GeoSeries.offset_curve
    GeoSeries.distance
    GeoSeries.representative_point
    GeoSeries.exterior
diff --git a/doc/source/docs/user_guide/geometric_manipulations.rst b/doc/source/docs/user_guide/geometric_manipulations.rst
index 61d92b8a..cf9861b2 100644
--- a/doc/source/docs/user_guide/geometric_manipulations.rst
+++ b/doc/source/docs/user_guide/geometric_manipulations.rst
@@ -37,6 +37,11 @@ Constructive methods
   smallest rectangular polygon (with sides parallel to the coordinate
   axes) that contains each object.
 
+.. method:: GeoSeries.offset_curve(distance, quad_segs=8, join_style="round", mitre_limit=5.0)
+
+  Returns a :class:`~geopandas.GeoSeries` containing a `Linestring` or `MultiLineString`
+  geometry at a distance from the object on its right or its left side.
+
 .. method:: GeoSeries.simplify(tolerance, preserve_topology=True)
 
   Returns a :class:`~geopandas.GeoSeries` containing a simplified representation of
diff --git a/geopandas/_vectorized.py b/geopandas/_vectorized.py
index 97534d52..7642b826 100644
--- a/geopandas/_vectorized.py
+++ b/geopandas/_vectorized.py
@@ -638,6 +638,24 @@ def exterior(data):
         return _unary_geo("exterior", data)
 
 
+def offset_curve(data, distance, quad_segs=8, join_style="round", mitre_limit=5.0):
+    if compat.USE_SHAPELY_20:
+        return shapely.offset_curve(
+            data,
+            distance=distance,
+            quad_segs=quad_segs,
+            join_style=join_style,
+            mitre_limit=mitre_limit,
+        )
+    elif compat.USE_PYGEOS:
+        return pygeos.offset_curve(data, distance, quad_segs, join_style, mitre_limit)
+    else:
+        raise NotImplementedError(
+            f"shapely >= 2.0 or PyGEOS is required, "
+            f"version {shapely.__version__} is installed"
+        )
+
+
 def interiors(data):
     data = to_shapely(data)
     has_non_poly = False
diff --git a/geopandas/array.py b/geopandas/array.py
index 40e94e4b..bd26c11c 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -525,6 +525,18 @@ class GeometryArray(ExtensionArray):
     def exterior(self):
         return GeometryArray(vectorized.exterior(self._data), crs=self.crs)
 
+    def offset_curve(self, distance, quad_segs=8, join_style="round", mitre_limit=5.0):
+        return GeometryArray(
+            vectorized.offset_curve(
+                self._data,
+                distance,
+                quad_segs=quad_segs,
+                join_style=join_style,
+                mitre_limit=mitre_limit,
+            ),
+            crs=self.crs,
+        )
+
     @property
     def interiors(self):
         # no GeometryArray as result
diff --git a/geopandas/base.py b/geopandas/base.py
index e05ed55a..e8733d76 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -618,6 +618,57 @@ GeometryCollection
         # TODO: return empty geometry for non-polygons
         return _delegate_property("exterior", self)
 
+    def offset_curve(self, distance, quad_segs=8, join_style="round", mitre_limit=5.0):
+        """Returns a ``LineString`` or ``MultiLineString`` geometry at a
+        distance from the object on its right or its left side.
+
+        Parameters
+        ----------
+        distance : float | array-like
+            Specifies the offset distance from the input geometry. Negative
+            for right side offset, positive for left side offset.
+        quad_segs : int (optional, default 8)
+            Specifies the number of linear segments in a quarter circle in the
+            approximation of circular arcs.
+        join_style : {'round', 'bevel', 'mitre'}, (optional, default 'round')
+            Specifies the shape of outside corners. 'round' results in
+            rounded shapes. 'bevel' results in a beveled edge that touches the
+            original vertex. 'mitre' results in a single vertex that is beveled
+            depending on the ``mitre_limit`` parameter.
+        mitre_limit : float (optional, default 5.0)
+            Crops of 'mitre'-style joins if the point is displaced from the
+            buffered vertex by more than this limit.
+
+        See http://shapely.readthedocs.io/en/latest/manual.html#object.offset_curve
+        for details.
+
+        Examples
+        --------
+
+        >>> from shapely.geometry import LineString
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         LineString([(0, 0), (0, 1), (1, 1)]),
+        ...     ],
+        ...     crs=3857
+        ... )
+        >>> s
+        0    LINESTRING (0.000 0.000, 0.000 1.000, 1.000 1....
+        dtype: geometry
+
+        >>> s.offset_curve(1)
+        0    LINESTRING (-1.000 0.000, -1.000 1.000, -0.981...
+        dtype: geometry
+        """
+        return _delegate_geo_method(
+            "offset_curve",
+            self,
+            distance,
+            quad_segs=quad_segs,
+            join_style=join_style,
+            mitre_limit=mitre_limit,
+        )
+
     @property
     def interiors(self):
         """Returns a ``Series`` of List representing the


Test patch:
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index 34412706..009cdbc8 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -976,7 +976,7 @@ class TestDataFrame:
     @pytest.mark.parametrize("how", ["left", "inner", "right"])
     @pytest.mark.parametrize("predicate", ["intersects", "within", "contains"])
     @pytest.mark.skipif(
-        not (compat.USE_PYGEOS and compat.HAS_RTREE and compat.USE_SHAPELY_20),
+        not (compat.USE_PYGEOS or compat.USE_SHAPELY_20 or compat.HAS_RTREE),
         reason="sjoin needs `rtree` or `pygeos` dependency",
     )
     def test_sjoin(self, how, predicate):
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index 5665eae2..f17879db 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -1579,3 +1579,13 @@ class TestGeomMethods:
 
         with pytest.raises(AttributeError, match="pointpats.random module has no"):
             gs.sample_points(10, method="nonexistent")
+
+    @pytest.mark.skipif(
+        not (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
+        reason="offset_curve is only implemented for pygeos, not shapely",
+    )
+    def test_offset_curve(self):
+        oc = GeoSeries([self.l1]).offset_curve(1, join_style="mitre")
+        expected = GeoSeries([LineString([[-1, 0], [-1, 2], [1, 2]])])
+        assert_geoseries_equal(expected, oc)
+        assert isinstance(oc, GeoSeries)