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)