Particles 02

2026-08-14

"""2026-08-14
Particles 02
Sistema de partículas dinâmico com divisão e movimento giratório.
ericof.com|https://ericof.com/en/sketches/2025-12-15
png
Sketch,py5,CreativeCoding
"""

from matplotlib.colors import LinearSegmentedColormap
from sketches.utils.draw import canvas
from sketches.utils.draw.cores.paletas import gera_paleta
from sketches.utils.draw.grade import cria_grade_ex
from sketches.utils.helpers import sketches as helpers

import py5


sketch = helpers.info_for_sketch(__file__, __doc__)

cor_fundo = py5.color(0)


particulas = []

PASSO: int = 120
DIAMETRO: float = 24.0
DDX: float = 0.990
REPETICOES: int = 10
Z_BASE: int = -50
PALETA: str = "op-2363803"


def cmap_da_paleta(nome: str) -> LinearSegmentedColormap:
    """Converte uma paleta do pacote numa colormap contínua do matplotlib.

    Ponto de extensão: :func:`gera_paleta` devolve uma lista discreta de cores,
    enquanto o modo ``CMAP`` do py5 espera uma rampa contínua indexável por um
    valor numérico. Aqui as cores viram os pontos de controle de uma
    interpolação linear, distribuídos uniformemente entre as extremidades.

    Cada cor é um :class:`py5.Py5Color`, que é um inteiro ARGB; os componentes
    saem por deslocamento de bits porque :func:`py5.red` e companhia exigem um
    sketch em execução e esta função roda em escopo de módulo.

    :param nome: nome de uma paleta conhecida por :func:`gera_paleta`.
    :returns: colormap aceita por :func:`py5.color_mode` em modo ``CMAP``.
    :raises ValueError: se a paleta não existir no pacote.
    """
    cores = [int(cor) for cor in gera_paleta(nome)]
    return LinearSegmentedColormap.from_list(
        nome,
        [
            (((cor >> 16) & 0xFF) / 255, ((cor >> 8) & 0xFF) / 255, (cor & 0xFF) / 255)
            for cor in cores
        ],
    )


cmap_paleta = cmap_da_paleta(PALETA)


class Particula:
    """Partícula circular que se desloca em arco, encolhe, se divide e morre.

    Cada partícula vive num plano ``z`` fixo e percorre o canvas com a
    velocidade girando sempre para o mesmo lado (:attr:`sentido`), o que traça
    arcos em vez de retas. A cada quadro o diâmetro é multiplicado por
    :data:`DDX`, então ela encolhe continuamente; abaixo de 1 pixel se remove da
    lista global ``particulas``. Enquanto vive, tem uma pequena chance por
    quadro de gerar uma cópia de si mesma no mesmo ponto, com a velocidade
    invertida — é essa divisão que povoa o sketch.
    """

    def __init__(
        self,
        x,
        y,
        z,
        diametro=None,
        velocidade=None,
        sentido=None,
        paleta: LinearSegmentedColormap | None = None,
    ):
        """Cria uma partícula em ``(x, y)`` sobre o plano ``z``.

        :param x: coordenada horizontal inicial, em pixels.
        :param y: coordenada vertical inicial, em pixels.
        :param z: profundidade do plano em que a partícula é desenhada.
        :param diametro: diâmetro inicial em pixels; sorteado entre 20 e 50
            quando omitido.
        :param velocidade: deslocamento por quadro como :class:`py5.Py5Vector2D`;
            sorteado em direção aleatória com módulo 2 quando omitido.
        :param sentido: ``-1`` ou ``1``, o lado para o qual a velocidade gira;
            sorteado quando omitido.
        :param paleta: colormap consultada em :meth:`desenha`, construída a
            partir de uma paleta do pacote por :func:`cmap_da_paleta`.
        """
        self.pos = py5.Py5Vector(x, y)
        self.z = z
        self.velocidade = velocidade or py5.Py5Vector2D.random() * 2
        self.diametro = diametro or py5.random(20, 50)
        self.sentido = sentido or py5.random_choice((-1, 1))
        self.paleta = paleta

    def atualiza(self):
        """Avança a partícula um quadro: desenha, move, encolhe, divide, morre.

        A ordem importa — a partícula é desenhada na posição em que estava antes
        do movimento deste quadro. Em seguida o diâmetro é multiplicado por
        :data:`DDX`; há cerca de 2% de chance de gerar uma filha na posição
        atual, com a velocidade invertida e o mesmo sentido de giro e paleta; e,
        se o diâmetro cair abaixo de 1 pixel, a partícula se remove da lista
        global ``particulas``.
        """
        self.desenha()
        self.movimenta()
        self.diametro = self.diametro * DDX
        if py5.random(100) < 2:
            particulas.append(
                Particula(
                    self.pos.x,
                    self.pos.y,
                    self.z,
                    self.diametro,
                    -self.velocidade,
                    sentido=self.sentido,
                    paleta=self.paleta,
                )
            )
        if self.diametro < 1:
            particulas.remove(self)

    def desenha(self):
        """Desenha a partícula como um círculo preenchido, sem contorno.

        A cor sai da paleta da própria partícula: o diâmetro atual é remapeado
        de ``0..DIAMETRO`` para ``0..255`` e usado como índice na colormap, de
        modo que encolher é também percorrer a paleta. A opacidade recebe um
        ruído pequeno a cada quadro, o que dá textura ao acúmulo.
        """
        with py5.push():
            py5.color_mode(py5.CMAP, self.paleta, 255)
            py5.no_stroke()
            cor = py5.remap(self.diametro, 0, DIAMETRO, 0, 255)
            b = py5.random_int(200, 205)
            py5.fill(cor, b)
            py5.translate(self.pos.x, self.pos.y, self.z)
            py5.circle(0, 0, self.diametro)

    def movimenta(self):
        """Aplica a velocidade à posição e prepara a velocidade do próximo quadro.

        A posição envolve nas bordas do canvas (módulo sobre ``width`` e
        ``height``), então quem sai de um lado reentra pelo oposto. A velocidade
        gira 2° por quadro no sentido da partícula e perde 0,5% do módulo — é
        essa combinação que fecha a trajetória numa espiral.
        """
        self.pos += self.velocidade
        self.pos.x %= py5.width
        self.pos.y %= py5.height
        self.velocidade.rotate(py5.radians(2) * self.sentido)
        self.velocidade *= 0.995


def inicializa():
    largura, altura = helpers.DIMENSOES.external
    grade = cria_grade_ex(
        largura=largura,
        altura=altura,
        margem_x=0,
        margem_y=0,
        celula_x=PASSO,
        celula_y=PASSO,
        alternada=True,
    )
    for item in grade:
        x, y = item[1], item[3]
        for _ in range(REPETICOES):
            particulas.append(
                Particula(x, y, Z_BASE, diametro=DIAMETRO, paleta=cmap_paleta)
            )


def setup():
    py5.size(*helpers.DIMENSOES.external, py5.P3D)
    py5.color_mode(py5.CMAP, cmap_paleta, 255)
    py5.background(py5.color(80))
    inicializa()


def draw():
    with py5.push():
        py5.translate(0, 0, -80)
        py5.fill(0, 10)
        for p in particulas.copy():
            p.atualiza()
    # 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()