Repository: Toblerity/Shapely
Pull request #806 (2019-12-10 09:18:13)

Implement GEOSBufferParams to support single sided buffers.
Attempting to add support for single sided buffers raised in this issue: https://github.com/Toblerity/Shapely/issues/727




Patch:
diff --git a/.travis.yml b/.travis.yml
index bd6056b..9f071fe 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,72 +1,114 @@
 language: python
-cache: pip
 dist: xenial
 
+cache:
+  directories:
+    - $HOME/geosinstall
+    - ~/.cache/pip
+
 matrix:
   include:
     - python: "2.7"
       env:
-        - SPEEDUPS=1
-        - NUMPY=1
+        GEOSVERSION="3.4.3"
+        SPEEDUPS=1
+        NUMPY=1
     - python: "2.7"
       env:
-        - SPEEDUPS=0
-        - NUMPY=1
+        GEOSVERSION="3.4.3"
+        SPEEDUPS=0
+        NUMPY=1
     - python: "2.7"
       env:
-        - SPEEDUPS=0
-        - NUMPY=0
+        GEOSVERSION="3.4.3"
+        SPEEDUPS=0
+        NUMPY=0
+    - python: "3.5"
+      env:
+        GEOSVERSION="3.5.2"
+        SPEEDUPS=1
+        NUMPY=1
     - python: "3.5"
       env:
-        - SPEEDUPS=1
-        - NUMPY=1
+        GEOSVERSION="3.5.2"
+        SPEEDUPS=0
+        NUMPY=1
     - python: "3.5"
       env:
-        - SPEEDUPS=0
-        - NUMPY=1
+        GEOSVERSION="3.5.2"
+        SPEEDUPS=0
+        NUMPY=0
     - python: "3.6"
       env:
-        - SPEEDUPS=1
-        - NUMPY=1
+        GEOSVERSION="3.6.4"
+        SPEEDUPS=1
+        NUMPY=1
     - python: "3.6"
       env:
-        - SPEEDUPS=0
-        - NUMPY=1
+        GEOSVERSION="3.6.4"
+        SPEEDUPS=0
+        NUMPY=1
     - python: "3.6"
       env:
-        - SPEEDUPS=0
-        - NUMPY=0
+        GEOSVERSION="3.6.4"
+        SPEEDUPS=0
+        NUMPY=0
     - python: "3.7"
       env:
-        - SPEEDUPS=1
-        - NUMPY=1
+        GEOSVERSION="3.7.3"
+        SPEEDUPS=1
+        NUMPY=1
     - python: "3.7"
       env:
-        - SPEEDUPS=0
-        - NUMPY=1
+        GEOSVERSION="3.7.3"
+        SPEEDUPS=0
+        NUMPY=1
     - python: "3.7"
       env:
-        - SPEEDUPS=0
-        - NUMPY=0
-
-addons:
-  apt:
-    packages:
-      - libgeos-dev
+        GEOSVERSION="3.7.3"
+        SPEEDUPS=0
+        NUMPY=0
+    - python: "3.8"
+      env:
+        GEOSVERSION="3.8.0"
+        SPEEDUPS=1
+        NUMPY=1
+    - python: "3.8"
+      env:
+        GEOSVERSION="3.8.0"
+        SPEEDUPS=0
+        NUMPY=1
+    - python: "3.8"
+      env:
+        GEOSVERSION="3.8.0"
+        SPEEDUPS=0
+        NUMPY=0
+    - python: "3.8-dev"
+      env:
+        GEOSVERSION="master"
+        SPEEDUPS=1
+        NUMPY=1
+  allow_failures:
+    - python: "3.8-dev"
+      env:
+        GEOSVERSION="master"
+        SPEEDUPS=1
+        NUMPY=1
 
 before_install:
-  - python -m pip install -U pip
-  - python -m pip --version
+  - ./ci/travis/install_geos.sh
+  - pip install --upgrade pip
   # if building with speedups install cython
   - if [ "$SPEEDUPS" == "1" ]; then pip install --install-option="--no-cython-compile" cython; fi
   # if testing without numpy explicitly remove it
   - if [ "$NUMPY" == "0" ]; then pip uninstall --yes numpy; fi
   # convert SPEEDUPS to --with-speedups/--without-speedups
   - if [ "$SPEEDUPS" == "1" ]; then SPEEDUPS_FLAG=--with-speedups; else SPEEDUPS_FLAG=--without-speedups; fi
+  - pip install --upgrade coveralls pytest-cov pytest>=3.8
 
 install:
-  - python -m pip install --upgrade coveralls pytest-cov pytest>=3.8
-  - python -m pip install -e .[all]
+  - export GEOS_CONFIG=$HOME/geosinstall/geos-$GEOSVERSION/bin/geos-config
+  - pip install -v -e .[all]
 
 script:
   - python -m pytest --cov shapely --cov-report term-missing "${SPEEDUPS_FLAG}"
diff --git a/appveyor.yml b/appveyor.yml
index a9b81ff..1afd72f 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -10,7 +10,7 @@ environment:
         # SDK v7.0 MSVC Express 2008's SetEnv.cmd script will fail if the
         # /E:ON and /V:ON options are not enabled in the batch script interpreter
         # See: http://stackoverflow.com/a/13751649/163740
-        CMD_IN_ENV: "cmd /E:ON /V:ON /C .\\appveyor\\run_with_env.cmd"
+        CMD_IN_ENV: "cmd /E:ON /V:ON /C .\\ci\\appveyor\\run_with_env.cmd"
         GDAL_HOME: "C:\\gdal"
 
     matrix:
@@ -38,7 +38,7 @@ install:
 
   # Install Python (from the official .msi of http://python.org) and pip when
   # not already installed.
-  # - ps: if (-not(Test-Path($env:PYTHON))) { & appveyor\install.ps1 }
+  # - ps: if (-not(Test-Path($env:PYTHON))) { & ci\appveyor\install.ps1 }
 
   # Prepend newly installed Python to the PATH of this build (this cannot be
   # done from inside the powershell script as it would require to restart
diff --git a/appveyor/install.ps1 b/ci/appveyor/install.ps1
similarity index 100%
rename from appveyor/install.ps1
rename to ci/appveyor/install.ps1
diff --git a/appveyor/run_with_env.cmd b/ci/appveyor/run_with_env.cmd
similarity index 100%
rename from appveyor/run_with_env.cmd
rename to ci/appveyor/run_with_env.cmd
diff --git a/ci/travis/install_geos.sh b/ci/travis/install_geos.sh
new file mode 100755
index 0000000..852224a
--- /dev/null
+++ b/ci/travis/install_geos.sh
@@ -0,0 +1,57 @@
+#!/bin/bash
+# based on contribution by @rbuffat to Toblerity/Fiona
+set -e
+
+# Cache install directory from .travis.yml
+CACHEGEOSINST=$HOME/geosinstall
+
+# Create directories, if they don't exit
+GEOSINSTVERSION=$CACHEGEOSINST/geos-$GEOSVERSION
+mkdir -p $GEOSINSTVERSION
+
+function build_geos {
+    echo "Building geos-$GEOSVERSION"
+    mkdir build
+    cd build
+    # disable building tests to speed up compilation
+    if [ "$GEOSVERSION" = "master" ] || dpkg --compare-versions "$GEOSVERSION" "gt" "3.8.0"; then
+        BUILD_TESTING=BUILD_TESTING
+    else
+        # cmake variable used for older GEOS releases
+        BUILD_TESTING=GEOS_ENABLE_TESTS
+        # note that this intentially also catches GEOS "3.8.0"
+        # see https://trac.osgeo.org/geos/ticket/1006
+    fi
+    cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=$GEOSINSTVERSION -D$BUILD_TESTING=OFF ..
+    make -j 2
+    make install
+}
+
+if [ "$GEOSVERSION" = "master" ]; then
+    # use GitHub mirror
+    git clone --depth 1 https://github.com/libgeos/geos.git geos-$GEOSVERSION
+    cd geos-$GEOSVERSION
+    git rev-parse HEAD > newrev.txt
+    BUILD=no
+    # Only build if nothing cached or if the GEOS revision changed
+    if test ! -f $GEOSINSTVERSION/rev.txt; then
+        BUILD=yes
+    elif ! diff newrev.txt $GEOSINSTVERSION/rev.txt >/dev/null; then
+        BUILD=yes
+    fi
+    if test "$BUILD" = "no"; then
+        echo "Using cached install $GEOSINSTVERSION"
+    else
+        cp newrev.txt $GEOSINSTVERSION/rev.txt
+        build_geos
+    fi
+else
+    if [ -d "$GEOSINSTVERSION/include/geos" ]; then
+        echo "Using cached install $GEOSINSTVERSION"
+    else
+        wget -q http://download.osgeo.org/geos/geos-$GEOSVERSION.tar.bz2
+        tar xfj geos-$GEOSVERSION.tar.bz2
+        cd geos-$GEOSVERSION
+        build_geos
+    fi
+fi
diff --git a/setup.py b/setup.py
index 1a97de2..702cc2b 100755
--- a/setup.py
+++ b/setup.py
@@ -220,10 +220,10 @@ setup_args = dict(
         'Programming Language :: Python :: 2',
         'Programming Language :: Python :: 2.7',
         'Programming Language :: Python :: 3',
-        'Programming Language :: Python :: 3.4',
         'Programming Language :: Python :: 3.5',
         'Programming Language :: Python :: 3.6',
         'Programming Language :: Python :: 3.7',
+        'Programming Language :: Python :: 3.8',
         'Topic :: Scientific/Engineering :: GIS',
     ],
     cmdclass           = {},
diff --git a/shapely/ctypes_declarations.py b/shapely/ctypes_declarations.py
index 6d3b0e1..7ecfb3a 100644
--- a/shapely/ctypes_declarations.py
+++ b/shapely/ctypes_declarations.py
@@ -135,6 +135,28 @@ def prototype(lgeos, geos_version):
             lgeos.GEOSOffsetCurve.argtypes = [
                 c_void_p, c_double, c_int, c_int, c_double]
 
+            lgeos.GEOSBufferParams_create.restype = c_void_p
+            lgeos.GEOSBufferParams_create.argtypes = None
+
+            lgeos.GEOSBufferParams_setEndCapStyle.restype = c_int
+            lgeos.GEOSBufferParams_setEndCapStyle.argtypes = [c_void_p, c_int]
+
+            lgeos.GEOSBufferParams_setJoinStyle.restype = c_int
+            lgeos.GEOSBufferParams_setJoinStyle.argtypes = [c_void_p, c_int]
+
+            lgeos.GEOSBufferParams_setMitreLimit.restype = c_int
+            lgeos.GEOSBufferParams_setMitreLimit.argtypes = [c_void_p, c_double]
+
+            lgeos.GEOSBufferParams_setQuadrantSegments.restype = c_int
+            lgeos.GEOSBufferParams_setQuadrantSegments.argtypes = [c_void_p, c_int]
+
+            lgeos.GEOSBufferParams_setSingleSided.restype = c_int
+            lgeos.GEOSBufferParams_setSingleSided.argtypes = [c_void_p, c_int]
+
+            lgeos.GEOSBufferWithParams.restype = c_void_p
+            lgeos.GEOSBufferWithParams.argtypes = [
+                c_void_p, c_void_p, c_double]
+
         else:
 
             # deprecated in GEOS 3.3.0 in favour of GEOSOffsetCurve
@@ -509,7 +531,7 @@ def prototype(lgeos, geos_version):
 
         lgeos.GEOSSTRtree_destroy.argtypes = [c_void_p]
         lgeos.GEOSSTRtree_destroy.restype = None
-    
+
     if geos_version >= (3, 6, 0):
         lgeos.GEOSDistanceCallback = CFUNCTYPE(c_int, c_void_p, c_void_p, c_void_p, c_void_p)
 
diff --git a/shapely/geometry/base.py b/shapely/geometry/base.py
index 04bb78a..c479532 100644
--- a/shapely/geometry/base.py
+++ b/shapely/geometry/base.py
@@ -531,29 +531,42 @@ class BaseGeometry(object):
 
     def buffer(self, distance, resolution=16, quadsegs=None,
                cap_style=CAP_STYLE.round, join_style=JOIN_STYLE.round,
-               mitre_limit=5.0):
+               mitre_limit=5.0, single_sided=False):
         """Returns a geometry with an envelope at a distance from the object's
         envelope
 
-        A negative distance has a "shrink" effect. A zero distance may be used
-        to "tidy" a polygon. The resolution of the buffer around each vertex of
-        the object increases by increasing the resolution keyword parameter
-        or second positional parameter. Note: the use of a `quadsegs` parameter
-        is deprecated and will be gone from the next major release.
-
-        The styles of caps are: CAP_STYLE.round (1), CAP_STYLE.flat (2), and
-        CAP_STYLE.square (3).
-
-        The styles of joins between offset segments are: JOIN_STYLE.round (1),
-        JOIN_STYLE.mitre (2), and JOIN_STYLE.bevel (3).
-
-        The mitre limit ratio is used for very sharp corners. The mitre ratio
-        is the ratio of the distance from the corner to the end of the mitred
-        offset corner. When two line segments meet at a sharp angle, a miter
-        join will extend the original geometry. To prevent unreasonable
-        geometry, the mitre limit allows controlling the maximum length of the
-        join corner. Corners with a ratio which exceed the limit will be
-        beveled.
+        Parameters
+        ==========
+        distance: float
+            The distance to buffer around the object. A negative distance has a "shrink" effect.
+            A zero distance may be used to "tidy" a polygon.
+        resolution: int, optional
+            The resolution of the buffer around each vertex of the object.
+        quadsegs: int, optional
+            Sets the number of line segments used to approximate an angle fillet.
+            Note: the use of a `quadsegs` parameter is deprecated and will be gone from
+            the next major release.
+        cap_style: int, optional
+            The styles of caps are: CAP_STYLE.round (1), CAP_STYLE.flat (2), and
+            CAP_STYLE.square (3).
+        join_style: int, optional
+            The styles of joins between offset segments are: JOIN_STYLE.round (1),
+            JOIN_STYLE.mitre (2), and JOIN_STYLE.bevel (3).
+        mitre_limit: float, optional
+            The mitre limit ratio is used for very sharp corners. The mitre ratio
+            is the ratio of the distance from the corner to the end of the mitred
+            offset corner. When two line segments meet at a sharp angle, a miter
+            join will extend the original geometry. To prevent unreasonable
+            geometry, the mitre limit allows controlling the maximum length of the
+            join corner. Corners with a ratio which exceed the limit will be
+            beveled.
+        single_side: bool, optional
+            The side used is determined by the sign of the buffer distance:
+                a positive distance indicates the left-hand side
+                a negative distance indicates the right-hand side
+            The single-sided buffer of point geometries is the same as the regular buffer.
+            The End Cap Style for single-sided buffers is always ignored, and forced to the
+            equivalent of CAP_FLAT.
 
         Example:
 
@@ -580,6 +593,16 @@ class BaseGeometry(object):
         if mitre_limit == 0.0:
             raise ValueError(
                 'Cannot compute offset from zero-length line segment')
+
+        if 'buffer_with_params' in self.impl:
+            params = self._lgeos.GEOSBufferParams_create()
+            self._lgeos.GEOSBufferParams_setEndCapStyle(params, cap_style)
+            self._lgeos.GEOSBufferParams_setJoinStyle(params, join_style)
+            self._lgeos.GEOSBufferParams_setMitreLimit(params, mitre_limit)
+            self._lgeos.GEOSBufferParams_setQuadrantSegments(params, res)
+            self._lgeos.GEOSBufferParams_setSingleSided(params, single_sided)
+            return geom_factory(self.impl['buffer_with_params'](self, params, distance))
+
         if cap_style == CAP_STYLE.round and join_style == JOIN_STYLE.round:
             return geom_factory(self.impl['buffer'](self, distance, res))
 
@@ -700,7 +723,7 @@ class BaseGeometry(object):
 
     def equals(self, other):
         """Returns True if geometries are equal, else False
-        
+
         Refers to point-set equality (or topological equality), and is equivalent to
         (self.within(other) & self.contains(other))
         """
@@ -725,8 +748,8 @@ class BaseGeometry(object):
     def equals_exact(self, other, tolerance):
         """Returns True if geometries are equal to within a specified
         tolerance
-        
-        Refers to coordinate equality, which requires coordinates to be equal 
+
+        Refers to coordinate equality, which requires coordinates to be equal
         and in the same order for all components of a geometry
         """
         return bool(self.impl['equals_exact'](self, other, tolerance))
diff --git a/shapely/geos.py b/shapely/geos.py
index f4e6a4b..7cf372c 100644
--- a/shapely/geos.py
+++ b/shapely/geos.py
@@ -842,6 +842,7 @@ class LGEOS330(LGEOS320):
         self.methods['cascaded_union'] = self.methods['unary_union']
         self.methods['snap'] = self.GEOSSnap
         self.methods['shared_paths'] = self.GEOSSharedPaths
+        self.methods['buffer_with_params'] = self.GEOSBufferWithParams
 
 
 class LGEOS340(LGEOS330):
diff --git a/shapely/impl.py b/shapely/impl.py
index ac179ac..cc6550a 100644
--- a/shapely/impl.py
+++ b/shapely/impl.py
@@ -144,7 +144,9 @@ IMPL320 = {
     }
 
 IMPL330 = {
-    'is_closed': (UnaryPredicate, 'is_closed')}
+    'is_closed': (UnaryPredicate, 'is_closed'),
+    'buffer_with_params': (UnaryTopologicalOp, 'buffer_with_params'),
+}
 
 
 def impl_items(defs):


Test patch:
diff --git a/tests/test_buffer.py b/tests/test_buffer.py
new file mode 100644
index 0000000..454c3cf
--- /dev/null
+++ b/tests/test_buffer.py
@@ -0,0 +1,80 @@
+from . import unittest
+from shapely import geometry
+
+
+class BufferSingleSidedCase(unittest.TestCase):
+    """ Test Buffer Point/Line/Polygon with and without single_sided params """
+
+    def test_empty(self):
+        g = geometry.Point(0,0)
+        h = g.buffer(0)
+        assert h.is_empty
+
+    def test_point(self):
+        g = geometry.Point(0, 0)
+        h = g.buffer(1, resolution=1)
+        self.assertEqual(h.geom_type, 'Polygon')
+        expected_coord = [(1.0, 0.0), (0, -1.0), (-1.0, 0), (0, 1.0), (1.0, 0.0)]
+        for index, coord in enumerate(h.exterior.coords):
+            self.assertAlmostEqual(coord[0], expected_coord[index][0])
+            self.assertAlmostEqual(coord[1], expected_coord[index][1])
+
+    def test_point_single_sidedd(self):
+        g = geometry.Point(0, 0)
+        h = g.buffer(1, resolution=1, single_sided=True)
+        self.assertEqual(h.geom_type, 'Polygon')
+        expected_coord = [(1.0, 0.0), (0, -1.0), (-1.0, 0), (0, 1.0), (1.0, 0.0)]
+        for index, coord in enumerate(h.exterior.coords):
+            self.assertAlmostEqual(coord[0], expected_coord[index][0])
+            self.assertAlmostEqual(coord[1], expected_coord[index][1])
+
+    def test_line(self):
+        g = geometry.LineString([[0, 0], [0, 1]])
+        h = g.buffer(1, resolution=1)
+        self.assertEqual(h.geom_type, 'Polygon')
+        expected_coord = [(-1.0, 1.0), (0, 2.0), (1.0, 1.0), (1.0, 0.0), (0, -1.0), (-1.0, 0.0),
+                          (-1.0, 1.0)]
+        for index, coord in enumerate(h.exterior.coords):
+            self.assertAlmostEqual(coord[0], expected_coord[index][0])
+            self.assertAlmostEqual(coord[1], expected_coord[index][1])
+
+    def test_line_single_sideded_left(self):
+        g = geometry.LineString([[0, 0], [0, 1]])
+        h = g.buffer(1, resolution=1, single_sided=True)
+        self.assertEqual(h.geom_type, 'Polygon')
+        expected_coord = [(0.0, 1.0), (0.0, 0.0), (-1.0, 0.0), (-1.0, 1.0), (0.0, 1.0)]
+        for index, coord in enumerate(h.exterior.coords):
+            self.assertAlmostEqual(coord[0], expected_coord[index][0])
+            self.assertAlmostEqual(coord[1], expected_coord[index][1])
+
+    def test_line_single_sideded_right(self):
+        g = geometry.LineString([[0, 0], [0, 1]])
+        h = g.buffer(-1, resolution=1, single_sided=True)
+        self.assertEqual(h.geom_type, 'Polygon')
+        expected_coord = [(0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0), (0.0, 0.0)]
+        for index, coord in enumerate(h.exterior.coords):
+            self.assertAlmostEqual(coord[0], expected_coord[index][0])
+            self.assertAlmostEqual(coord[1], expected_coord[index][1])
+
+    def test_polygon(self):
+        g = geometry.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])
+        h = g.buffer(1, resolution=1)
+        self.assertEqual(h.geom_type, 'Polygon')
+        expected_coord = [(-1.0, 0.0), (-1.0, 1.0), (0.0, 2.0), (1.0, 2.0), (2.0, 1.0), (2.0, 0.0),
+                          (1.0, -1.0), (0.0, -1.0), (-1.0, 0.0)]
+        for index, coord in enumerate(h.exterior.coords):
+            self.assertAlmostEqual(coord[0], expected_coord[index][0])
+            self.assertAlmostEqual(coord[1], expected_coord[index][1])
+
+    def test_polygon_single_sideded(self):
+        g = geometry.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])
+        h = g.buffer(1, resolution=1, single_sided=True)
+        self.assertEqual(h.geom_type, 'Polygon')
+        expected_coord = [(-1.0, 0.0), (-1.0, 1.0), (0.0, 2.0), (1.0, 2.0), (2.0, 1.0), (2.0, 0.0),
+                          (1.0, -1.0), (0.0, -1.0), (-1.0, 0.0)]
+        for index, coord in enumerate(h.exterior.coords):
+            self.assertAlmostEqual(coord[0], expected_coord[index][0])
+            self.assertAlmostEqual(coord[1], expected_coord[index][1])
+
+def test_suite():
+    return unittest.TestLoader().loadTestsFromTestCase(BufferSingleSidedCase)
diff --git a/tests/test_geos_err_handler.py b/tests/test_geos_err_handler.py
index ceea3ab..29ff874 100644
--- a/tests/test_geos_err_handler.py
+++ b/tests/test_geos_err_handler.py
@@ -35,7 +35,7 @@ def test_error_handler(tmpdir):
     LineString([(0, 0), (2, 2)]).project(LineString([(1, 1), (1.5, 1.5)]))
 
     log = open(logfile).read()
-    assert "third argument of GEOSProject_r must be Point*" in log
+    assert "third argument of GEOSProject_r must be Point" in log
 
 
 def test_error_handler_wrong_type():