Recursive Division 01

2026-02-28

"""2026-02-28
Recursive Division 01
Desenho de subdivisões recursivas a partir de um quadrilátero
ericof.com|https://openprocessing.org/sketch/1307209
png
Sketch,py5,CreativeCoding
"""

from dataclasses import dataclass
from sketches.utils.draw import canvas
from sketches.utils.draw import vetores as vh
from sketches.utils.draw.cores.paletas import gera_paleta
from sketches.utils.helpers import sketches as helpers

import numpy as np
import py5


sketch = helpers.info_for_sketch(__file__, __doc__)


@dataclass
class Limites:
    """Limites do quadrilátero."""

    v1: np.ndarray
    v2: np.ndarray
    v3: np.ndarray
    v4: np.ndarray


cor_fundo_base = py5.color(0)
# Variáveis globais
limites: Limites | None = None
paleta = gera_paleta("op-1307209")
largura_canvas, altura_canvas = helpers.DIMENSOES.internal
lado_menor = min(largura_canvas, altura_canvas)
largura_fundo = lado_menor
largura_minima = largura_fundo * 0.95
meia_largura_minima = largura_minima / 2
raio2_minimo = meia_largura_minima * meia_largura_minima

cor_fundo = None


class CelulaRecursiva:
    """Objeto recursivo que subdivide quadrilátero e desenha contornos arredondados."""

    contador: int
    numero_divisoes_atual: int
    limite_divisoes: int
    razao_folga: float
    subobjetos: list["CelulaRecursiva"]
    amplitude_deslocamento: float
    cor: int
    limite: Limites
    direcao_divisao: str
    semente_ruido: float
    velocidade_ruido: float
    frequencia: float
    min_ruido: float
    max_ruido: float
    intervalo_ruido: float
    exibir_sub: list[bool]
    obj_id: str

    def __init__(
        self,
        numero_divisoes_atual: int,
        limite: Limites,
        obj_id: str,
    ):
        """Cria uma instância e possivelmente a subdivide em dois subobjetos."""
        self.obj_id = obj_id
        self.contador = 0
        self.numero_divisoes_atual = numero_divisoes_atual
        self.limite_divisoes = 19
        self.razao_folga = 0.9
        self.subobjetos: list[CelulaRecursiva] = []

        self.amplitude_deslocamento = largura_minima / 1600
        self.cor = py5.color(py5.random_choice(paleta))

        probabilidade_divisao = 1.0
        e_primeiro = self.numero_divisoes_atual == 1
        pode_dividir = self.numero_divisoes_atual < self.limite_divisoes
        sorteou = py5.random(1) < probabilidade_divisao

        self.limite = limite

        if (pode_dividir and sorteou) or e_primeiro:
            self.dividir_objeto()

    def dividir_objeto(self) -> None:
        """Define direção e parâmetros de ruído e cria dois subobjetos."""
        self.direcao_divisao = "vertical" if py5.random(1) < 0.5 else "horizontal"

        self.semente_ruido = float(py5.random(1000))
        self.velocidade_ruido = 0.0015
        self.frequencia = 6
        self.min_ruido = 0.2
        self.max_ruido = 0.8
        self.intervalo_ruido = self.max_ruido - self.min_ruido
        self.contador = 0

        proximo = self.numero_divisoes_atual + 1
        self.subobjetos = [
            CelulaRecursiva(proximo, limite=self.limite, obj_id=f"{self.obj_id}/0"),
            CelulaRecursiva(proximo, limite=self.limite, obj_id=f"{self.obj_id}/1"),
        ]

    def atualizar(
        self,
        limite: Limites,
    ) -> None:
        """Atualiza cantos e repassa para subobjetos (se existirem)."""
        self.limite = limite

        self.exibir_sub = [True, True]

        if self.subobjetos:
            passo = self.velocidade_ruido * self.contador
            ruido_base = py5.noise(self.semente_ruido + passo)
            ang = py5.TAU * self.frequencia * ruido_base
            seno = py5.sin(ang)
            metade_intervalo = self.intervalo_ruido / 2
            valor_ruido = float((seno + 1) * metade_intervalo + self.min_ruido)
            func = (
                self._atualizar_vertical
                if self.direcao_divisao == "vertical"
                else self._atualizar_horizontal
            )
            func(self.limite, valor_ruido)

        self.contador += 1

    def _atualizar_vertical(
        self,
        limite_base: Limites,
        valor_ruido: float,
    ) -> None:
        """Atualiza subdivisão vertical."""
        limite = Limites(
            limite_base.v1,
            vh.interpolar_vetor(limite_base.v1, limite_base.v2, valor_ruido),
            vh.interpolar_vetor(limite_base.v4, limite_base.v3, valor_ruido),
            limite_base.v4,
        )

        r1 = self.redimensionar_objeto_deslocamento_fixo(
            limite, self.amplitude_deslocamento
        )
        self.exibir_sub[0] = r1[1]
        if self.exibir_sub[0]:
            self.subobjetos[0].atualizar(limite)

        limite2 = Limites(
            limite.v2,
            limite_base.v2,
            limite_base.v3,
            limite.v3,
        )

        r2 = self.redimensionar_objeto_deslocamento_fixo(
            limite2, self.amplitude_deslocamento
        )
        self.exibir_sub[1] = r2[1]
        if self.exibir_sub[1]:
            self.subobjetos[1].atualizar(limite2)

    def _atualizar_horizontal(
        self,
        limite_base: Limites,
        valor_ruido: float,
    ) -> None:
        """Atualiza subdivisão horizontal."""
        limite = Limites(
            limite_base.v1,
            limite_base.v2,
            vh.interpolar_vetor(limite_base.v2, limite_base.v3, valor_ruido),
            vh.interpolar_vetor(limite_base.v1, limite_base.v4, valor_ruido),
        )

        r1 = self.redimensionar_objeto_deslocamento_fixo(
            limite, self.amplitude_deslocamento
        )
        self.exibir_sub[0] = r1[1]
        if self.exibir_sub[0]:
            self.subobjetos[0].atualizar(limite)

        limite2 = Limites(
            limite.v4,
            limite.v3,
            limite_base.v3,
            limite_base.v4,
        )

        r2 = self.redimensionar_objeto_deslocamento_fixo(
            limite2, self.amplitude_deslocamento
        )
        self.exibir_sub[1] = r2[1]
        if self.exibir_sub[1]:
            self.subobjetos[1].atualizar(limite2)

    def desenhar(self) -> None:
        """Desenha o objeto e recursivamente os subobjetos visíveis."""
        py5.fill(self.cor)
        print(f"- Desenhando {self.obj_id}")
        if self.verificar_desenho():
            self.desenhar_linha_externa()

        if self.subobjetos:
            if self.exibir_sub[0]:
                self.subobjetos[0].desenhar()
            if self.exibir_sub[1]:
                self.subobjetos[1].desenhar()

    def verificar_desenho(self) -> bool:
        """True se os 4 cantos estiverem dentro do círculo (usa dist²)."""
        v1x, v1y = self.limite.v1
        v2x, v2y = self.limite.v2
        v3x, v3y = self.limite.v3
        v4x, v4y = self.limite.v4

        d1 = v1x * v1x + v1y * v1y
        d2 = v2x * v2x + v2y * v2y
        d3 = v3x * v3x + v3y * v3y
        d4 = v4x * v4x + v4y * v4y

        return (
            d1 < raio2_minimo
            and d2 < raio2_minimo
            and d3 < raio2_minimo
            and d4 < raio2_minimo
        )

    def redimensionar_objeto_deslocamento_fixo(
        self,
        limite_base: Limites,
        amplitude: float,
    ) -> tuple[Limites, bool]:
        """Desloca arestas e retorna [v1s, v2s, v3s, v4s, exibir]."""
        exibir = True

        v1: np.ndarray = limite_base.v1
        v2: np.ndarray = limite_base.v2
        v3: np.ndarray = limite_base.v3
        v4: np.ndarray = limite_base.v4

        n12 = vh.ajustar_magnitude(vh.normal_90(v2 - v1), amplitude)
        n23 = vh.ajustar_magnitude(vh.normal_90(v3 - v2), amplitude)
        n34 = vh.ajustar_magnitude(vh.normal_90(v4 - v3), amplitude)
        n41 = vh.ajustar_magnitude(vh.normal_90(v1 - v4), amplitude)

        v1_12 = v1 + n12
        v2_12 = v2 + n12

        v2_23 = v2 + n23
        v3_23 = v3 + n23

        v3_34 = v3 + n34
        v4_34 = v4 + n34

        v4_41 = v4 + n41
        v1_41 = v1 + n41

        limite = Limites(
            vh.calcular_cruzamento(v4_41, v1_41, v1_12, v2_12),
            vh.calcular_cruzamento(v1_12, v2_12, v2_23, v3_23),
            vh.calcular_cruzamento(v2_23, v3_23, v3_34, v4_34),
            vh.calcular_cruzamento(v3_34, v4_34, v4_41, v1_41),
        )

        if limite.v1[0] >= limite.v2[0] or limite.v1[1] >= limite.v4[1]:
            exibir = False
        return limite, exibir

    def desenhar_linha_externa(self) -> None:
        """Desenha o contorno externo arredondado."""
        v1, v2, v3, v4 = self.limite.v1, self.limite.v2, self.limite.v3, self.limite.v4
        parametros = (
            (v4, v1, v2),
            (v1, v2, v3),
            (v2, v3, v4),
            (v3, v4, v1),
        )

        with py5.begin_shape():
            for v in parametros:
                pontos = self.calcular_canto_externo(*v)
                for x, y in pontos:
                    py5.vertex(float(x), float(y))

    def calcular_canto_externo(
        self,
        v1: np.ndarray,
        v2: np.ndarray,
        v3: np.ndarray,
    ) -> np.ndarray:
        """Gera pontos do arco externo no canto v2 (v1-v2-v3)."""
        deslocamento = self.amplitude_deslocamento * self.razao_folga

        centro21 = vh.ajustar_magnitude(vh.normal_90(v1 - v2), deslocamento)
        centro23 = vh.ajustar_magnitude(vh.normal_90(v2 - v3), deslocamento)

        # Ângulo entre vetores
        a = centro21
        b = centro23
        dot = float(a[0] * b[0] + a[1] * b[1])
        na = float(np.hypot(a[0], a[1]))
        nb = float(np.hypot(b[0], b[1]))
        cosang = dot / (na * nb)
        cosang = float(np.clip(cosang, -1.0, 1.0))
        ang_externo = float(np.arccos(cosang))

        divisoes = 32
        passo = ang_externo / divisoes

        # Rotações incrementais com matriz 2x2 (NumPy)
        c = float(np.cos(passo))
        s = float(np.sin(passo))
        rot = np.array([[c, -s], [s, c]], dtype=np.float64)

        pontos = np.empty((divisoes + 1, 2), dtype=np.float64)
        atual = centro21.copy()

        for i in range(divisoes + 1):
            pontos[i] = v2 + atual
            atual = rot @ atual

        return pontos


def criar_objeto_principal() -> CelulaRecursiva:
    """Cria o objeto principal."""
    global cor_fundo, limites

    limites = Limites(
        vh.criar_vetor(-meia_largura_minima, -meia_largura_minima),
        vh.criar_vetor(+meia_largura_minima, -meia_largura_minima),
        vh.criar_vetor(+meia_largura_minima, +meia_largura_minima),
        vh.criar_vetor(-meia_largura_minima, +meia_largura_minima),
    )

    paleta.append("#FFF4EC")
    cor_fundo = py5.color(py5.random_choice(paleta))
    objeto = CelulaRecursiva(numero_divisoes_atual=1, limite=limites, obj_id="0")
    objeto.atualizar(limites)
    return objeto


def setup():
    py5.size(*helpers.DIMENSOES.external, py5.P3D)
    py5.color_mode(py5.HSB, 360, 100, 100)
    py5.background(cor_fundo_base)
    principal = criar_objeto_principal()

    with py5.push():
        py5.translate(*helpers.DIMENSOES.centro)
        py5.no_stroke()
        principal.desenhar()

    # Credits and go
    canvas.sketch_frame(
        sketch,
        cor_fundo_base,
        "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()