Source code for oowl.strings.composites

"""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 ) )