Repository: Toblerity/Shapely
Pull request #870 (2020-03-19 02:36:53)

Add Minimum Clearance implementation
This pull request exposes the "minimum clearance" functionality added in GEOS 3.6 to Shapely geometries as a unary property. I've never contributed here before and I'm sure there are more implications than I saw to adding support for a newer version of GEOS, so if this is a no-go (or there's a ton more work to be done to fully support that) please let me know, but I'm happy to put in the work to get it done. 

I've wanted for a while to add something back to this library which I've gotten great use out of, so I hope this is something that fits.

Thanks for your time!

coveralls: 
[![Coverage Status](https://coveralls.io/builds/29461017/badge)](https://coveralls.io/builds/29461017)

Coverage decreased (-0.1%) to 80.877% when pulling **0878aaa92a09022d1f29a4cb20fa1452dde0b03d on tomplex:minimum-clearance-impl** into **68ee09cff39a869526adb64780e0819c532e838a on Toblerity:master**.

tomplex: Hey @sgillies,

I modified the code the explicitly use the decorator and it seems the tests were still being skipped in Travis, though I was able to run / skip them on my local by playing with the required version. 

That still didn't get the tests to stop always skipping in CI, so I switched from `pytest` to `unittest` to see if that helps. Travis is taking a bit to start the run so I'll check back a bit later.

As an aside, the [pytest docs](http://doc.pytest.org/en/latest/skipping.html#skip-all-test-functions-of-a-class-or-module) suggest that creating a marker called `pytestmark` at the module level should enforce those rules for an entire file, so I guess it's good that the marker wasn't working regardless of how it was applied, but that's not helpful as to why it wasn't working. I'll try to take a closer look.
sgillies: @tomplex I think you're doing everything right. Makes me think that our test environment is misconfigured and we're not using the version of GEOS we think we are. I'll dig into that today.
tomplex: @sgillies, I made `pytest` show print statements so we could see the diagnostic info in the `test/__init__.py`. I checked every job in the matrix and they're all running with `geos` version 3.5.0, or at least as far as Shapely is concerned.
tomplex: I also added a check of the `geos-config --version` which shows us that cached versions of GEOS are correct, so it means that the wires are getting crossed somewhere in the `pip install`, and we're not installing against the specified version properly.
tomplex: Also, it looks like during the `pip install` step, we're using the right `geos-config` - so I'm not sure where we're going wrong. Maybe we're using the wrong Python to run tests?
tomplex: hey @sgillies, I think I've gotten to the root of this, sort of. It looks like in all cases we're referring to the right version of GEOS, passing it into `pip` / the `setup.py` file correctly. I think the issue is that Shapely is not consistently referencing the proper version of GEOS. I included more info including steps to reproduce in #872.
sgillies: @tomplex in https://github.com/Toblerity/Shapely/pull/873/files I've made some changes to help shapely use the correct GEOS and assert that it is doing so.
tomplex: @sgillies  should this request be pointing at `master` or a different branch? Seems like you merged the changes for Travis into `maint-1.7`, so should I be pointing at that branch instead?
sgillies: @tomplex if you're comfortable rebasing it on maint-1.7, please do 🙏 . If not, no sweat, I'm going to squash merge this PR and then I could cherry-pick the commit after.
tomplex: No problem! I can do that as soon as I "get home" from work this evening.
tomplex: @sgilles I think I mucked up the history a teensy bit but if you're gonna squash it anyway then it shouldn't make a difference. Thanks again for taking a look a this!
tomplex: Crap, I accidentally carried over a change in the `__init__.py` regarding the Shapely version...no idea where that came from. 
sgillies: Fantastic. Thanks @tomplex !

Patch:
diff --git a/CREDITS.txt b/CREDITS.txt
index c4d0e51..ff72820 100644
--- a/CREDITS.txt
+++ b/CREDITS.txt
@@ -76,6 +76,7 @@ Shapely is written by:
 * Taro Matsuzawa aka. btm <btm@tech.email.ne.jp>
 * Thibault Deutsch <thibault.deutsch@gmail.com>
 * Thomas Kluyver <takowl@gmail.com>
+* Tom Caruso <carusot42@gmail.com>
 * Tobias Sauerwein <tobias.sauerwein@camptocamp.com>
 * Tom Clancy <17627475+clncy@users.noreply.github.com>
 * WANG Aiyong <gepcelway@gmail.com>
diff --git a/shapely/ctypes_declarations.py b/shapely/ctypes_declarations.py
index 7ecfb3a..444011d 100644
--- a/shapely/ctypes_declarations.py
+++ b/shapely/ctypes_declarations.py
@@ -538,3 +538,6 @@ def prototype(lgeos, geos_version):
         lgeos.GEOSSTRtree_nearest_generic.argtypes = [
             c_void_p, py_object, c_void_p, lgeos.GEOSDistanceCallback, py_object]
         lgeos.GEOSSTRtree_nearest_generic.restype = c_void_p
+
+        lgeos.GEOSMinimumClearance.argtypes = [c_void_p]
+        lgeos.GEOSMinimumClearance.restype = c_double
diff --git a/shapely/geometry/base.py b/shapely/geometry/base.py
index ec23a71..415848f 100644
--- a/shapely/geometry/base.py
+++ b/shapely/geometry/base.py
@@ -450,6 +450,11 @@ class BaseGeometry(object):
         """Unitless length of the geometry (float)"""
         return self.impl['length'](self)
 
+    @property
+    def minimum_clearance(self):
+        """Unitless distance by which a node could be moved to produce an invalid geometry (float)"""
+        return self.impl['minimum_clearance'](self)
+
     # Topological properties
     # ----------------------
 
diff --git a/shapely/geos.py b/shapely/geos.py
index 5b80c55..cbdaba2 100644
--- a/shapely/geos.py
+++ b/shapely/geos.py
@@ -862,13 +862,28 @@ class LGEOS340(LGEOS330):
 class LGEOS350(LGEOS340):
     """Proxy for GEOS 3.5.0-CAPI-1.9.0
     """
+    geos_version = (3, 5, 0)
+    geos_capi_version = (1, 9, 0)
 
     def __init__(self, dll):
         super(LGEOS350, self).__init__(dll)
         self.methods['clip_by_rect'] = self.GEOSClipByRect
 
 
-if geos_version >= (3, 5, 0):
+class LGEOS360(LGEOS350):
+    """Proxy for GEOS 3.6.0-CAPI-1.10.0
+    """
+    geos_version = (3, 6, 0)
+    geos_capi_version = (1, 10, 0)
+
+    def __init__(self, dll):
+        super(LGEOS360, self).__init__(dll)
+        self.methods['minimum_clearance'] = self.GEOSMinimumClearance
+
+
+if geos_version >= (3, 6, 0):
+    L = LGEOS360
+elif geos_version >= (3, 5, 0):
     L = LGEOS350
 elif geos_version >= (3, 4, 0):
     L = LGEOS340
diff --git a/shapely/impl.py b/shapely/impl.py
index cc6550a..dbc9bc4 100644
--- a/shapely/impl.py
+++ b/shapely/impl.py
@@ -148,6 +148,9 @@ IMPL330 = {
     'buffer_with_params': (UnaryTopologicalOp, 'buffer_with_params'),
 }
 
+IMPL360 = {
+    'minimum_clearance': (UnaryRealProperty, 'minimum_clearance')
+}
 
 def impl_items(defs):
     return [(k, v[0](v[1])) for k, v in list(defs.items())]
@@ -161,5 +164,7 @@ if lgeos.geos_version >= (3, 2, 0):
     imp.update(impl_items(IMPL320))
 if lgeos.geos_version >= (3, 3, 0):
     imp.update(impl_items(IMPL330))
+if lgeos.geos_version >= (3, 6, 0):
+    imp.update(impl_items(IMPL360))
 
 DefaultImplementation = imp


Test patch:
diff --git a/tests/test_minimum_clearance.py b/tests/test_minimum_clearance.py
new file mode 100644
index 0000000..be7f1fd
--- /dev/null
+++ b/tests/test_minimum_clearance.py
@@ -0,0 +1,36 @@
+"""
+Tests for the minimum clearance property.
+"""
+
+import math
+
+import pytest
+
+from shapely.wkt import loads as load_wkt
+from shapely.geos import geos_version
+
+requires_geos_36 = pytest.mark.skipif(geos_version < (3, 6, 0), reason="GEOS > 3.6.0 is required.")
+
+
+@requires_geos_36
+def test_point():
+    point = load_wkt("POINT (0 0)")
+    assert point.minimum_clearance == math.inf
+
+
+@requires_geos_36
+def test_linestring():
+    line = load_wkt('LINESTRING (0 0, 1 1, 2 2)')
+    assert round(line.minimum_clearance, 6) == 1.414214
+
+
+@requires_geos_36
+def test_simple_polygon():
+    poly = load_wkt('POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))')
+    assert poly.minimum_clearance == 1.0
+
+
+@requires_geos_36
+def test_more_complicated_polygon():
+    poly = load_wkt('POLYGON ((20 20, 34 124, 70 140, 130 130, 70 100, 110 70, 170 20, 90 10, 20 20))')
+    assert round(poly.minimum_clearance, 6) == 35.777088