diff --git a/pygeoif/factories.py b/pygeoif/factories.py index e7d762f8..fc584213 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,70 @@ 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 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] @@ -332,6 +397,8 @@ def mapping( __all__ = [ + "force_2d", + "force_3d", "box", "from_wkt", "mapping", diff --git a/pygeoif/functions.py b/pygeoif/functions.py index f3e82f34..034f669e 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,73 @@ 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 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)) + ((-1, 1), (-2, 2)) + >>> move_coordinates(((0, 0), (-1, 1)), (-1, 1, 0)) + ((-1, 1, 0), (-2, 2, 0)) + """ + 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", "compare_geo_interface", "convex_hull", "dedupe", + "move_coordinate", + "move_coordinates", + "is_coordinate", "signed_area", ] diff --git a/tests/test_factories.py b/tests/test_factories.py index 96235841..71994ac1 100644 --- a/tests/test_factories.py +++ b/tests/test_factories.py @@ -21,6 +21,167 @@ 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_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)] diff --git a/tests/test_functions.py b/tests/test_functions.py index e1441a22..3267ea66 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -11,6 +11,7 @@ from pygeoif.functions import compare_geo_interface from pygeoif.functions import convex_hull from pygeoif.functions import dedupe +from pygeoif.functions import is_coordinate from pygeoif.functions import signed_area @@ -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