Source code for noisemaker.points

"""Point cloud library for Noisemaker. Used for Voronoi and DLA functions."""

from __future__ import annotations

import math
from typing import Any

import noisemaker.masks as masks
import noisemaker.rng as rng
import noisemaker.simplex as simplex
from noisemaker.constants import PointDistribution, ValueMask


[docs] def point_cloud( freq: int, distrib: PointDistribution | ValueMask = PointDistribution.random, shape: list[int] | None = None, corners: bool = False, generations: int = 1, drift: float = 0.0, time: float = 0.0, speed: float = 1.0, ) -> tuple[list[Any], list[Any]] | None: """ Generate a point cloud for Voronoi diagrams or other point-based effects. Args: freq: Point frequency/density distrib: Point distribution method (PointDistribution or ValueMask) shape: Optional shape [height, width, channels] corners: If True, anchor points to corners instead of center generations: Number of generations for iterative distributions drift: Amount of random drift to apply to points time: Time parameter for animation speed: Animation speed multiplier Returns: Tuple of (x_coords, y_coords) lists, or None if freq is 0 """ if not freq: return None x: list[Any] = [] y: list[Any] = [] if shape is None: width = 1.0 height = 1.0 else: width = shape[1] height = shape[0] if isinstance(distrib, int): if any(d.value == distrib for d in PointDistribution): distrib = PointDistribution(distrib) else: distrib = ValueMask(distrib) elif isinstance(distrib, str): if any(d.name == distrib for d in PointDistribution): distrib = PointDistribution[distrib] else: distrib = ValueMask[distrib] if distrib in ValueMask.procedural_members(): raise Exception("Procedural ValueMask can't be used as a PointDistribution.") point_func: Any = rand range_x = width * 0.5 range_y = height * 0.5 # seen: set[tuple[Any, Any]] = set() active_set: set[tuple[Any, Any, int]] = set() if isinstance(distrib, PointDistribution): if PointDistribution.is_grid(distrib): point_func = square_grid elif distrib == PointDistribution.spiral: point_func = spiral elif PointDistribution.is_circular(distrib): point_func = circular if PointDistribution.is_grid(distrib): active_set.add((0.0, 0.0, 1)) else: active_set.add((range_y, range_x, 1)) else: # Use a ValueMask as a PointDistribution! if shape is None: raise ValueError("shape must be provided when using ValueMask as PointDistribution") mask: Any = masks.Masks[distrib] mask_shape = masks.mask_shape(distrib) x_space = shape[1] / mask_shape[1] y_space = shape[0] / mask_shape[0] x_margin = x_space * 0.5 y_margin = y_space * 0.5 for mx in range(mask_shape[1]): for my in range(mask_shape[0]): pixel = mask[my][mx] if isinstance(pixel, list): pixel = sum(p for p in pixel) if drift: x_drift = simplex.random(time, speed=speed) * drift / freq * shape[1] y_drift = simplex.random(time, speed=speed) * drift / freq * shape[0] else: x_drift = 0 y_drift = 0 if pixel != 0: x.append(int(x_margin + mx * x_space + x_drift)) y.append(int(y_margin + my * y_space + y_drift)) return x, y seen.update((x, y) for x, y, _ in active_set) while active_set: x_point, y_point, generation = active_set.pop() if generation <= generations: multiplier = max(2 * (generation - 1), 1) _x, _y = point_func( freq=freq, distrib=distrib, corners=corners, center_x=x_point, center_y=y_point, range_x=range_x / multiplier, range_y=range_y / multiplier, width=width, height=height, generation=generation, time=time, speed=speed * 0.1, ) for i in range(len(_x)): x_point = _x[i] y_point = _y[i] if (x_point, y_point) in seen: continue seen.add((x_point, y_point)) active_set.add((x_point, y_point, generation + 1)) if drift: x_drift = simplex.random(time, speed=speed) * drift y_drift = simplex.random(time, speed=speed) * drift else: x_drift = 0 y_drift = 0 if shape is None: x_point = (x_point + x_drift / freq) % 1.0 y_point = (y_point + y_drift / freq) % 1.0 else: x_point = int(x_point + (x_drift / freq * shape[1])) % shape[1] y_point = int(y_point + (y_drift / freq * shape[0])) % shape[0] x.append(x_point) y.append(y_point) return (x, y)
[docs] def cloud_points(count: int, seed: int | None = None) -> tuple[list[float], list[float]]: """Convenience wrapper for random point clouds. RNG: ``count * count * 2`` calls to :func:`rng.random` via :func:`rand`, ordered as x then y for each point. Args: count: Number of points per axis (total points = count * count). seed: Optional random seed for reproducible output. Returns: Tuple of (x_coords, y_coords) lists with normalized [0.0, 1.0] coordinates. """ if seed is not None: rng.set_seed(seed) result = point_cloud(count, PointDistribution.random) if result is None: return ([], []) return result
[docs] def rand( freq: int = 2, center_x: float = 0.5, center_y: float = 0.5, range_x: float = 0.5, range_y: float = 0.5, width: float = 1.0, height: float = 1.0, **kwargs: Any, ) -> tuple[list[float], list[float]]: """Generate a random cloud of points within a specified region. RNG: ``freq * freq * 2`` calls to :func:`rng.random`, ordered as x then y. Args: freq: Number of points per axis (total points = freq * freq). center_x: Horizontal center of the distribution region [0.0, 1.0]. center_y: Vertical center of the distribution region [0.0, 1.0]. range_x: Horizontal radius from center [0.0, 1.0]. range_y: Vertical radius from center [0.0, 1.0]. width: Horizontal wrapping bounds. height: Vertical wrapping bounds. **kwargs: Unused; accepts additional parameters for compatibility. Returns: Tuple of (x_coords, y_coords) lists with coordinates wrapped to [0.0, width) and [0.0, height). """ x = [] y = [] for i in range(freq * freq): _x = (center_x + (rng.random() * (range_x * 2.0) - range_x)) % width # RNG[x] _y = (center_y + (rng.random() * (range_y * 2.0) - range_y)) % height # RNG[y] x.append(_x) y.append(_y) return x, y
[docs] def square_grid( freq: float = 1.0, distrib: PointDistribution | None = None, corners: bool = False, center_x: float = 0.0, center_y: float = 0.0, range_x: float = 1.0, range_y: float = 1.0, width: float = 1.0, height: float = 1.0, **kwargs: Any, ) -> tuple[list[float], list[float]]: """Generate a square grid of points with optional distribution patterns. Supports various grid patterns including waffle, chess, and hexagonal layouts. Args: freq: Number of grid divisions per axis (total points = freq * freq). distrib: Optional point distribution pattern (waffle, chess, h_hex, v_hex). corners: If True, align grid to corners; otherwise center the grid. center_x: Horizontal center offset [0.0, 1.0]. center_y: Vertical center offset [0.0, 1.0]. range_x: Horizontal scale factor. range_y: Vertical scale factor. width: Horizontal wrapping bounds. height: Vertical wrapping bounds. **kwargs: Unused; accepts additional parameters for compatibility. Returns: Tuple of (x_coords, y_coords) lists with grid coordinates. """ x = [] y = [] # Keep a node in the center of the image, or pin to corner: drift_amount = 0.5 / freq if (freq % 2) == 0: drift = 0.0 if not corners else drift_amount else: drift = drift_amount if not corners else 0.0 # for a in range(int(freq)): for b in range(int(freq)): if distrib == PointDistribution.waffle and (b % 2) == 0 and (a % 2) == 0: continue if distrib == PointDistribution.chess and (a % 2) == (b % 2): continue # if distrib == PointDistribution.h_hex: x_drift = drift_amount if (b % 2) == 1 else 0 else: x_drift = 0 # if distrib == PointDistribution.v_hex: y_drift = 0 if (a % 2) == 1 else drift_amount else: y_drift = 0 _x = (center_x + (((a / freq) + drift + x_drift) * range_x * 2)) % width _y = (center_y + (((b / freq) + drift + y_drift) * range_y * 2)) % height x.append(_x) y.append(_y) return x, y
[docs] def spiral( freq: float = 1.0, center_x: float = 0.0, center_y: float = 0.0, range_x: float = 1.0, range_y: float = 1.0, width: float = 1.0, height: float = 1.0, time: float = 0.0, speed: float = 1.0, **kwargs: Any, ) -> tuple[list[float], list[float]]: """Generate points along a spiral path with time-based rotation. RNG: 1 call to :func:`rng.random` for spiral kink factor. Args: freq: Number of points to generate. center_x: Horizontal center of the spiral [0.0, 1.0]. center_y: Vertical center of the spiral [0.0, 1.0]. range_x: Horizontal radius of the spiral. range_y: Vertical radius of the spiral. width: Horizontal wrapping bounds. height: Vertical wrapping bounds. time: Animation time parameter for rotation. speed: Animation speed multiplier. **kwargs: Unused; accepts additional parameters for compatibility. Returns: Tuple of (x_coords, y_coords) lists with spiral coordinates. """ kink = 0.5 + rng.random() * 0.5 x = [] y = [] count = freq * freq for i in range(int(count)): fract = i / count degrees = fract * 360.0 * math.radians(1) * kink x.append((center_x + math.sin(degrees) * fract * range_x) % width) y.append((center_y + math.cos(degrees) * fract * range_y) % height) return x, y
[docs] def circular( freq: float = 1.0, distrib: PointDistribution = PointDistribution.circular, center_x: float = 0.0, center_y: float = 0.0, range_x: float = 1.0, range_y: float = 1.0, width: float = 1.0, height: float = 1.0, generation: int = 1, time: float = 0.0, speed: float = 1.0, **kwargs: Any, ) -> tuple[list[float], list[float]]: """Generate points in concentric circular rings. Creates a center point surrounded by rings of evenly spaced points. Args: freq: Number of rings and points per ring. distrib: Point distribution method (circular or rotating). center_x: Horizontal center of the circular pattern [0.0, 1.0]. center_y: Vertical center of the circular pattern [0.0, 1.0]. range_x: Horizontal radius scale factor. range_y: Vertical radius scale factor. width: Horizontal wrapping bounds. height: Vertical wrapping bounds. generation: Ring generation parameter for pattern variation. time: Animation time parameter for rotation. speed: Animation speed multiplier. **kwargs: Unused; accepts additional parameters for compatibility. Returns: Tuple of (x_coords, y_coords) lists with circular coordinates. """ x = [] y = [] ring_count = freq dot_count = freq x.append(center_x) y.append(center_y) rotation = (1 / dot_count) * 360.0 * math.radians(1) kink = 0.5 + rng.random() * 0.5 for i in range(1, int(ring_count) + 1): dist_fract = i / ring_count for j in range(1, int(dot_count) + 1): rads = j * rotation if distrib == PointDistribution.circular: rads += rotation * 0.5 * i if distrib == PointDistribution.rotating: rads += rotation * dist_fract * kink x_point = center_x + math.sin(rads) * dist_fract * range_x y_point = center_y + math.cos(rads) * dist_fract * range_y x.append(x_point % width) y.append(y_point % height) return x, y