Source code for oowl.boundaries.polygon

"""User-supplied polygonal boundaries."""

from __future__ import annotations

from collections.abc import Sequence
from typing import TYPE_CHECKING

import numpy as np
from shapely.geometry import MultiPolygon
from shapely.geometry import Polygon as _ShapelyPolygon
from shapely.geometry.base import BaseGeometry

from .._repr import _describe
from .._validation import _validate_real_array
from .base import Boundary, Point2D, _resolve_center, _validate_target_area

if TYPE_CHECKING:  # pragma: no cover
    from numpy.typing import ArrayLike

__all__ = ["Polygon"]


def _coordinates(name: str, values) -> np.ndarray:
    """Return ``values`` as a finite ``(N, 2)`` ring of at least three points."""
    coordinates = _validate_real_array(name, values)
    if coordinates.ndim != 2 or coordinates.shape[1] != 2:
        raise ValueError(f"{name} must have shape (N, 2)")
    if len(coordinates) < 3:
        raise ValueError(f"{name} must contain at least three points")
    if not np.all(np.isfinite(coordinates)):
        raise ValueError(f"{name} must contain only finite coordinates")
    return coordinates


[docs] class Polygon(Boundary): """Boundary given by explicit vertices or an existing Shapely geometry. ``exterior`` and each of ``holes`` are ``(N, 2)`` rings in meters, kept as given unless ``area`` or ``center`` is passed to rescale or move the shape. Invalid geometry is never repaired, and building one raises ``ValueError`` naming what is wrong. A non-numeric ``center`` raises ``TypeError``, and one other than two finite coordinates ``ValueError``. """ # The rings are given in meters, so the shape is sized as given. _unit_shape = False def __init__( self, exterior: ArrayLike, holes: Sequence | None = None, *, area: float | None = None, center: Point2D | None = None, ): exterior = _coordinates("exterior", exterior) holes = [] if holes is None else list(holes) self.exterior = exterior self.holes = tuple( _coordinates(f"holes[{index}]", hole) for index, hole in enumerate(holes) ) self.target_area = None if area is None else _validate_target_area(area) # Validate without normalizing, so the stored value and repr stay as given. _resolve_center(center) self.center = center self._positioned = True self._source_geometry = None # The rings are known, so a degenerate or invalid shape is refused now # rather than on the first draw, where a sampler would retry it. self._unscaled # noqa: B018
[docs] @classmethod def from_geometry( cls, geometry: _ShapelyPolygon | MultiPolygon, *, area: float | None = None, center: Point2D | None = None, ) -> "Polygon": """Construct from a Shapely ``Polygon`` or ``MultiPolygon``.""" if not isinstance(geometry, (_ShapelyPolygon, MultiPolygon)): raise TypeError("geometry must be a Polygon or MultiPolygon") # GIS outlines often hold one polygon as a MultiPolygon, which every # layout treats as that polygon. if isinstance(geometry, MultiPolygon) and len(geometry.geoms) == 1: geometry = geometry.geoms[0] instance = cls.__new__(cls) instance.exterior = None instance.holes = () instance.target_area = None if area is None else _validate_target_area(area) _resolve_center(center) instance.center = center instance._positioned = True instance._source_geometry = geometry # The shape is known, so a degenerate or invalid one is refused now. instance._unscaled # noqa: B018 return instance
def __repr__(self) -> str: if self._source_geometry is None: return super().__repr__() parts = [ _describe(self._source_geometry), f"area={self.target_area!r}", f"center={_describe(self.center)}", ] return f"Polygon.from_geometry({', '.join(parts)})" def _build_geometry(self) -> BaseGeometry: if self._source_geometry is not None: return self._source_geometry return _ShapelyPolygon(self.exterior, holes=self.holes)