Source code for rapidtools.core.polygon_region

# Copyright (c) 2025 The University of Washington
#
# This file is part of rapidtools.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# You should have received a copy of the BSD 3-Clause License along with
# rapidtools. If not, see <http://www.opensource.org/licenses/>.
#
# Contributors:
# Barbaros Cetiner
#
# Last updated:
# 09-22-2026

"""
Arbitrary polygonal regions.

:class:`PolygonRegion` wraps a Shapely polygon so irregular study areas can be
used wherever a :class:`~rapidtools.core.Region` is expected.

Example:
    >>> from rapidtools.core import PolygonRegion
    >>> region = PolygonRegion([(0, 0), (4, 0), (4, 3), (0, 3)])
    >>> region.get_bounding_box().bounds
    (0.0, 0.0, 4.0, 3.0)
"""

from __future__ import annotations

from collections.abc import Iterable
from typing import Self

from shapely.geometry import Polygon
from shapely.geometry.base import BaseGeometry

from .bounding_box import BoundingBox
from .region import Region


[docs] class PolygonRegion(Region): """ Represents a geographical region defined by a single polygon shell. This class wraps a Shapely Polygon and provides initialization from explicit coordinates. Use ``from_geometry`` to wrap existing Shapely objects. Example: Define a triangle using (x, y) coordinates: >>> from rapidtools.core import PolygonRegion >>> >>> coords = [(0, 0), (10, 0), (0, 10)] >>> region = PolygonRegion(coords) >>> region.area 50.0 """ def __init__(self, shell: Iterable[tuple[float, float]]): """ Initialize the PolygonRegion from a list of coordinates. Args: shell: A list (or iterable) of (x, y) coordinates defining the outer edge of the polygon. Raises: ValueError: If the shell has fewer than 3 vertices. Example: Create a generic square ``PolygonRegion``: >>> from rapidtools.core import PolygonRegion >>> >>> coords = [(0, 0), (1, 0), (1, 1), (0, 1)] >>> region = PolygonRegion(coords) >>> region.is_valid True """ # Convert to list to check length safely: coords = list(shell) if len(coords) < 3: raise ValueError('A polygon must have at least 3 vertices.') # Initialize the geometry from coordinates: self._geom = Polygon(shell=coords)
[docs] @classmethod def from_geometry(cls, geometry: BaseGeometry) -> Self: """ Create a PolygonRegion from a Shapely geometry. Args: geometry (BaseGeometry): A Shapely Polygon. Returns: PolygonRegion: The wrapped region. Raises: TypeError: If the geometry is not a Polygon. Example: >>> from shapely.geometry import Polygon >>> from rapidtools.core import PolygonRegion >>> >>> raw_poly = Polygon([(0, 0), (5, 0), (5, 5)]) >>> region = PolygonRegion.from_geometry(raw_poly) >>> region.area 12.5 """ if not isinstance(geometry, Polygon): raise TypeError( f'PolygonRegion requires a Polygon, got {type(geometry).__name__}' ) instance = cls.__new__(cls) instance._geom = geometry return instance
[docs] def buffer(self, distance: float) -> PolygonRegion: """ Create a new Region expanded (or shrunk) by a constant distance. Args: distance (float): The distance to buffer. Positive expands, negative erodes. Returns: PolygonRegion: A new PolygonRegion representing the result. Example: Initialize a ``PolygonRegion`` representing a 10x10 square: >>> from shapely.geometry import Polygon >>> from rapidtools.core import PolygonRegion >>> >>> region = PolygonRegion([(0, 0), (10, 0), (10, 10), (0, 10)]) >>> region.area 100.0 Apply a positive buffer to expand the region: >>> expanded = region.buffer(1.0) >>> expanded.area > 100.0 True Apply a negative buffer to erode (shrink) the region: >>> shrunk = region.buffer(-1.0) >>> shrunk.area < 100.0 True """ buffered_geom = self._geom.buffer(distance) # Handle empty results (e.g., shrinking a small polygon to nothing): if buffered_geom.is_empty: # Create an empty instance return PolygonRegion.from_geometry(Polygon()) # If result is a Polygon, return a PolygonRegion: if isinstance(buffered_geom, Polygon): return PolygonRegion.from_geometry(buffered_geom) # Handle complex results (MultiPolygon, GeometryCollection, etc.): raise NotImplementedError( f'Buffering resulted in {type(buffered_geom).__name__}, which is ' 'not supported by PolygonRegion (requires single Polygon).' )
[docs] def get_bounding_box(self) -> BoundingBox: """ Calculate the smallest bounding box that encloses the polygon. Returns: BoundingBox: A new BoundingBox object representing the calculated bounds. Example: Initialize a triangular ``PolygonRegion`` and retrieve its enclosing ``BoundingBox``: >>> from rapidtools.core import PolygonRegion >>> >>> region = PolygonRegion([(0, 0), (2, 5), (4, 0)]) >>> bbox = region.get_bounding_box() >>> bbox.bounds (0.0, 0.0, 4.0, 5.0) """ min_x, min_y, max_x, max_y = self.bounds return BoundingBox(min_x, min_y, max_x, max_y)