Repository: geopandas/geopandas

Issue:
Remove usage of buffer(0) as trick to make geometries valid
In our overlay code we have some places where we ensure geometries are valid. In the first place the input geometries, but for difference and intersection also the resulting geometries.

Historically we have been doing that with the `buffer(0)` trick, but in https://github.com/geopandas/geopandas/pull/2939 we already replaced one such example with `make_valid()` instead, which is faster and more robust, and just meant for making geometries valid, instead of being a trick.

But we have some remaining cases:

Making the input valid:

https://github.com/geopandas/geopandas/blob/321df2b588a41e8e87f740e828b65c2a87cd9542/geopandas/tools/overlay.py#L293-L307

The intersection result:

https://github.com/geopandas/geopandas/blob/321df2b588a41e8e87f740e828b65c2a87cd9542/geopandas/tools/overlay.py#L37-L39



Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0f30f398..a0a14713 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,11 +12,14 @@ New methods:
 - Added `count_coordinates` method from shapely to GeoSeries/GeoDataframe (#3026).
 - Added `minimum_clearance` method from shapely to GeoSeries/GeoDataframe (#2989).
 - Added `is_ccw` method from shapely to GeoSeries/GeoDataframe (#3027).
+- Added ``transform`` method from shapely to GeoSeries/GeoDataFrame (#3075).
 
 New features and improvements:
 
 - GeoSeries and GeoDataFrame `__repr__` now trims trailing zeros for a more readable
   output (#3087).
+- `make_valid` option in `overlay` now uses the `make_valid` method instead of
+  `buffer(0)` (#3113).
 
 Potentially breaking changes:
 - reading a data source that does not have a geometry field using ``read_file``
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index 8f843c91..07a451d0 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -108,6 +108,7 @@ Constructive methods and attributes
    GeoSeries.segmentize
    GeoSeries.shortest_line
    GeoSeries.simplify
+   GeoSeries.transform
 
 Affine transformations
 ----------------------
diff --git a/geopandas/array.py b/geopandas/array.py
index 5fd97462..b9052514 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -1,26 +1,25 @@
+import inspect
 import numbers
 import operator
 import warnings
-import inspect
 from functools import lru_cache
 
 import numpy as np
 import pandas as pd
-from pandas.api.extensions import (
-    ExtensionArray,
-    ExtensionDtype,
-    register_extension_dtype,
-)
-
 import shapely
 import shapely.affinity
 import shapely.geometry
-from shapely.geometry.base import BaseGeometry
 import shapely.ops
 import shapely.wkt
+from pandas.api.extensions import (
+    ExtensionArray,
+    ExtensionDtype,
+    register_extension_dtype,
+)
 from pyproj import CRS, Transformer
 from pyproj.aoi import AreaOfInterest
 from pyproj.database import query_utm_crs_info
+from shapely.geometry.base import BaseGeometry
 
 from .sindex import SpatialIndex
 
@@ -638,6 +637,11 @@ class GeometryArray(ExtensionArray):
             crs=self.crs,
         )
 
+    def transform(self, transformation, include_z=False):
+        return GeometryArray(
+            shapely.transform(self._data, transformation, include_z), crs=self.crs
+        )
+
     #
     # Binary predicates
     #
diff --git a/geopandas/base.py b/geopandas/base.py
index b80f9e6e..a2de795f 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -1,11 +1,11 @@
-from warnings import warn
 import warnings
+from warnings import warn
 
 import numpy as np
 import pandas as pd
-from pandas import DataFrame, Series
 import shapely
-from shapely.geometry import box, MultiPoint
+from pandas import DataFrame, Series
+from shapely.geometry import MultiPoint, box
 from shapely.geometry.base import BaseGeometry
 
 from . import _compat as compat
@@ -1298,6 +1298,46 @@ GeometryCollection
         """
         return _delegate_geo_method("segmentize", self, max_segment_length)
 
+    def transform(self, transformation, include_z=False):
+        """Returns a ``GeoSeries`` with the transformation function
+        applied to the geometry coordinates.
+
+        Parameters
+        ----------
+        transformation : Callable
+            A function that transforms a (N, 2) or (N, 3) ndarray of float64
+            to another (N,2) or (N, 3) ndarray of float64
+        include_z : bool, default False
+            If True include the third dimension in the coordinates array that
+            is passed to the ``transformation`` function. If a geometry has no third
+            dimension, the z-coordinates passed to the function will be NaN.
+
+        Returns
+        -------
+        GeoSeries
+
+        Examples
+        --------
+        >>> from shapely import Point, Polygon
+        >>> s = geopandas.GeoSeries([Point(0, 0)])
+        >>> s.transform(lambda x: x + 1)
+        0    POINT (1 1)
+        dtype: geometry
+
+        >>> s = geopandas.GeoSeries([Polygon([(0, 0), (1, 1), (0, 1)])])
+        >>> s.transform(lambda x: x * [2, 3])
+        0    POLYGON ((0 0, 2 3, 0 3, 0 0))
+        dtype: geometry
+
+        By default the third dimension is ignored and you need explicitly include it:
+
+        >>> s = geopandas.GeoSeries([Point(0, 0, 0)])
+        >>> s.transform(lambda x: x + 1, include_z=True)
+        0    POINT Z (1 1 1)
+        dtype: geometry
+        """
+        return _delegate_geo_method("transform", self, transformation, include_z)
+
     #
     # Reduction operations that return a Shapely geometry
     #
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index ddee1f8e..8f161b46 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -2327,7 +2327,7 @@ chicago_w_groceries[chicago_w_groceries["community"] == "UPTOWN"]
             geometries.
         make_valid : bool, default True
             If True, any invalid input geometries are corrected with a call to
-            `buffer(0)`, if False, a `ValueError` is raised if any input geometries
+            make_valid(), if False, a `ValueError` is raised if any input geometries
             are invalid.
 
         Returns
diff --git a/geopandas/tools/overlay.py b/geopandas/tools/overlay.py
index bdc8e3c9..6cb10b5b 100644
--- a/geopandas/tools/overlay.py
+++ b/geopandas/tools/overlay.py
@@ -36,7 +36,7 @@ def _overlay_intersection(df1, df2):
         right.reset_index(drop=True, inplace=True)
         intersections = left.intersection(right)
         poly_ix = intersections.geom_type.isin(["Polygon", "MultiPolygon"])
-        intersections.loc[poly_ix] = intersections[poly_ix].buffer(0)
+        intersections.loc[poly_ix] = intersections[poly_ix].make_valid()
 
         # only keep actual intersecting geometries
         pairs_intersect = pd.DataFrame({"__idx1": idx1, "__idx2": idx2})
@@ -166,7 +166,7 @@ def overlay(df1, df2, how="intersection", keep_geom_type=None, make_valid=True):
         which will set keep_geom_type to True but warn upon dropping
         geometries.
     make_valid : bool, default True
-        If True, any invalid input geometries are corrected with a call to `buffer(0)`,
+        If True, any invalid input geometries are corrected with a call to make_valid(),
         if False, a `ValueError` is raised if any input geometries are invalid.
 
     Returns
@@ -296,7 +296,7 @@ def overlay(df1, df2, how="intersection", keep_geom_type=None, make_valid=True):
             mask = ~df.geometry.is_valid
             col = df._geometry_column_name
             if make_valid:
-                df.loc[mask, col] = df.loc[mask, col].buffer(0)
+                df.loc[mask, col] = df.loc[mask, col].make_valid()
             elif mask.any():
                 raise ValueError(
                     "You have passed make_valid=False along with "


Test patch:
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index 5520e53e..26d440c0 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -2,31 +2,28 @@ import string
 import warnings
 
 import numpy as np
+import pytest
+import shapely
 from numpy.testing import assert_array_equal
 from pandas import DataFrame, Index, MultiIndex, Series, concat
-
-import shapely
-
+from pandas.testing import assert_frame_equal, assert_index_equal, assert_series_equal
+from shapely import wkt
 from shapely.geometry import (
     LinearRing,
     LineString,
     MultiLineString,
     MultiPoint,
+    MultiPolygon,
     Point,
     Polygon,
-    MultiPolygon,
 )
 from shapely.geometry.collection import GeometryCollection
 from shapely.ops import unary_union
-from shapely import wkt
 
 from geopandas import GeoDataFrame, GeoSeries
 from geopandas.base import GeoPandasBase
-
 from geopandas.testing import assert_geodataframe_equal
 from geopandas.tests.util import assert_geoseries_equal, geom_almost_equals, geom_equals
-from pandas.testing import assert_frame_equal, assert_index_equal, assert_series_equal
-import pytest
 
 
 def assert_array_dtype_equal(a, b, *args, **kwargs):
@@ -919,6 +916,44 @@ class TestGeomMethods:
         assert_geoseries_equal(expected_g1, result_g1)
         assert_geoseries_equal(expected_g5, result_g5)
 
+    def test_transform(self):
+        # Test 2D
+        test_2d = GeoSeries(
+            [LineString([(2, 2), (4, 4)]), Polygon([(0, 0), (1, 1), (0, 1)])]
+        )
+        expected_2d = GeoSeries(
+            [LineString([(4, 6), (8, 12)]), Polygon([(0, 0), (2, 3), (0, 3)])]
+        )
+        result_2d = test_2d.transform(lambda x: x * [2, 3])
+        assert_geoseries_equal(expected_2d, result_2d)
+        # Test 3D
+        test_3d = GeoSeries(
+            [
+                Point(0, 0, 0),
+                LineString([(2, 2, 2), (4, 4, 4)]),
+                Polygon([(0, 0, 0), (1, 1, 1), (0, 1, 0.5)]),
+            ]
+        )
+        expected_3d = GeoSeries(
+            [
+                Point(1, 1, 1),
+                LineString([(3, 3, 3), (5, 5, 5)]),
+                Polygon([(1, 1, 1), (2, 2, 2), (1, 2, 1.5)]),
+            ]
+        )
+        result_3d = test_3d.transform(lambda x: x + 1, include_z=True)
+        assert_geoseries_equal(expected_3d, result_3d)
+        # Test 3D as 2D transformation
+        expected_3d_to_2d = GeoSeries(
+            [
+                Point(1, 1),
+                LineString([(3, 3), (5, 5)]),
+                Polygon([(1, 1), (2, 2), (1, 2)]),
+            ]
+        )
+        result_3d_to_2d = test_3d.transform(lambda x: x + 1, include_z=False)
+        assert_geoseries_equal(expected_3d_to_2d, result_3d_to_2d)
+
     def test_concave_hull(self):
         assert_geoseries_equal(self.squares, self.squares.concave_hull())
 
diff --git a/geopandas/tests/test_overlay.py b/geopandas/tests/test_overlay.py
index 4c11834a..465d2e3a 100644
--- a/geopandas/tests/test_overlay.py
+++ b/geopandas/tests/test_overlay.py
@@ -3,6 +3,7 @@ import os
 import numpy as np
 import pandas as pd
 
+from shapely import make_valid
 from shapely.geometry import Point, Polygon, LineString, GeometryCollection, box
 
 import geopandas
@@ -690,11 +691,11 @@ def test_keep_geom_type_geometry_collection_difference():
     assert_geodataframe_equal(result1, expected1)
 
 
-@pytest.mark.parametrize("make_valid", [True, False])
-def test_overlap_make_valid(make_valid):
+@pytest.mark.parametrize("should_make_valid", [True, False])
+def test_overlap_make_valid(should_make_valid):
     bowtie = Polygon([(1, 1), (9, 9), (9, 1), (1, 9), (1, 1)])
     assert not bowtie.is_valid
-    fixed_bowtie = bowtie.buffer(0)
+    fixed_bowtie = make_valid(bowtie)
     assert fixed_bowtie.is_valid
 
     df1 = GeoDataFrame({"col1": ["region"], "geometry": GeoSeries([box(0, 0, 10, 10)])})
@@ -702,13 +703,13 @@ def test_overlap_make_valid(make_valid):
         {"col1": ["invalid", "valid"], "geometry": GeoSeries([bowtie, fixed_bowtie])}
     )
 
-    if make_valid:
-        df_overlay_bowtie = overlay(df1, df_bowtie, make_valid=make_valid)
+    if should_make_valid:
+        df_overlay_bowtie = overlay(df1, df_bowtie, make_valid=should_make_valid)
         assert df_overlay_bowtie.at[0, "geometry"].equals(fixed_bowtie)
         assert df_overlay_bowtie.at[1, "geometry"].equals(fixed_bowtie)
     else:
         with pytest.raises(ValueError, match="1 invalid input geometries"):
-            overlay(df1, df_bowtie, make_valid=make_valid)
+            overlay(df1, df_bowtie, make_valid=should_make_valid)
 
 
 def test_empty_overlay_return_non_duplicated_columns():