Repository: geopandas/geopandas

Issue:
ENH: Add fast rectangle clipping to `geopandas.clip()`
#### Is your feature request related to a problem?

Shapely provides `shapely.ops.clip_by_rect()`, which leverages the fast rectangle clipping implemented in GEOS. It would be nice if `geopandas.clip()` would switch to fast the fast clipping in case the `mask` argument of `geopandas.clip()` is an axis aligned bounding box (i.e. a rectangle).

#### Describe the solution you'd like

It would be nice to implicitly add this, i.e. add a check if the `mask` is a an axis aligned `Polygon` (or maybe also allow a `Multipolygon`?) that consists only of four Points which are axis aligned. And if this is the case, the faster clipping is used. Maybe there should also be an additional `force_slow_clipping: bool = False` parameter, as the doc of `shapely.ops.clip_by_rect()` states that it might produce invalid geometries.

#### API breaking implications

This would not break the API.

#### Describe alternatives you've considered

An alternative would be to implement `geopandas.clip_by_rect(gdf, xmin, ymin, xmax, ymax)` in a similar fashion to `geopandas.clip()`

If we could decide on an implementation I would be willing to contribute this in a PR.




Discussion:
Thanks for the suggestion! I think that may be a valuable addition. 

I think that the best way would be to allow a tuple `(xmin, ymin, xmax, ymax)` as a `mask` in `geopandas.clip`. Then if you pass a polygon it uses the standard intersection-based function while if you pass a tuple it uses `clip_by_rect` (we need to properly document that).

It should be relatively straightforward to do, you'll just need to add `clip_by_rect` GeoSeries method based on `shapely.ops.clip_by_rect()` and in the vectorized version using `pygeos.clip_by_rect` (see the [docs](https://pygeos.readthedocs.io/en/latest/constructive.html#pygeos.constructive.clip_by_rect)) and then use it instead of intersection after on the output of spatial index query.

I would not do the automatic check that the Polygon is a rectangle because `clip_by_rect` can result in invalid geometries more often than `intersection`.

Independent `geopandas.clip_by_rect` is also a possibility but I feel that the tuple solution outlined above is a good balance between predictability, usability and conciseness (trying to have a lean API). Esp. since it will also introduce `GeoSeries.clip_by_rect(xmin, ymin, xmax, ymax)` method.
I agree that integrating it into the existing `clip` sounds best.

> I would not do the automatic check that the Polygon is a rectangle because `clip_by_rect` can result in invalid geometries more often than `intersection`.

Yes, I wanted to mention this as well: it needs to be clear that using `clip_by_rect` (by passing a bbox tuple) has disadvantages. 

Separately, it might also be interesting to see if the current clip algorithm can be improved (eg currently we are calculating the intersection of all geometries intersecting with the box, also the ones that fall fully inside it (which might be the majority depending on your use case))

Resolving patch:
diff --git a/geopandas/_vectorized.py b/geopandas/_vectorized.py
--- a/geopandas/_vectorized.py
+++ b/geopandas/_vectorized.py
@@ -11,6 +11,7 @@
 
 import shapely.geometry
 import shapely.geos
+import shapely.ops
 import shapely.wkb
 import shapely.wkt
 
@@ -728,6 +729,20 @@ def almost_equals(self, other, decimal):
 #
 
 
+def clip_by_rect(data, xmin, ymin, xmax, ymax):
+    if compat.USE_PYGEOS:
+        return pygeos.clip_by_rect(data, xmin, ymin, xmax, ymax)
+    else:
+        clipped_geometries = np.empty(len(data), dtype=object)
+        clipped_geometries[:] = [
+            shapely.ops.clip_by_rect(s, xmin, ymin, xmax, ymax)
+            if s is not None
+            else None
+            for s in data
+        ]
+        return clipped_geometries
+
+
 def difference(data, other):
     if compat.USE_PYGEOS:
         return _binary_method("difference", data, other)
diff --git a/geopandas/array.py b/geopandas/array.py
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -564,6 +564,11 @@ def geom_almost_equals(self, other, decimal):
     # Binary operations that return new geometries
     #
 
+    def clip_by_rect(self, xmin, ymin, xmax, ymax):
+        return GeometryArray(
+            vectorized.clip_by_rect(self.data, xmin, ymin, xmax, ymax), crs=self.crs
+        )
+
     def difference(self, other):
         return GeometryArray(
             self._binary_method("difference", self, other), crs=self.crs
diff --git a/geopandas/base.py b/geopandas/base.py
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -2536,6 +2536,75 @@ def intersection(self, other, align=True):
         """
         return _binary_geo("intersection", self, other, align)
 
+    def clip_by_rect(self, xmin, ymin, xmax, ymax):
+        """Returns a ``GeoSeries`` of the portions of geometry within the given
+        rectangle.
+
+        Note that the results are not exactly equal to
+        :meth:`~GeoSeries.intersection()`. E.g. in edge cases,
+        :meth:`~GeoSeries.clip_by_rect()` will not return a point just touching the
+        rectangle. Check the examples section below for some of these exceptions.
+
+        The geometry is clipped in a fast but possibly dirty way. The output is not
+        guaranteed to be valid. No exceptions will be raised for topological errors.
+
+        Note: empty geometries or geometries that do not overlap with the specified
+        bounds will result in ``GEOMETRYCOLLECTION EMPTY``.
+
+        Parameters
+        ----------
+        xmin: float
+            Minimum x value of the rectangle
+        ymin: float
+            Minimum y value of the rectangle
+        xmax: float
+            Maximum x value of the rectangle
+        ymax: float
+            Maximum y value of the rectangle
+
+        Returns
+        -------
+        GeoSeries
+
+        Examples
+        --------
+        >>> from shapely.geometry import Polygon, LineString, Point
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         Polygon([(0, 0), (2, 2), (0, 2)]),
+        ...         Polygon([(0, 0), (2, 2), (0, 2)]),
+        ...         LineString([(0, 0), (2, 2)]),
+        ...         LineString([(2, 0), (0, 2)]),
+        ...         Point(0, 1),
+        ...     ],
+        ...     crs=3857,
+        ... )
+        >>> bounds = (0, 0, 1, 1)
+        >>> s
+        0    POLYGON ((0.000 0.000, 2.000 2.000, 0.000 2.00...
+        1    POLYGON ((0.000 0.000, 2.000 2.000, 0.000 2.00...
+        2                LINESTRING (0.000 0.000, 2.000 2.000)
+        3                LINESTRING (2.000 0.000, 0.000 2.000)
+        4                                  POINT (0.000 1.000)
+        dtype: geometry
+        >>> s.clip_by_rect(*bounds)
+        0    POLYGON ((0.000 0.000, 0.000 1.000, 1.000 1.00...
+        1    POLYGON ((0.000 0.000, 0.000 1.000, 1.000 1.00...
+        2                LINESTRING (0.000 0.000, 1.000 1.000)
+        3                             GEOMETRYCOLLECTION EMPTY
+        4                             GEOMETRYCOLLECTION EMPTY
+        dtype: geometry
+
+        See also
+        --------
+        GeoSeries.intersection
+        """
+        from .geoseries import GeoSeries
+
+        geometry_array = GeometryArray(self.geometry.values)
+        clipped_geometry = geometry_array.clip_by_rect(xmin, ymin, xmax, ymax)
+        return GeoSeries(clipped_geometry.data, index=self.index, crs=self.crs)
+
     #
     # Other operations
     #


Test patch:
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -85,8 +85,12 @@ def setup_method(self):
         self.g8 = GeoSeries([self.t1, self.t5])
         self.empty = GeoSeries([])
         self.all_none = GeoSeries([None, None])
+        self.all_geometry_collection_empty = GeoSeries(
+            [GeometryCollection([]), GeometryCollection([])]
+        )
         self.empty_poly = Polygon()
         self.g9 = GeoSeries(self.g0, index=range(1, 8))
+        self.g10 = GeoSeries([self.t1, self.t4])
 
         # Crossed lines
         self.l3 = LineString([(0, 0), (1, 1)])
@@ -254,6 +258,15 @@ def test_intersection(self):
             assert len(self.g0.intersection(self.g9, align=True) == 8)
         assert len(self.g0.intersection(self.g9, align=False) == 7)
 
+    def test_clip_by_rect(self):
+        self._test_binary_topological(
+            "clip_by_rect", self.g1, self.g10, *self.sq.bounds
+        )
+        # self.g1 and self.t3.bounds do not intersect
+        self._test_binary_topological(
+            "clip_by_rect", self.all_geometry_collection_empty, self.g1, *self.t3.bounds
+        )
+
     def test_union_series(self):
         self._test_binary_topological("union", self.sq, self.g1, self.g2)