Manchas concêntricas 02

2026-03-19

"""2026-03-19
Manchas concêntricas 02
Distribuição de círculos concêntricos
ericof.com
png
Sketch,py5,CreativeCoding
"""

from collections import deque
from dataclasses import dataclass
from sketches.utils.draw import canvas
from sketches.utils.draw.cores import extrai_rgba
from sketches.utils.draw.cores import lerp_color_rgba
from sketches.utils.draw.cores.paletas import gera_paleta
from sketches.utils.draw.cores.paletas import lista_paletas
from sketches.utils.helpers import sketches as helpers

import numpy as np
import py5


sketch = helpers.info_for_sketch(__file__, __doc__)

cor_fundo = py5.color(0)
paletas: deque[list[int]] = deque([
    gera_paleta("op-2363803") for name in lista_paletas() if name.startswith("op-")
])


@dataclass
class CirculoRaio:
    raio: float
    traco: float
    cor: int


@dataclass
class CirculosConcentricos:
    x: float
    y: float
    z: float
    circulos: list[CirculoRaio]


CIRCULOS: list[CirculosConcentricos] = []


def calcula_raios(
    num: int,
    cor_1: int,
    cor_2: int,
    z_dist: float,
    inicial: float,
    final: float,
) -> list[CirculoRaio]:
    """Considerando valores para raio inicial e final, retorna lista com num CirculoRaio

    * Primeiramente calculamos a distância média entre os raios
    * Iteramos por range(num)
        * raio será a distância média + random_int
        * traco será (distância média /2) * random(0.2, 0.75)

    """
    raios = []
    dist_media = (final - inicial) / num
    traco_base = dist_media / 1.2
    t_minmax = (0, num)
    for idx in range(num):
        r, g, b, a = extrai_rgba(lerp_color_rgba(cor_1, cor_2, idx, t_minmax))
        a *= z_dist
        cor = py5.color(r, g, b, a)
        raios.append(
            CirculoRaio(
                raio=(dist_media + py5.random_int(-3, 3)) * idx + inicial,
                cor=cor,
                traco=traco_base * py5.random(0.2, 0.75),
            )
        )
    return raios


def calcula_circulos(
    x: float,
    y: float,
    z: float,
    z_dist: float,
    r_range: tuple[float, float],
    total: int,
    cor_1: int,
    cor_2: int,
) -> CirculosConcentricos:
    circulos = calcula_raios(total, cor_1, cor_2, z_dist, *r_range)
    return CirculosConcentricos(x=x, y=y, z=z, circulos=circulos)


def calcula_pontos(
    total: int,
    x_range: tuple[float, float],
    y_range: tuple[float, float],
    z_range: tuple[float, float],
    coeficiente: int = 6,
) -> tuple[tuple[tuple[float, float, float], ...], tuple[float, float]]:
    """Retorna uma tupla con `total` coordenadas (x, y, z).

    Os pontos são distribuidos, inicialmente em uma matrix 2d de
    maneira equidistante (respeitando os limites estipulados em x_range
    e y_range).
    Para cada ponto, os valores de x e y devem ter aplicado algum `noise`
    para que a distribuição dos pontos seja levemente "bagunçada"
    A coordenada z será calculada utilizando o py5.random(*z_range)
    """
    cols = int(np.ceil(np.sqrt(total)))
    rows = int(np.ceil(total / cols))

    xs = np.linspace(x_range[0], x_range[1], cols)
    ys = np.linspace(y_range[0], y_range[1], rows)
    grid_x, grid_y = np.meshgrid(xs, ys)

    step_x = (x_range[1] - x_range[0]) / cols
    # Desloca meio passo em x nas linhas ímpares
    offset = np.where(np.arange(rows) % 2 == 1, step_x / 2, 0.0)
    grid_x += offset[:, np.newaxis]

    flat_x = grid_x.flatten()[:total]
    flat_y = grid_y.flatten()[:total]

    step_y = (y_range[1] - y_range[0]) / rows
    flat_x += np.random.uniform(-step_x / coeficiente, step_x / coeficiente, total)
    flat_y += np.random.uniform(-step_y / coeficiente, step_y / coeficiente, total)

    zs = np.array([py5.random(*z_range) for _ in range(total)])

    return tuple(zip(flat_x, flat_y, zs, strict=True)), (step_x, step_y)


def setup():
    py5.size(*helpers.DIMENSOES.external, py5.P3D)
    meio_x = helpers.DIMENSOES.external[0] / 2
    meio_y = helpers.DIMENSOES.external[1] / 2
    x_range = -meio_x, meio_x + 1
    y_range = -meio_y, meio_y + 1
    z_range = -900, 40
    pontos, (passo_x, passo_y) = calcula_pontos(400, x_range, y_range, z_range)
    for x, y, z in pontos:
        paleta = paletas[0]
        passo = max([passo_x, passo_y])
        r_max = py5.random((passo), passo * 2)
        r_range = py5.random(r_max / 3.0, r_max / 1.8), r_max
        total = py5.random_int(5, 14)
        cor_1 = py5.random_choice(paleta)
        cor_2 = py5.random_choice(paleta)
        while cor_1 == cor_2:
            cor_2 = py5.random_choice(paleta)
        z_dist = float(py5.remap(z, z_range[0], z_range[1], 0, 1))
        CIRCULOS.append(calcula_circulos(x, y, z, z_dist, r_range, total, cor_1, cor_2))
        paletas.rotate()


def draw():
    py5.background("#010101")
    with py5.push():
        py5.translate(*helpers.DIMENSOES.centro, -40)
        py5.no_fill()
        for figura in CIRCULOS:
            for info in figura.circulos:
                with py5.push():
                    py5.translate(0, 0, figura.z)
                    raio = info.raio
                    py5.stroke_weight(info.traco)
                    py5.stroke(info.cor)
                    py5.circle(figura.x, figura.y, raio)

    # Credits and go
    canvas.sketch_frame(
        sketch,
        cor_fundo,
        "large_transparent_white",
        "transparent_white",
        version=2,
    )


def key_pressed():
    key = py5.key
    if key == " ":
        save_and_close()


def save_and_close():
    py5.no_loop()
    canvas.save_sketch_image(sketch)
    py5.exit_sketch()


if __name__ == "__main__":
    py5.run_sketch()