"""Star-shaped boundaries described by a radius at each angle."""
from __future__ import annotations
from typing import Sequence
import numpy as np
from shapely.geometry import Polygon
from shapely.geometry.base import BaseGeometry
from .._random import as_rng
from .._validation import (
_validate_integer,
_validate_nonnegative,
_validate_real,
_validate_real_array,
)
from .base import Boundary, Point2D, _optional_area, _resolve_center
__all__ = ["RadialPolygon", "RandomRadialPolygon", "SmoothBlob"]
[docs]
class RadialPolygon(Boundary):
"""Star-shaped polygon given by the radius and angular step of each vertex.
Vertex ``i`` of the generating shape lies at distance ``radii[i]`` from
its origin and at the cumulative sum of ``angular_offsets`` up to ``i``, in
degrees. The offsets are positive and default to equal steps. ``radii`` set
only the shape, since ``area`` sets the size, and ``center`` places its
centroid, which need not be that origin.
"""
def __init__(
self,
radii: Sequence[float],
angular_offsets: Sequence[float] | None = None,
*,
area: float | None = None,
center: Point2D | None = None,
):
radius_array = _validate_real_array("radii", radii)
if radius_array.ndim != 1 or radius_array.size < 3:
raise ValueError("radii must be a one-dimensional sequence of length >= 3")
if not np.all(np.isfinite(radius_array)) or np.any(radius_array <= 0):
raise ValueError("radii must be finite and > 0")
if angular_offsets is None:
offsets = np.full(radius_array.size, 360.0 / radius_array.size)
else:
offsets = _validate_real_array("angular_offsets", angular_offsets)
if offsets.shape != radius_array.shape:
raise ValueError("angular_offsets must contain one value per radius")
if not np.all(np.isfinite(offsets)) or np.any(offsets <= 0):
raise ValueError("angular_offsets must be finite and > 0")
self.radii = radius_array
self.angular_offsets = offsets
self.target_area = _optional_area(area)
self.center, self._positioned = _resolve_center(center)
def _build_geometry(self) -> BaseGeometry:
angles = np.deg2rad(np.cumsum(self.angular_offsets))
x = self.radii * np.cos(angles)
y = self.radii * np.sin(angles)
return Polygon(np.column_stack([x, y]))
[docs]
class RandomRadialPolygon(Boundary):
"""Random star-shaped polygon jittered from a regular ``n_vertices``-gon.
Each unit radius is perturbed uniformly by up to ``radius_jitter`` and each
equal angular step by up to the fraction ``angular_jitter``, before the
steps are rescaled to a full turn. Radii stay positive.
"""
def __init__(
self,
n_vertices: int = 8,
*,
area: float | None = None,
radius_jitter: float = 0.35,
angular_jitter: float = 0.5,
center: Point2D | None = None,
seed: int | np.random.Generator | None = None,
):
self.n_vertices = _validate_integer("n_vertices", n_vertices, minimum=3)
self.target_area = _optional_area(area)
self.radius_jitter = _validate_nonnegative("radius_jitter", radius_jitter)
angular_jitter = _validate_real("angular_jitter", angular_jitter)
if not np.isfinite(angular_jitter) or not 0 <= angular_jitter < 1:
raise ValueError(
"angular_jitter must be finite and satisfy 0 <= angular_jitter < 1"
)
self.angular_jitter = angular_jitter
self.center, self._positioned = _resolve_center(center)
self.seed = self._resolve_own_seed(seed)
def _build_geometry(self) -> BaseGeometry:
rng = as_rng(self.seed)
n = self.n_vertices
base_step = 2.0 * np.pi / n
steps = base_step * (1.0 + self.angular_jitter * (2.0 * rng.random(n) - 1.0))
steps = steps / steps.sum() * 2.0 * np.pi
radii = 1.0 + self.radius_jitter * (2.0 * rng.random(n) - 1.0)
radii = np.clip(radii, 1e-6, None)
angles = np.cumsum(steps)
x = radii * np.cos(angles)
y = radii * np.sin(angles)
return Polygon(np.column_stack([x, y]))
[docs]
class SmoothBlob(Boundary):
r"""Random smooth outline from a low-frequency Fourier radius function.
The radius at ``n_segments`` equally spaced angles is
.. math::
r(\theta) = 1 + \sum_{k=1}^{K} \frac{\rho\,\xi_k}{k}\sin(k\theta + \phi_k)
with ``K = n_harmonics``, ``roughness`` :math:`\rho`, standard-normal
:math:`\xi_k` and uniform phases :math:`\phi_k`. The radius is clipped at
0.1 to keep it positive.
"""
def __init__(
self,
*,
area: float | None = None,
n_segments: int = 200,
roughness: float = 0.3,
n_harmonics: int = 5,
center: Point2D | None = None,
seed: int | np.random.Generator | None = None,
):
self.target_area = _optional_area(area)
self.n_segments = _validate_integer("n_segments", n_segments, minimum=4)
self.roughness = _validate_nonnegative("roughness", roughness)
self.n_harmonics = _validate_integer("n_harmonics", n_harmonics)
self.center, self._positioned = _resolve_center(center)
self.seed = self._resolve_own_seed(seed)
def _build_geometry(self) -> BaseGeometry:
rng = as_rng(self.seed)
theta = np.linspace(0.0, 2.0 * np.pi, self.n_segments, endpoint=False)
r = np.ones_like(theta)
for k in range(1, self.n_harmonics + 1):
amp = self.roughness * rng.normal() / k
phase = rng.uniform(0.0, 2.0 * np.pi)
r += amp * np.sin(k * theta + phase)
r = np.clip(r, 0.1, None)
x = r * np.cos(theta)
y = r * np.sin(theta)
return Polygon(np.column_stack([x, y]))