From 0f3b1a783dfa36cb23aae0bb954756d0edcd9fc1 Mon Sep 17 00:00:00 2001 From: Olivier Tabone Date: Wed, 26 Jul 2023 23:18:29 +0200 Subject: [PATCH] Fixed #34739 -- Added GEOSGeometry.equals_identical() method. --- django/contrib/gis/geos/geometry.py | 12 +++- .../contrib/gis/geos/prototypes/__init__.py | 1 + .../contrib/gis/geos/prototypes/predicates.py | 1 + docs/ref/contrib/gis/geos.txt | 9 +++ docs/releases/5.0.txt | 3 + tests/gis_tests/geos_tests/test_geos.py | 72 ++++++++++++++++++- 6 files changed, 96 insertions(+), 2 deletions(-) diff --git a/django/contrib/gis/geos/geometry.py b/django/contrib/gis/geos/geometry.py index 38e5fe554c..00b36af0a6 100644 --- a/django/contrib/gis/geos/geometry.py +++ b/django/contrib/gis/geos/geometry.py @@ -11,7 +11,7 @@ from django.contrib.gis.geos import prototypes as capi from django.contrib.gis.geos.base import GEOSBase from django.contrib.gis.geos.coordseq import GEOSCoordSeq from django.contrib.gis.geos.error import GEOSException -from django.contrib.gis.geos.libgeos import GEOM_PTR +from django.contrib.gis.geos.libgeos import GEOM_PTR, geos_version_tuple from django.contrib.gis.geos.mutable_list import ListMixin from django.contrib.gis.geos.prepared import PreparedGeometry from django.contrib.gis.geos.prototypes.io import ewkb_w, wkb_r, wkb_w, wkt_r, wkt_w @@ -318,6 +318,16 @@ class GEOSGeometryBase(GEOSBase): """ return capi.geos_equalsexact(self.ptr, other.ptr, float(tolerance)) + def equals_identical(self, other): + """ + Return true if the two Geometries are point-wise equivalent. + """ + if geos_version_tuple() < (3, 12): + raise GEOSException( + "GEOSGeometry.equals_identical() requires GEOS >= 3.12.0." + ) + return capi.geos_equalsidentical(self.ptr, other.ptr) + def intersects(self, other): "Return true if disjoint return false." return capi.geos_intersects(self.ptr, other.ptr) diff --git a/django/contrib/gis/geos/prototypes/__init__.py b/django/contrib/gis/geos/prototypes/__init__.py index 8fa98f98e7..3e980b9b89 100644 --- a/django/contrib/gis/geos/prototypes/__init__.py +++ b/django/contrib/gis/geos/prototypes/__init__.py @@ -51,6 +51,7 @@ from django.contrib.gis.geos.prototypes.predicates import ( # NOQA geos_disjoint, geos_equals, geos_equalsexact, + geos_equalsidentical, geos_hasz, geos_intersects, geos_isclosed, diff --git a/django/contrib/gis/geos/prototypes/predicates.py b/django/contrib/gis/geos/prototypes/predicates.py index d2e113a734..32b790173a 100644 --- a/django/contrib/gis/geos/prototypes/predicates.py +++ b/django/contrib/gis/geos/prototypes/predicates.py @@ -38,6 +38,7 @@ geos_equals = BinaryPredicate("GEOSEquals") geos_equalsexact = BinaryPredicate( "GEOSEqualsExact", argtypes=[GEOM_PTR, GEOM_PTR, c_double] ) +geos_equalsidentical = BinaryPredicate("GEOSEqualsIdentical") geos_intersects = BinaryPredicate("GEOSIntersects") geos_overlaps = BinaryPredicate("GEOSOverlaps") geos_relatepattern = BinaryPredicate( diff --git a/docs/ref/contrib/gis/geos.txt b/docs/ref/contrib/gis/geos.txt index 564d49bbb6..471103bf97 100644 --- a/docs/ref/contrib/gis/geos.txt +++ b/docs/ref/contrib/gis/geos.txt @@ -483,6 +483,15 @@ return a boolean. ``poly1.equals_exact(poly2, 0.001)`` will compare equality to within one thousandth of a unit. +.. method:: GEOSGeometry.equals_identical(other) + + .. versionadded:: 5.0 + + Returns ``True`` if the two geometries are point-wise equivalent by + checking that the structure, ordering, and values of all vertices are + identical in all dimensions. ``NaN`` values are considered to be equal to + other ``NaN`` values. Requires GEOS 3.12. + .. method:: GEOSGeometry.intersects(other) Returns ``True`` if :meth:`GEOSGeometry.disjoint` is ``False``. diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt index 030e16a054..8f15171d17 100644 --- a/docs/releases/5.0.txt +++ b/docs/releases/5.0.txt @@ -192,6 +192,9 @@ Minor features * Added support for GEOS 3.12. +* The new :meth:`.GEOSGeometry.equals_identical` method allows point-wise + equivalence checking of geometries. + :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 9f1ba6d45f..0051a88b07 100644 --- a/tests/gis_tests/geos_tests/test_geos.py +++ b/tests/gis_tests/geos_tests/test_geos.py @@ -1,11 +1,12 @@ import ctypes import itertools import json +import math 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 ( @@ -241,6 +242,75 @@ class GEOSTest(SimpleTestCase, TestDataMixin): self.assertEqual(p0, "SRID=0;POINT (5 23)") self.assertNotEqual(p1, "SRID=0;POINT (5 23)") + @skipIf(geos_version_tuple() < (3, 12), "GEOS >= 3.12.0 is required") + def test_equals_identical(self): + tests = [ + # Empty inputs of different types are not equals_identical. + ("POINT EMPTY", "LINESTRING EMPTY", False), + # Empty inputs of different dimensions are not equals_identical. + ("POINT EMPTY", "POINT Z EMPTY", False), + # Non-empty inputs of different dimensions are not equals_identical. + ("POINT Z (1 2 3)", "POINT M (1 2 3)", False), + ("POINT ZM (1 2 3 4)", "POINT Z (1 2 3)", False), + # Inputs with different structure are not equals_identical. + ("LINESTRING (1 1, 2 2)", "MULTILINESTRING ((1 1, 2 2))", False), + # Inputs with different types are not equals_identical. + ( + "GEOMETRYCOLLECTION (LINESTRING (1 1, 2 2))", + "MULTILINESTRING ((1 1, 2 2))", + False, + ), + # Same lines are equals_identical. + ("LINESTRING M (1 1 0, 2 2 1)", "LINESTRING M (1 1 0, 2 2 1)", True), + # Different lines are not equals_identical. + ("LINESTRING M (1 1 0, 2 2 1)", "LINESTRING M (1 1 1, 2 2 1)", False), + # Same polygons are equals_identical. + ("POLYGON ((0 0, 1 0, 1 1, 0 0))", "POLYGON ((0 0, 1 0, 1 1, 0 0))", True), + # Different polygons are not equals_identical. + ("POLYGON ((0 0, 1 0, 1 1, 0 0))", "POLYGON ((1 0, 1 1, 0 0, 1 0))", False), + # Different polygons (number of holes) are not equals_identical. + ( + "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (1 1, 2 1, 2 2, 1 1))", + ( + "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (1 1, 2 1, 2 2, 1 1), " + "(3 3, 4 3, 4 4, 3 3))" + ), + False, + ), + # Same collections are equals_identical. + ( + "MULTILINESTRING ((1 1, 2 2), (2 2, 3 3))", + "MULTILINESTRING ((1 1, 2 2), (2 2, 3 3))", + True, + ), + # Different collections (structure) are not equals_identical. + ( + "MULTILINESTRING ((1 1, 2 2), (2 2, 3 3))", + "MULTILINESTRING ((2 2, 3 3), (1 1, 2 2))", + False, + ), + ] + for g1, g2, is_equal_identical in tests: + with self.subTest(g1=g1, g2=g2): + self.assertIs( + fromstr(g1).equals_identical(fromstr(g2)), is_equal_identical + ) + + @skipIf(geos_version_tuple() < (3, 12), "GEOS >= 3.12.0 is required") + def test_infinite_values_equals_identical(self): + # Input with identical infinite values are equals_identical. + g1 = Point(x=float("nan"), y=math.inf) + g2 = Point(x=float("nan"), y=math.inf) + self.assertIs(g1.equals_identical(g2), True) + + @mock.patch("django.contrib.gis.geos.libgeos.geos_version", lambda: b"3.11.0") + def test_equals_identical_geos_version(self): + g1 = fromstr("POINT (1 2 3)") + g2 = fromstr("POINT (1 2 3)") + msg = "GEOSGeometry.equals_identical() requires GEOS >= 3.12.0" + with self.assertRaisesMessage(GEOSException, msg): + g1.equals_identical(g2) + def test_points(self): "Testing Point objects." prev = fromstr("POINT(0 0)")