From 0fdc579d5d652cd02915573739ef103ddf74b1e9 Mon Sep 17 00:00:00 2001 From: Alex Svetkin Date: Wed, 1 Nov 2023 00:38:44 +0100 Subject: [PATCH 1/5] added force_2d factory and move_coordinates function #180 --- pygeoif/factories.py | 32 +++++++++++ pygeoif/functions.py | 51 ++++++++++++++++++ tests/test_factories.py | 114 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+) diff --git a/pygeoif/factories.py b/pygeoif/factories.py index e7d762f8..8377b309 100644 --- a/pygeoif/factories.py +++ b/pygeoif/factories.py @@ -24,6 +24,7 @@ from typing import cast from pygeoif.exceptions import WKTParserError +from pygeoif.functions import move_coordinates from pygeoif.functions import signed_area from pygeoif.geometry import Geometry from pygeoif.geometry import GeometryCollection @@ -60,6 +61,36 @@ mpre: Pattern[str] = re.compile(r"\(\((.+?)\)\)") +def force_2d( + context: Union[GeoType, GeoCollectionType], +) -> Union[Geometry, GeometryCollection]: + """ + Force the dimensionality of a geometry to 2D. + + >>> force_2d(Point(0, 0, 1)) + Point(0, 0) + >>> force_2d(Point(0, 0)) + Point(0, 0) + >>> force_2d(LineString([(0, 0, 0), (0, 1, 1), (1, 1, 2)])) + LineString(((0, 0), (0, 1), (1, 1))) + """ + geometry = context if isinstance(context, dict) else mapping(context) + if not geometry: + msg = "Object does not implement __geo_interface__" + raise TypeError(msg) + if geometry["type"] == "GeometryCollection": + return GeometryCollection( + force_2d(g) # type: ignore [arg-type] + for g in geometry["geometries"] # type: ignore [typeddict-item] + ) + + geometry["coordinates"] = move_coordinates( # type: ignore [typeddict-unknown-key] + geometry["coordinates"], # type: ignore [typeddict-item] + (0, 0), + ) + return shape(geometry) + + def get_oriented_ring(ring: LineType, ccw: bool) -> LineType: # noqa: FBT001 s = 1.0 if ccw else -1.0 return ring if signed_area(ring) / s >= 0 else ring[::-1] @@ -332,6 +363,7 @@ def mapping( __all__ = [ + "force_2d", "box", "from_wkt", "mapping", diff --git a/pygeoif/functions.py b/pygeoif/functions.py index f3e82f34..1d771285 100644 --- a/pygeoif/functions.py +++ b/pygeoif/functions.py @@ -19,8 +19,10 @@ import math from itertools import groupby from itertools import zip_longest +from typing import Any from typing import Iterable from typing import List +from typing import Sequence from typing import Tuple from typing import Union from typing import cast @@ -188,11 +190,60 @@ def compare_geo_interface( return False +def move_coordinate( + coordinate: Sequence[float], + move_by: Sequence[float], + z: float = 0, +) -> Tuple[float, ...]: + """ + Move the coordinate by the given vector. + + This forcefully changes the dimensions of the coordinate to match the latter. + >>> move_coordinate((0, 0), (-1, 1)) + (-1, 1) + >>> move_coordinate((0, 0, 0), (-1, 1)) + (-1, 1) + >>> move_coordinate((0, 0), (-1, 1, 0)) + (-1, 1, 0) + """ + if len(coordinate) > len(move_by): + return tuple(c + m for c, m in zip(coordinate, move_by)) + return tuple(c + m for c, m in zip_longest(coordinate, move_by, fillvalue=z)) + + +def move_coordinates( + coordinates: Sequence[Any], + move_by: Sequence[float], + z: float = 0, +) -> Sequence[Any]: + """ + Move the coordinates recursively by the given vector. + + This forcefully changes the dimension of each of the coordinate to match + the dimension of the vector. + >>> move_coordinates(((0, 0), (-1, 1)), (-1, 1)) + ((-1, 1), (-2, 2)) + >>> move_coordinates(((0, 0, 0), (-1, 1, 0)), (-1, 1)) + ((-1, 1), (-2, 2)) + >>> move_coordinates(((0, 0), (-1, 1)), (-1, 1, 0)) + ((-1, 1, 0), (-2, 2, 0)) + """ + if isinstance(coordinates, (tuple, list)) and isinstance( + coordinates[0], + (int, float), + ): + # coordinates is just a list of numbers, i.e. represents a single coordinate + return move_coordinate(coordinates, move_by, z) + return tuple(move_coordinates(c, move_by, z) for c in coordinates) + + __all__ = [ "centroid", "compare_coordinates", "compare_geo_interface", "convex_hull", "dedupe", + "move_coordinate", + "move_coordinates", "signed_area", ] diff --git a/tests/test_factories.py b/tests/test_factories.py index 96235841..c2472827 100644 --- a/tests/test_factories.py +++ b/tests/test_factories.py @@ -21,6 +21,120 @@ def test_num_float() -> None: assert isinstance(factories.num("1.1"), float) +def test_force_2d_point() -> None: + # 2d point to 2d point (no actual change) + p = geometry.Point(-1, 1) + p2d = factories.force_2d(p) + assert p2d.x == -1 + assert p2d.y == 1 + assert not p2d.has_z + + # 3d point to 2d point + p = geometry.Point(-1, 1, 2) + p2d = factories.force_2d(p) + assert p2d.x == -1 + assert p2d.y == 1 + assert not p2d.has_z + + +def test_force_2d_multipoint() -> None: + # 2d to 2d (no actual change) + p = geometry.MultiPoint([(-1, 1), (2, 3)]) + p2d = factories.force_2d(p) + assert list(p2d.geoms) == [geometry.Point(-1, 1), geometry.Point(2, 3)] + + +def test_force_2d_linestring() -> None: + # 2d line string to 2d line string (no actual change) + ls = geometry.LineString([(1, 2), (3, 4)]) + l2d = factories.force_2d(ls) + assert l2d.coords == ((1, 2), (3, 4)) + + # 3d line string to 2d line string + ls = geometry.LineString([(1, 2, 3), (4, 5, 6)]) + l2d = factories.force_2d(ls) + assert l2d.coords == ((1, 2), (4, 5)) + + +def test_force_2d_linearring() -> None: + # 2d linear ring to 2d linear ring (no actual change) + r = geometry.LinearRing([(1, 2), (3, 4)]) + r2d = factories.force_2d(r) + assert r2d.coords == ((1, 2), (3, 4), (1, 2)) + + # 3d linear ring to 2d linear ring + r = geometry.LinearRing([(1, 2, 3), (4, 5, 6)]) + r2d = factories.force_2d(r) + assert r2d.coords == ((1, 2), (4, 5), (1, 2)) + + +def test_force_2d_multilinestring() -> None: + # 2d multi line string to 2d multi line string (no actual change) + mls = geometry.MultiLineString([[(1, 2), (3, 4)], [(5, 6), (7, 8)]]) + mls2d = factories.force_2d(mls) + assert list(mls2d.geoms) == list(mls.geoms) + + # 3d multi line string to 2d multi line string + mls = geometry.MultiLineString([[(1, 2, 3), (4, 5, 6)], [(7, 8, 9), (10, 11, 12)]]) + mls2d = factories.force_2d(mls) + assert list(mls2d.geoms) == [geometry.LineString([(1, 2), (4, 5)]), geometry.LineString([(7, 8), (10, 11)])] + +def test_force_2d_polygon() -> None: + # 2d to 2d (no actual change) + external = [(0, 0), (0, 2), (2, 2), (2, 0), (0, 0)] + internal = [(0.5, 0.5), (0.5, 1.5), (1.5, 1.5), (1.5, 0.5), (0.5, 0.5)] + p = geometry.Polygon(external, [internal]) + p2d = factories.force_2d(p) + assert p2d.coords[0] == ( + ((0, 0), (0, 2), (2, 2), (2, 0), (0, 0)) + ) + assert p2d.coords[1] == ( + ((0.5, 0.5), (0.5, 1.5), (1.5, 1.5), (1.5, 0.5), (0.5, 0.5)), + ) + assert not p2d.has_z + assert p.maybe_valid == p2d.maybe_valid + + # 3d to 2d + external = [(0, 0, 1), (0, 2, 1), (2, 2, 1), (2, 0, 1), (0, 0, 1)] + internal = [(0.5, 0.5, 1), (0.5, 1.5, 1), (1.5, 1.5, 1), (1.5, 0.5, 1), (0.5, 0.5, 1)] + + p = geometry.Polygon(external, [internal]) + p2d = factories.force_2d(p) + assert p2d.coords[0] == ( + ((0, 0), (0, 2), (2, 2), (2, 0), (0, 0)) + ) + assert p2d.coords[1] == ( + ((0.5, 0.5), (0.5, 1.5), (1.5, 1.5), (1.5, 0.5), (0.5, 0.5)), + ) + assert not p2d.has_z + + +def test_force_2d_multipolygon() -> None: + # 2d to 2d (no actual change) + external = [(0, 0), (0, 2), (2, 2), (2, 0), (0, 0)] + internal = [(0.5, 0.5), (0.5, 1.5), (1.5, 1.5), (1.5, 0.5), (0.5, 0.5)] + mp = geometry.MultiPolygon([(external, [internal]), (external, [internal])]) + mp2d = factories.force_2d(mp) + + assert list(mp2d.geoms) == list(mp.geoms) + + +def test_force2d_collection() -> None: + # 2d to 2d (no actual change) + gc = geometry.GeometryCollection([geometry.Point(-1, 1), geometry.Point(-2, 2)]) + gc2d = factories.force_2d(gc) + assert list(gc2d.geoms) == list(gc.geoms) + + # 3d to 2d + gc = geometry.GeometryCollection([geometry.Point(-1, 1, 0), geometry.Point(-2, 2, 0)]) + gc2d = factories.force_2d(gc) + assert list(gc2d.geoms) == [geometry.Point(-1, 1), geometry.Point(-2, 2)] + + +def test_force_2d_nongeo() -> None: + pytest.raises(AttributeError, factories.force_2d, (1, 2, 3)) + + def test_orient_true() -> None: ext = [(0, 0), (0, 2), (2, 2), (2, 0), (0, 0)] int_1 = [(0.5, 0.25), (1.5, 0.25), (1.5, 1.25), (0.5, 1.25), (0.5, 0.25)] From f28889f8969e9f874b08a09042c08aff60dec833 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 31 Oct 2023 23:46:40 +0000 Subject: [PATCH 2/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pygeoif/factories.py | 2 +- tests/test_factories.py | 26 +++++++++++++++++--------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/pygeoif/factories.py b/pygeoif/factories.py index 8377b309..4f7da1b8 100644 --- a/pygeoif/factories.py +++ b/pygeoif/factories.py @@ -86,7 +86,7 @@ def force_2d( geometry["coordinates"] = move_coordinates( # type: ignore [typeddict-unknown-key] geometry["coordinates"], # type: ignore [typeddict-item] - (0, 0), + (0, 0), ) return shape(geometry) diff --git a/tests/test_factories.py b/tests/test_factories.py index c2472827..99b30b6c 100644 --- a/tests/test_factories.py +++ b/tests/test_factories.py @@ -77,7 +77,11 @@ def test_force_2d_multilinestring() -> None: # 3d multi line string to 2d multi line string mls = geometry.MultiLineString([[(1, 2, 3), (4, 5, 6)], [(7, 8, 9), (10, 11, 12)]]) mls2d = factories.force_2d(mls) - assert list(mls2d.geoms) == [geometry.LineString([(1, 2), (4, 5)]), geometry.LineString([(7, 8), (10, 11)])] + assert list(mls2d.geoms) == [ + geometry.LineString([(1, 2), (4, 5)]), + geometry.LineString([(7, 8), (10, 11)]), + ] + def test_force_2d_polygon() -> None: # 2d to 2d (no actual change) @@ -85,9 +89,7 @@ def test_force_2d_polygon() -> None: internal = [(0.5, 0.5), (0.5, 1.5), (1.5, 1.5), (1.5, 0.5), (0.5, 0.5)] p = geometry.Polygon(external, [internal]) p2d = factories.force_2d(p) - assert p2d.coords[0] == ( - ((0, 0), (0, 2), (2, 2), (2, 0), (0, 0)) - ) + assert p2d.coords[0] == (((0, 0), (0, 2), (2, 2), (2, 0), (0, 0))) assert p2d.coords[1] == ( ((0.5, 0.5), (0.5, 1.5), (1.5, 1.5), (1.5, 0.5), (0.5, 0.5)), ) @@ -96,13 +98,17 @@ def test_force_2d_polygon() -> None: # 3d to 2d external = [(0, 0, 1), (0, 2, 1), (2, 2, 1), (2, 0, 1), (0, 0, 1)] - internal = [(0.5, 0.5, 1), (0.5, 1.5, 1), (1.5, 1.5, 1), (1.5, 0.5, 1), (0.5, 0.5, 1)] + internal = [ + (0.5, 0.5, 1), + (0.5, 1.5, 1), + (1.5, 1.5, 1), + (1.5, 0.5, 1), + (0.5, 0.5, 1), + ] p = geometry.Polygon(external, [internal]) p2d = factories.force_2d(p) - assert p2d.coords[0] == ( - ((0, 0), (0, 2), (2, 2), (2, 0), (0, 0)) - ) + assert p2d.coords[0] == (((0, 0), (0, 2), (2, 2), (2, 0), (0, 0))) assert p2d.coords[1] == ( ((0.5, 0.5), (0.5, 1.5), (1.5, 1.5), (1.5, 0.5), (0.5, 0.5)), ) @@ -126,7 +132,9 @@ def test_force2d_collection() -> None: assert list(gc2d.geoms) == list(gc.geoms) # 3d to 2d - gc = geometry.GeometryCollection([geometry.Point(-1, 1, 0), geometry.Point(-2, 2, 0)]) + gc = geometry.GeometryCollection( + [geometry.Point(-1, 1, 0), geometry.Point(-2, 2, 0)], + ) gc2d = factories.force_2d(gc) assert list(gc2d.geoms) == [geometry.Point(-1, 1), geometry.Point(-2, 2)] From a654b38d5019d34b1f22208627987c359c4fbdc0 Mon Sep 17 00:00:00 2001 From: Alex Svetkin Date: Sat, 4 Nov 2023 18:41:55 +0100 Subject: [PATCH 3/5] introduced is_coordinate function --- pygeoif/functions.py | 25 +++++++++++++++++++------ tests/test_functions.py | 16 ++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/pygeoif/functions.py b/pygeoif/functions.py index 1d771285..034f669e 100644 --- a/pygeoif/functions.py +++ b/pygeoif/functions.py @@ -220,7 +220,7 @@ def move_coordinates( Move the coordinates recursively by the given vector. This forcefully changes the dimension of each of the coordinate to match - the dimension of the vector. + the dimensionality of the vector. >>> move_coordinates(((0, 0), (-1, 1)), (-1, 1)) ((-1, 1), (-2, 2)) >>> move_coordinates(((0, 0, 0), (-1, 1, 0)), (-1, 1)) @@ -228,15 +228,27 @@ def move_coordinates( >>> move_coordinates(((0, 0), (-1, 1)), (-1, 1, 0)) ((-1, 1, 0), (-2, 2, 0)) """ - if isinstance(coordinates, (tuple, list)) and isinstance( - coordinates[0], - (int, float), - ): - # coordinates is just a list of numbers, i.e. represents a single coordinate + if is_coordinate(coordinates): + # a single coordinate return move_coordinate(coordinates, move_by, z) + # a list of coordinates return tuple(move_coordinates(c, move_by, z) for c in coordinates) +def is_coordinate(val: Any) -> bool: # noqa: ANN401 + """ + Check if given value is a coordinate i.e. vector of generic dimensionality. + + >>> is_coordinate((1, 0)) + True + >>> is_coordinate(1) + False + >>> is_coordinate([(1, 2), (3, 4)]) + False + """ + return isinstance(val, tuple) and all(isinstance(x, (int, float)) for x in val) + + __all__ = [ "centroid", "compare_coordinates", @@ -245,5 +257,6 @@ def move_coordinates( "dedupe", "move_coordinate", "move_coordinates", + "is_coordinate", "signed_area", ] diff --git a/tests/test_functions.py b/tests/test_functions.py index e1441a22..8966e215 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -12,6 +12,7 @@ from pygeoif.functions import convex_hull from pygeoif.functions import dedupe from pygeoif.functions import signed_area +from pygeoif.functions import is_coordinate def circle_ish(x, y, r, steps): @@ -452,3 +453,18 @@ def test_compare_neq_empty_geo_interface() -> None: } assert compare_geo_interface(geo_if, {}) is False + + +def test_is_coordinate() -> None: + assert is_coordinate((1, 2)) is True + assert is_coordinate((1,)) is True + + +def test_is_coordinate_not_composite_coordinates() -> None: + assert is_coordinate([(1, 2)]) is False + assert is_coordinate(((1, 2), )) is False + assert is_coordinate((((1, 2), ), )) is False + + +def test_is_coordinate_not_primitive() -> None: + assert is_coordinate(1) is False From 8466fd998b5053079d9c641864b3bbadf7248ef3 Mon Sep 17 00:00:00 2001 From: Alex Svetkin Date: Sun, 5 Nov 2023 10:31:32 +0100 Subject: [PATCH 4/5] added force_3d factory --- pygeoif/factories.py | 35 +++++++++++++++++++++++++++++++++++ tests/test_factories.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/pygeoif/factories.py b/pygeoif/factories.py index 4f7da1b8..fc584213 100644 --- a/pygeoif/factories.py +++ b/pygeoif/factories.py @@ -91,6 +91,40 @@ def force_2d( return shape(geometry) +def force_3d( + context: Union[GeoType, GeoCollectionType], + z: float = 0, +) -> Union[Geometry, GeometryCollection]: + """ + Force the dimensionality of a geometry to 3D. + + >>> force_3d(Point(0, 0)) + Point(0, 0, 0) + >>> force_3d(Point(0, 0), 1) + Point(0, 0, 1) + >>> force_3d(Point(0, 0, 0)) + Point(0, 0, 0) + >>> force_3d(LineString([(0, 0), (0, 1), (1, 1)])) + LineString(((0, 0, 0), (0, 1, 0), (1, 1, 0))) + """ + geometry = context if isinstance(context, dict) else mapping(context) + if not geometry: + msg = "Object does not implement __geo_interface__" + raise TypeError(msg) + if geometry["type"] == "GeometryCollection": + return GeometryCollection( + force_3d(g, z) # type: ignore [arg-type] + for g in geometry["geometries"] # type: ignore [typeddict-item] + ) + + geometry["coordinates"] = move_coordinates( # type: ignore [typeddict-unknown-key] + geometry["coordinates"], # type: ignore [typeddict-item] + (0, 0, 0), + z, + ) + return shape(geometry) + + def get_oriented_ring(ring: LineType, ccw: bool) -> LineType: # noqa: FBT001 s = 1.0 if ccw else -1.0 return ring if signed_area(ring) / s >= 0 else ring[::-1] @@ -364,6 +398,7 @@ def mapping( __all__ = [ "force_2d", + "force_3d", "box", "from_wkt", "mapping", diff --git a/tests/test_factories.py b/tests/test_factories.py index 99b30b6c..71994ac1 100644 --- a/tests/test_factories.py +++ b/tests/test_factories.py @@ -143,6 +143,45 @@ def test_force_2d_nongeo() -> None: pytest.raises(AttributeError, factories.force_2d, (1, 2, 3)) +def test_force_3d_point() -> None: + p = geometry.Point(0, 0) + p3d = factories.force_3d(p) + assert p3d.x == 0 + assert p3d.y == 0 + assert p3d.z == 0 + assert p3d.has_z + + +def test_force_3d_collection() -> None: + gc = geometry.GeometryCollection( + [geometry.Point(-1, 1), geometry.Point(-2, 2)], + ) + gc3d = factories.force_3d(gc) + assert list(gc3d.geoms) == [geometry.Point(-1, 1, 0), geometry.Point(-2, 2, 0)] + + +def test_force_3d_point_with_z() -> None: + p = geometry.Point(0, 0, 1) + p3d = factories.force_3d(p) + assert p3d.x == 0 + assert p3d.y == 0 + assert p3d.z == 1 + assert p3d.has_z + + +def test_force_3d_point_noop() -> None: + p = geometry.Point(1, 2, 3) + p3d = factories.force_3d(p) + assert p3d.x == 1 + assert p3d.y == 2 + assert p3d.z == 3 + assert p3d.has_z + + +def test_force_3d_nongeo() -> None: + pytest.raises(AttributeError, factories.force_3d, (1, 2)) + + def test_orient_true() -> None: ext = [(0, 0), (0, 2), (2, 2), (2, 0), (0, 0)] int_1 = [(0.5, 0.25), (1.5, 0.25), (1.5, 1.25), (0.5, 1.25), (0.5, 0.25)] From a21390578637cc43b5bdf4a7cffb9b713eb81180 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 5 Nov 2023 09:49:23 +0000 Subject: [PATCH 5/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_functions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_functions.py b/tests/test_functions.py index 8966e215..3267ea66 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -11,8 +11,8 @@ from pygeoif.functions import compare_geo_interface from pygeoif.functions import convex_hull from pygeoif.functions import dedupe -from pygeoif.functions import signed_area from pygeoif.functions import is_coordinate +from pygeoif.functions import signed_area def circle_ish(x, y, r, steps): @@ -462,8 +462,8 @@ def test_is_coordinate() -> None: def test_is_coordinate_not_composite_coordinates() -> None: assert is_coordinate([(1, 2)]) is False - assert is_coordinate(((1, 2), )) is False - assert is_coordinate((((1, 2), ), )) is False + assert is_coordinate(((1, 2),)) is False + assert is_coordinate((((1, 2),),)) is False def test_is_coordinate_not_primitive() -> None: