From 6bbf9a20e2c5865e01b537e8cd34dfca06621a4a Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Thu, 17 Oct 2019 13:17:42 +0500 Subject: [PATCH] Fixed #29770 -- Added LinearRing.is_counterclockwise property. --- .../contrib/gis/db/backends/oracle/adapter.py | 19 +++---------- django/contrib/gis/geos/coordseq.py | 24 ++++++++++++++-- django/contrib/gis/geos/linestring.py | 8 ++++++ .../contrib/gis/geos/prototypes/__init__.py | 3 +- .../contrib/gis/geos/prototypes/coordseq.py | 4 ++- docs/ref/contrib/gis/geos.txt | 6 ++++ docs/releases/3.1.txt | 2 ++ tests/gis_tests/geos_tests/test_geos.py | 28 ++++++++++++++++++- 8 files changed, 74 insertions(+), 20 deletions(-) diff --git a/django/contrib/gis/db/backends/oracle/adapter.py b/django/contrib/gis/db/backends/oracle/adapter.py index 0c491fdc8d..6b19349ed2 100644 --- a/django/contrib/gis/db/backends/oracle/adapter.py +++ b/django/contrib/gis/db/backends/oracle/adapter.py @@ -25,11 +25,13 @@ class OracleSpatialAdapter(WKTAdapter): def _fix_polygon(self, poly): """Fix single polygon orientation as described in __init__().""" - if self._isClockwise(poly.exterior_ring): + if poly.empty: + return poly + if not poly.exterior_ring.is_counterclockwise: poly.exterior_ring = list(reversed(poly.exterior_ring)) for i in range(1, len(poly)): - if not self._isClockwise(poly[i]): + if poly[i].is_counterclockwise: poly[i] = list(reversed(poly[i])) return poly @@ -42,16 +44,3 @@ class OracleSpatialAdapter(WKTAdapter): for i, geom in enumerate(coll): if isinstance(geom, Polygon): coll[i] = self._fix_polygon(geom) - - def _isClockwise(self, coords): - """ - A modified shoelace algorithm to determine polygon orientation. - See https://en.wikipedia.org/wiki/Shoelace_formula. - """ - n = len(coords) - area = 0.0 - for i in range(n): - j = (i + 1) % n - area += coords[i][0] * coords[j][1] - area -= coords[j][0] * coords[i][1] - return area < 0.0 diff --git a/django/contrib/gis/geos/coordseq.py b/django/contrib/gis/geos/coordseq.py index 6ea0fe9be3..0a71a2be4a 100644 --- a/django/contrib/gis/geos/coordseq.py +++ b/django/contrib/gis/geos/coordseq.py @@ -3,12 +3,12 @@ by GEOSGeometry to house the actual coordinates of the Point, LineString, and LinearRing geometries. """ -from ctypes import byref, c_double, c_uint +from ctypes import byref, c_byte, c_double, c_uint from django.contrib.gis.geos import prototypes as capi from django.contrib.gis.geos.base import GEOSBase from django.contrib.gis.geos.error import GEOSException -from django.contrib.gis.geos.libgeos import CS_PTR +from django.contrib.gis.geos.libgeos import CS_PTR, geos_version_tuple from django.contrib.gis.shortcuts import numpy @@ -194,3 +194,23 @@ class GEOSCoordSeq(GEOSBase): if n == 1: return get_point(0) return tuple(get_point(i) for i in range(n)) + + @property + def is_counterclockwise(self): + """Return whether this coordinate sequence is counterclockwise.""" + if geos_version_tuple() < (3, 7): + # A modified shoelace algorithm to determine polygon orientation. + # See https://en.wikipedia.org/wiki/Shoelace_formula. + area = 0.0 + n = len(self) + for i in range(n): + j = (i + 1) % n + area += self[i][0] * self[j][1] + area -= self[j][0] * self[i][1] + return area > 0.0 + ret = c_byte() + if not capi.cs_is_ccw(self.ptr, byref(ret)): + raise GEOSException( + 'Error encountered in GEOS C function "%s".' % capi.cs_is_ccw.func_name + ) + return ret.value == 1 diff --git a/django/contrib/gis/geos/linestring.py b/django/contrib/gis/geos/linestring.py index 630aa0a63d..85f29319e4 100644 --- a/django/contrib/gis/geos/linestring.py +++ b/django/contrib/gis/geos/linestring.py @@ -176,3 +176,11 @@ class LineString(LinearGeometryMixin, GEOSGeometry): class LinearRing(LineString): _minlength = 4 _init_func = capi.create_linearring + + @property + def is_counterclockwise(self): + if self.empty: + raise ValueError( + 'Orientation of an empty LinearRing cannot be determined.' + ) + return self._cs.is_counterclockwise diff --git a/django/contrib/gis/geos/prototypes/__init__.py b/django/contrib/gis/geos/prototypes/__init__.py index 18eae1d31a..844ae4cda7 100644 --- a/django/contrib/gis/geos/prototypes/__init__.py +++ b/django/contrib/gis/geos/prototypes/__init__.py @@ -6,7 +6,8 @@ from django.contrib.gis.geos.prototypes.coordseq import ( # NOQA create_cs, cs_clone, cs_getdims, cs_getordinate, cs_getsize, cs_getx, - cs_gety, cs_getz, cs_setordinate, cs_setx, cs_sety, cs_setz, get_cs, + cs_gety, cs_getz, cs_is_ccw, cs_setordinate, cs_setx, cs_sety, cs_setz, + get_cs, ) from django.contrib.gis.geos.prototypes.geom import ( # NOQA create_collection, create_empty_polygon, create_linearring, diff --git a/django/contrib/gis/geos/prototypes/coordseq.py b/django/contrib/gis/geos/prototypes/coordseq.py index d1a5ed6043..aab5d3e75e 100644 --- a/django/contrib/gis/geos/prototypes/coordseq.py +++ b/django/contrib/gis/geos/prototypes/coordseq.py @@ -1,4 +1,4 @@ -from ctypes import POINTER, c_double, c_int, c_uint +from ctypes import POINTER, c_byte, c_double, c_int, c_uint from django.contrib.gis.geos.libgeos import CS_PTR, GEOM_PTR, GEOSFuncFactory from django.contrib.gis.geos.prototypes.errcheck import ( @@ -89,3 +89,5 @@ cs_setz = CsOperation('GEOSCoordSeq_setZ') # These routines return size & dimensions. cs_getsize = CsInt('GEOSCoordSeq_getSize') cs_getdims = CsInt('GEOSCoordSeq_getDimensions') + +cs_is_ccw = GEOSFuncFactory('GEOSCoordSeq_isCCW', restype=c_int, argtypes=[CS_PTR, POINTER(c_byte)]) diff --git a/docs/ref/contrib/gis/geos.txt b/docs/ref/contrib/gis/geos.txt index fda1b5cc3d..536c42f74e 100644 --- a/docs/ref/contrib/gis/geos.txt +++ b/docs/ref/contrib/gis/geos.txt @@ -730,6 +730,12 @@ Other Properties & Methods Notice that ``(0, 0)`` is the first and last coordinate -- if they were not equal, an error would be raised. + .. attribute:: is_counterclockwise + + .. versionadded:: 3.1 + + Returns whether this ``LinearRing`` is counterclockwise. + ``Polygon`` ----------- diff --git a/docs/releases/3.1.txt b/docs/releases/3.1.txt index 70297b5311..0a08ce3cdf 100644 --- a/docs/releases/3.1.txt +++ b/docs/releases/3.1.txt @@ -61,6 +61,8 @@ Minor features * :lookup:`relate` lookup is now supported on MariaDB. +* Added the :attr:`.LinearRing.is_counterclockwise` property. + :mod:`django.contrib.messages` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/gis_tests/geos_tests/test_geos.py b/tests/gis_tests/geos_tests/test_geos.py index a74eabb314..c2aca95178 100644 --- a/tests/gis_tests/geos_tests/test_geos.py +++ b/tests/gis_tests/geos_tests/test_geos.py @@ -5,7 +5,7 @@ import pickle import random from binascii import a2b_hex from io import BytesIO -from unittest import mock +from unittest import mock, skipIf from django.contrib.gis import gdal from django.contrib.gis.geos import ( @@ -360,6 +360,32 @@ class GEOSTest(SimpleTestCase, TestDataMixin): line.reverse() self.assertEqual(line.ewkt, 'SRID=4326;LINESTRING (151.2607 -33.887, 144.963 -37.8143)') + def _test_is_counterclockwise(self): + lr = LinearRing((0, 0), (1, 0), (0, 1), (0, 0)) + self.assertIs(lr.is_counterclockwise, True) + lr.reverse() + self.assertIs(lr.is_counterclockwise, False) + msg = 'Orientation of an empty LinearRing cannot be determined.' + with self.assertRaisesMessage(ValueError, msg): + LinearRing().is_counterclockwise + + @skipIf(geos_version_tuple() < (3, 7), 'GEOS >= 3.7.0 is required') + def test_is_counterclockwise(self): + self._test_is_counterclockwise() + + @skipIf(geos_version_tuple() < (3, 7), 'GEOS >= 3.7.0 is required') + def test_is_counterclockwise_geos_error(self): + with mock.patch('django.contrib.gis.geos.prototypes.cs_is_ccw') as mocked: + mocked.return_value = 0 + mocked.func_name = 'GEOSCoordSeq_isCCW' + msg = 'Error encountered in GEOS C function "GEOSCoordSeq_isCCW".' + with self.assertRaisesMessage(GEOSException, msg): + LinearRing((0, 0), (1, 0), (0, 1), (0, 0)).is_counterclockwise + + @mock.patch('django.contrib.gis.geos.libgeos.geos_version', lambda: b'3.6.9') + def test_is_counterclockwise_fallback(self): + self._test_is_counterclockwise() + def test_multilinestring(self): "Testing MultiLineString objects." prev = fromstr('POINT(0 0)')