"""Compositions of string guides and modifier chains."""
from __future__ import annotations
from collections.abc import Iterable, Sequence
import numpy as np
from shapely.affinity import rotate as shp_rotate
from shapely.affinity import translate as shp_translate
from shapely.geometry import MultiLineString
from .._random import as_rng, resolve_seed, spawn_seeds
from .._validation import _validate_integer, _validate_nonnegative, _validate_positive
from .base import StringGuide, _LogicalString
from .modifiers import StringModifier
from .primitives import Organic
__all__ = ["Composite", "Multi", "Parallel", "Scattered"]
[docs]
class Composite(StringGuide):
"""A base guide with an ordered chain of geometry modifiers."""
def __init__(self, base: StringGuide, modifiers: Iterable[StringModifier]):
if not isinstance(base, StringGuide):
raise TypeError("base must be a StringGuide")
modifiers = tuple(modifiers)
for modifier in modifiers:
if not isinstance(modifier, StringModifier):
raise TypeError("string modifiers must be StringModifier objects")
if isinstance(base, Composite):
self.base = base.base
self.modifiers = base.modifiers + modifiers
else:
self.base = base
self.modifiers = modifiers
strings = self.base._strings
for modifier in self.modifiers:
strings = modifier.apply(strings)
super().__init__(strings)
self._pinned_seed = self.base._pinned_seed and all(
modifier._pinned_seed for modifier in self.modifiers
)
def _reseeded(self, seed: int) -> "Composite":
if self._pinned_seed:
return self
seeds = spawn_seeds(seed, 1 + len(self.modifiers))
return Composite(
self.base._reseeded(seeds[0]),
[m._reseeded(s) for m, s in zip(self.modifiers, seeds[1:])],
)
[docs]
def modify(self, *modifiers: StringModifier) -> Composite:
"""Return a new composite with ``modifiers`` appended to this chain."""
return Composite(self.base, self.modifiers + tuple(modifiers))
def __repr__(self) -> str:
return f"{self.base!r}.modify({', '.join(map(repr, self.modifiers))})"
[docs]
class Multi(StringGuide):
"""Several guides combined into one guide for one farm."""
def __init__(self, strings: Sequence[StringGuide]):
strings = tuple(strings)
if not strings:
raise ValueError("strings must contain at least one guide")
for index, guide in enumerate(strings):
if not isinstance(guide, StringGuide):
raise TypeError(
f"string {index} must be a StringGuide, not {type(guide).__name__}"
)
super().__init__(
tuple(
logical_string for guide in strings for logical_string in guide._strings
)
)
self._guides = strings
self._pinned_seed = all(guide._pinned_seed for guide in strings)
def _reseeded(self, seed: int) -> "Multi":
if self._pinned_seed:
return self
seeds = spawn_seeds(seed, len(self._guides))
return Multi([g._reseeded(s) for g, s in zip(self._guides, seeds)])
[docs]
class Parallel(StringGuide):
"""Parallel copies of one logical string, centered on the original.
Neighboring copies sit ``relative_spacing`` times the string length apart,
normal to its chord, and each gap varies uniformly by up to
``spacing_jitter`` of itself. Each copy shifts along the chord by up to
``longitudinal_jitter`` times the length and turns about the string's
centroid by a normal angle with ``rotation_spread`` degrees of standard
deviation.
"""
def __init__(
self,
string: StringGuide,
n_strings: int,
*,
relative_spacing: float = 0.2,
rotation_spread: float = 0.0,
longitudinal_jitter: float = 0.0,
spacing_jitter: float = 0.0,
seed: int | np.random.Generator | None = None,
):
if not isinstance(string, StringGuide):
raise TypeError("string must be a StringGuide")
if string.n_strings != 1:
raise ValueError("Parallel requires a single logical string guide")
n_strings = _validate_integer("n_strings", n_strings)
relative_spacing = _validate_positive("relative_spacing", relative_spacing)
rotation_spread = _validate_nonnegative("rotation_spread", rotation_spread)
longitudinal_jitter = _validate_nonnegative(
"longitudinal_jitter", longitudinal_jitter
)
spacing_jitter = _validate_nonnegative("spacing_jitter", spacing_jitter)
if spacing_jitter >= 1:
raise ValueError("spacing_jitter must be between 0 and 1")
self._seed_given = seed is not None
self._pinned_seed = self._seed_given and string._pinned_seed
self.seed = resolve_seed(seed)
rng = as_rng(self.seed)
logical = string._strings[0]
first = np.asarray(logical.segments[0].coords[0], dtype=float)
last = np.asarray(logical.segments[-1].coords[-1], dtype=float)
chord = last - first
norm = float(np.linalg.norm(chord))
if norm == 0:
raise ValueError("parallel guide requires distinct endpoints")
normal = np.array([-chord[1], chord[0]]) / norm
step = relative_spacing * logical.length
gaps = step * (1 + rng.uniform(-spacing_jitter, spacing_jitter, n_strings - 1))
normal_offsets = np.concatenate([[0.0], np.cumsum(gaps)])
normal_offsets -= normal_offsets.mean()
rotation_origin = MultiLineString(
[segment.coords for segment in logical.segments]
).centroid.coords[0]
strings = []
for index in range(int(n_strings)):
along = rng.uniform(-longitudinal_jitter, longitudinal_jitter)
offset = (
normal_offsets[index] * normal + along * logical.length * chord / norm
)
angle = rng.normal(scale=rotation_spread)
segments = tuple(
shp_translate(
shp_rotate(
segment,
angle,
origin=rotation_origin,
use_radians=False,
),
xoff=offset[0],
yoff=offset[1],
)
for segment in logical.segments
)
strings.append(_LogicalString(logical.guide_type, segments))
super().__init__(tuple(strings))
def _reseeded(self, seed: int) -> "Parallel":
if self._pinned_seed:
return self
string_seed, own_seed = spawn_seeds(seed, 2)
arguments = self._arguments()
arguments["string"] = arguments["string"]._reseeded(string_seed)
if not self._seed_given:
arguments["seed"] = own_seed
return Parallel(**arguments)
[docs]
class Scattered(StringGuide):
"""Randomly oriented organic strings scattered without touching.
Each of ``n_strings`` :class:`~oowl.strings.Organic` strings, with the
given ``roughness`` and ``n_harmonics``, is ``length`` times a uniform
factor within ``1 ± length_variation``, and keeps at least
``relative_spacing * length`` from the others. Strings are placed uniformly
in a square that grows whenever a string does not fit, and the result is
centered on the origin.
"""
def __init__(
self,
n_strings: int,
*,
length: float = 1.0,
length_variation: float = 0.35,
roughness: float = 0.25,
n_harmonics: int = 4,
relative_spacing: float = 0.15,
seed: int | np.random.Generator | None = None,
):
n_strings = _validate_integer("n_strings", n_strings)
length = _validate_positive("length", length)
length_variation = _validate_nonnegative("length_variation", length_variation)
if length_variation >= 1:
raise ValueError("length_variation must be between 0 and 1")
roughness = _validate_nonnegative("roughness", roughness)
n_harmonics = _validate_integer("n_harmonics", n_harmonics)
relative_spacing = _validate_positive("relative_spacing", relative_spacing)
self._pinned_seed = seed is not None
self.seed = resolve_seed(seed)
rng = as_rng(self.seed)
strings = []
extent = length * max(1.0, np.sqrt(n_strings))
minimum_distance = relative_spacing * length
for _ in range(int(n_strings)):
local_length = length * rng.uniform(
1 - length_variation, 1 + length_variation
)
guide = Organic(
length=local_length,
roughness=roughness,
n_harmonics=n_harmonics,
rotation=rng.uniform(0, 180),
seed=rng,
)
base = guide._strings[0]
attempts = 0
while True:
center = rng.uniform(-extent / 2, extent / 2, size=2)
segments = tuple(
shp_translate(segment, xoff=center[0], yoff=center[1])
for segment in base.segments
)
geometry = segments[0]
if all(
geometry.distance(existing.segments[0]) >= minimum_distance
for existing in strings
):
strings.append(_LogicalString("Organic", segments))
break
attempts += 1
if attempts == 100:
extent *= 1.25
attempts = 0
bounds = np.asarray([logical.segments[0].bounds for logical in strings])
center = np.array(
[
(bounds[:, 0].min() + bounds[:, 2].max()) / 2,
(bounds[:, 1].min() + bounds[:, 3].max()) / 2,
]
)
super().__init__(
tuple(
_LogicalString(
logical.guide_type,
tuple(
shp_translate(segment, xoff=-center[0], yoff=-center[1])
for segment in logical.segments
),
)
for logical in strings
)
)