Repository: PedestrianDynamics/PedPy

Issue:
Create WalkableArea from GeometryCollection
Currently `WalkableArea`s can only be created from `shapely.Polygon`-types and their wkt representation. As JuPedSim uses `shapely.GeometryCollection` as their geometry exchange format, including such types, would make the usage in simulation contexts easier.

**GeometryCollection condition:**

- contains only polygons
- union of all contained polygons must be a single, simple polygon with a non-zero area
- `WalkableArea` is the union of all contained polygons

**ToDos:**

- allow passing of `shapely.GeometryCollection` to `WalkableArea()`
- allow parsing of `shapely.GeometryCollection` in wkt representation



Discussion:

Patch:
diff --git a/pedpy/data/geometry.py b/pedpy/data/geometry.py
index dcedfbc..0541438 100644
--- a/pedpy/data/geometry.py
+++ b/pedpy/data/geometry.py
@@ -1,7 +1,8 @@
 """Module handling the geometrical environment of the analysis."""
 
 from dataclasses import dataclass
-from typing import Any, List, Optional
+from numbers import Number
+from typing import Any, List, Optional, Tuple
 
 import shapely
 
@@ -37,7 +38,7 @@ class WalkableArea:
         polygon: Any,
         obstacles: Optional[Any] = None,
     ):
-        """Creates a measurement area.
+        """Creates a walkable area.
 
         Args:
             polygon: A sequence of (x, y) numeric coordinate pairs, or
@@ -145,7 +146,7 @@ class MeasurementArea:
         except Exception as exc:
             raise GeometryError(
                 f"Could not create measurement area from the given "
-                f"coordinates: {exc}."
+                f"input: {exc}."
             ) from exc
 
         if self._polygon.interiors:
@@ -312,6 +313,110 @@ class MeasurementLine:
 ###############################################################################
 # Helper functions
 ###############################################################################
+def _polygon_from_wkt(wkt_input: str) -> shapely.Polygon:
+    try:
+        wkt_type = shapely.from_wkt(wkt_input)
+    except Exception as exc:
+        raise GeometryError(
+            f"Could not create geometry objects from the given WKT: "
+            f"{wkt_input}. See following error message:\n{exc}"
+        ) from exc
+
+    try:
+        polygon = _polygon_from_shapely(wkt_type)
+    except Exception as exc:
+        raise GeometryError(
+            f"Could not create a geometry collection from the given WKT: "
+            f"{wkt_input}. See following error message:\n{exc}"
+        ) from exc
+
+    return polygon
+
+
+def _polygon_from_shapely(
+    geometry_input: shapely.Polygon
+    | shapely.MultiPolygon
+    | shapely.GeometryCollection
+    | shapely.MultiPoint,
+) -> shapely.Polygon:
+    def _polygons_from_multi_polygon(
+        multi_polygon: shapely.MultiPolygon,
+    ) -> List[shapely.Polygon]:
+        result = []
+        for polygon in multi_polygon.geoms:
+            result += _polygons_from_polygon(polygon)
+        return result
+
+    def _polygons_from_linear_ring(
+        linear_ring: shapely.LinearRing,
+    ) -> List[shapely.Polygon]:
+        return _polygons_from_polygon(shapely.Polygon(linear_ring))
+
+    def _polygons_from_polygon(
+        polygon: shapely.Polygon,
+    ) -> List[shapely.Polygon]:
+        return [polygon]
+
+    def _polygons_from_geometry_collection(
+        geometry_collection: shapely.GeometryCollection,
+    ) -> List[shapely.Polygon]:
+        polygons = []
+        for geo in geometry_collection.geoms:
+            if (
+                shapely.get_type_id(geo)
+                == shapely.GeometryType.GEOMETRYCOLLECTION
+            ):
+                polygons += _polygons_from_geometry_collection(geo)
+            elif shapely.get_type_id(geo) == shapely.GeometryType.MULTIPOLYGON:
+                polygons += _polygons_from_multi_polygon(geo)
+            elif shapely.get_type_id(geo) == shapely.GeometryType.LINEARRING:
+                polygons += _polygons_from_linear_ring(geo)
+            elif shapely.get_type_id(geo) == shapely.GeometryType.POLYGON:
+                polygons += _polygons_from_polygon(geo)
+            else:
+                raise GeometryError(
+                    f"Unexpected geometry type found in GeometryCollection: "
+                    f"{geo.geom_type}. Only Polygon types are allowed."
+                )
+        return polygons
+
+    polygons = []
+    if (
+        shapely.get_type_id(geometry_input)
+        == shapely.GeometryType.GEOMETRYCOLLECTION
+    ):
+        polygons += _polygons_from_geometry_collection(geometry_input)
+    elif (
+        shapely.get_type_id(geometry_input) == shapely.GeometryType.MULTIPOLYGON
+    ):
+        polygons += _polygons_from_multi_polygon(geometry_input)
+    elif shapely.get_type_id(geometry_input) == shapely.GeometryType.LINEARRING:
+        polygons += _polygons_from_linear_ring(geometry_input)
+    elif shapely.get_type_id(geometry_input) == shapely.GeometryType.POLYGON:
+        polygons += _polygons_from_polygon(geometry_input)
+    else:
+        raise GeometryError(
+            f"Unexpected geometry type found in GeometryCollection: "
+            f"{geometry_input.geom_type}. Only Polygon types are allowed."
+        )
+
+    polygon = shapely.union_all(polygons)
+    if not isinstance(polygon, shapely.Polygon):
+        raise GeometryError(
+            "Input can not be combined to one single polygon. "
+            "Make sure, that parts of the input are connected."
+        )
+    return polygon
+
+
+def _polygon_from_coordinates(
+    coordinates: List[Tuple[Number]] | shapely.Point,
+    *,
+    holes: Optional[List[Tuple[Number]] | shapely.Point] = None,
+) -> shapely.Polygon:
+    return shapely.Polygon(coordinates, holes=holes)
+
+
 def _create_polygon_from_input(
     polygon_input: Any, holes: Optional[List[Any]] = None
 ) -> shapely.Polygon:
@@ -332,35 +437,35 @@ def _create_polygon_from_input(
     Returns:
         shapely.Polygon constructed by the input
     """
-    return_poly = None
-    if isinstance(polygon_input, shapely.Polygon):
+    if isinstance(
+        polygon_input,
+        (
+            shapely.GeometryCollection,
+            shapely.MultiPoint,
+            shapely.MultiPolygon,
+            shapely.Polygon,
+        ),
+    ):
         if holes is not None:
             raise GeometryError(
-                "If polygon is of type shapely.Polygon additional holes are not allowed"
+                "If polygon is of type shapely.Polygon additional holes are not allowed."
             )
-        return_poly = polygon_input
+        return_poly = _polygon_from_shapely(polygon_input)
     elif isinstance(polygon_input, str):
         if holes is not None:
             raise GeometryError(
-                "If polygon is of type WKT additional holes are not allowed"
+                "If polygon is of type WKT additional holes are not allowed."
             )
         try:
-            wkt_type = shapely.from_wkt(polygon_input)
+            return_poly = _polygon_from_wkt(polygon_input)
         except Exception as exc:
             raise GeometryError(
                 f"Could not create polygon from the given WKT: {polygon_input}."
                 f" See following error message:\n{exc}"
             ) from exc
-
-        if isinstance(wkt_type, shapely.Polygon):
-            return_poly = wkt_type
-        else:
-            raise GeometryError(
-                f"Could not create a polygon from the given WKT: {polygon_input}"
-            )
     else:
         try:
-            return_poly = shapely.Polygon(polygon_input, holes)
+            return_poly = _polygon_from_coordinates(polygon_input, holes=holes)
         except Exception as exc:
             raise GeometryError(
                 f"Could not create polygon from the given input: {polygon_input}."
@@ -372,4 +477,5 @@ def _create_polygon_from_input(
             "self-intersections area only allowed at boundary points."
         )
 
+    assert isinstance(return_poly, shapely.Polygon)
     return return_poly


Test patch:
diff --git a/tests/unit_tests/data/test_geometry.py b/tests/unit_tests/data/test_geometry.py
index 6225589..9d51b00 100644
--- a/tests/unit_tests/data/test_geometry.py
+++ b/tests/unit_tests/data/test_geometry.py
@@ -180,6 +180,49 @@ def test_create_walkable_area_from_polygon(reference_polygon):
     assert shapely.is_prepared(walkable_area.polygon)
 
 
+@pytest.mark.parametrize(
+    "geometry_collection",
+    [
+        shapely.GeometryCollection(
+            shapely.Polygon(
+                [(0, 3.1), (1.2, 5.4), (4.1, 7.9), (7.1, 3.0), (0, 3.1)],
+            )
+        ),
+        shapely.GeometryCollection(
+            [
+                shapely.Polygon(
+                    [(-1, 3), (-1, -7), (-13, -2), (-5, 3), (-1, 3)],
+                    [[(-6, 0), (-8, -2), (-5, -2)]],
+                ),
+                shapely.Polygon([(-1, 3), (11, 3), (11, -5), (-1, -7)]),
+            ]
+        ),
+        shapely.GeometryCollection(
+            shapely.GeometryCollection(
+                [
+                    shapely.Polygon(
+                        [(-1, 3), (-1, -7), (-13, -2), (-5, 3), (-1, 3)],
+                        [[(-6, 0), (-8, -2), (-5, -2)]],
+                    ),
+                    shapely.Polygon([(-1, 3), (11, 3), (11, -5), (-1, -7)]),
+                ]
+            )
+        ),
+    ],
+)
+def test_create_walkable_area_from_geometry_collection(geometry_collection):
+    reference_polygon = shapely.union_all(geometry_collection)
+
+    walkable_area = WalkableArea(geometry_collection)
+
+    assert walkable_area.polygon.equals_exact(
+        reference_polygon, tolerance=1e-20
+    )
+    assert walkable_area.area != 0
+    assert walkable_area.polygon.is_simple
+    assert shapely.is_prepared(walkable_area.polygon)
+
+
 @pytest.mark.parametrize(
     "wkt",
     [
@@ -216,12 +259,34 @@ def test_create_walkable_area_from_polygon(reference_polygon):
                 [(-1.1, -2.1), (-2.3, 3.1), (2.5, 4.2), (1.2, -3.4), (0.1, 1.1)]
             ),
         ),
+        shapely.to_wkt(
+            shapely.GeometryCollection(
+                shapely.Polygon(
+                    [
+                        (2.4, -1.3),
+                        (4.1, -5),
+                        (3, -7.2),
+                        (-1.3, -10.1),
+                        (-2, -3.1),
+                        (2.4, -1.3),
+                    ],
+                    [
+                        [(3.1, -3.5), (1.7, -2.3), (0.9, -4.1)],
+                        [(-1.2, -3.9), (-0.8, -6.1), (0.1, -5.9), (0.2, -4.3)],
+                        [(0.7, -7.5), (2.1, -6.5), (2.5, -4.7)],
+                    ],
+                )
+            )
+        ),
     ],
 )
 def test_create_walkable_area_from_wkt(wkt):
     reference_polygon = shapely.from_wkt(wkt)
     walkable_area = WalkableArea(wkt)
 
+    if isinstance(reference_polygon, shapely.GeometryCollection):
+        reference_polygon = shapely.union_all(reference_polygon)
+
     assert walkable_area.polygon.equals_exact(
         reference_polygon, tolerance=1e-20
     )
@@ -437,7 +502,7 @@ def test_create_measurement_area_from_wkt(wkt):
             [
                 shapely.Point(2.4, -1.3),
             ],
-            "Could not create measurement area from the given coordinates:",
+            "Could not create measurement area from the given input:",
         ),
         (
             shapely.Polygon(
@@ -513,7 +578,7 @@ def test_create_measurement_area_from_wkt(wkt):
         ),
         (
             shapely.to_wkt(shapely.LineString([(0.0, 3.0), (1.0, -10.0)])),
-            "Could not create a polygon from the given WKT",
+            "Unexpected geometry type found in GeometryCollection",
         ),
     ],
 )
@@ -865,7 +930,7 @@ def test_create_polygon_from_wkt(wkt):
         (
             shapely.LineString([(0, 0), (1, 1)]).wkt,
             None,
-            "Could not create a polygon from the given WKT",
+            "Could not create polygon from the given WKT",
         ),
         (
             shapely.Polygon(