Source code for oowl.boundaries.hull

"""Hull boundaries of random point clouds."""

from __future__ import annotations

import math

import numpy as np
import shapely
from shapely.geometry import MultiPoint, Polygon
from shapely.geometry.base import BaseGeometry

from .._random import as_rng
from .._validation import _validate_integer, _validate_real
from .base import Boundary, Point2D, _optional_area, _resolve_center

__all__ = ["ConvexHull"]


def _validate_concavity(concavity: float | None) -> float | None:
    """Return a usable ``concave_hull`` ratio, or ``None`` for a convex hull."""
    if concavity is None:
        return None
    value = _validate_real("concavity", concavity)
    if not math.isfinite(value):
        raise ValueError("concavity must be a finite number in (0, 1], or None")
    if not 0.0 < value <= 1.0:
        raise ValueError(f"concavity must be in (0, 1], or None; got {value}")
    return value


[docs] class ConvexHull(Boundary): """Boundary as the hull of random scattered points, convex or concave. The default ``concavity=None`` gives the exact convex hull. A value in ``(0, 1]`` is Shapely's ``concave_hull`` ratio. Smaller values wrap the points more tightly and ``1.0`` reproduces the convex hull. The shape is always scaled to ``area``. Parameters ---------- area: Realized enclosed area in square meters, ``None`` for a unit-size shape to size later. n_points: Number of scattered points to draw, at least 3. A concave hull needs a dense cloud. center: Centroid of the realized boundary. concavity: ``None`` for a convex hull, or a ratio in ``(0, 1]`` for a concave one. seed: Seed or generator for the scattered points, drawn from a standard normal distribution before the hull is moved to ``center``. Raises ------ ValueError If ``concavity`` is outside ``(0, 1]`` or not finite, or if the point set admits no valid hull with positive area at that ratio. """ def __init__( self, *, area: float | None = None, n_points: int = 20, center: Point2D | None = None, concavity: float | None = None, seed: int | np.random.Generator | None = None, ): self.n_points = _validate_integer("n_points", n_points, minimum=3) self.target_area = _optional_area(area) self.center, self._positioned = _resolve_center(center) self.concavity = _validate_concavity(concavity) self.seed = self._resolve_own_seed(seed) def _build_geometry(self) -> BaseGeometry: rng = as_rng(self.seed) pts = rng.standard_normal((self.n_points, 2)) cloud = MultiPoint(pts) if self.concavity is None: hull = cloud.convex_hull if not isinstance(hull, Polygon): raise ValueError( "convex hull did not produce a polygon; add more points" ) return hull # Reject degenerate concave hulls instead of falling back to convex. hull = shapely.concave_hull(cloud, ratio=self.concavity) if not isinstance(hull, Polygon) or hull.is_empty or hull.area <= 0.0: raise ValueError( f"concave hull at concavity={self.concavity} is degenerate; " "raise concavity or add more points" ) if not hull.is_valid: raise ValueError( f"concave hull at concavity={self.concavity} is not a valid " "polygon; raise concavity or add more points" ) return hull