Esfera de Retalhos 05

2026-03-24

"""2026-03-24
Esfera de Retalhos 05
Esfera formada por um cilindro com gradientes de cores diversas
ericof.com
png
Sketch,py5,CreativeCoding
"""

from collections import deque
from collections.abc import Mapping
from dataclasses import dataclass
from random import shuffle
from sketches.utils.draw import canvas
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)

z_factor = 0.04

paletas = [
    gera_paleta(name, True) for name in lista_paletas() if name.startswith("op-")
]


@dataclass
class EsferaRotacao:
    x: float = -15
    y: float = 45
    z: float = 0
    dist_z: float = -2000


rotacao = EsferaRotacao()
forma: py5.Py5Shape
cilindro_polos: py5.Py5Shape


def gera_caminho_espiral(raio: float, num_pontos: int, n_sub: int) -> np.ndarray:
    """Gera caminho denso da espiral Fibonacci sobre a superfície da esfera.

    Trata o índice como parâmetro contínuo — sem interpolação externa.
    Produz (num_pontos - 1) * n_sub + 1 pontos na superfície.

    :param n_sub: amostras entre cada par de pontos Fibonacci consecutivos.
    :return: array (total, 3)
    """
    angulo_dourado = np.pi * (3.0 - np.sqrt(5.0))
    total = (num_pontos - 1) * n_sub + 1
    t = np.linspace(0.0, num_pontos - 1, total)
    y = 1.0 - (2.0 * t) / (num_pontos - 1)
    raio_xy = np.sqrt(np.maximum(0.0, 1.0 - y * y))
    theta = angulo_dourado * t
    coords = np.stack([np.cos(theta) * raio_xy, y, np.sin(theta) * raio_xy], axis=-1)
    return coords * raio


def gera_caminho_bezier_cubico(
    p0: np.ndarray,
    p1: np.ndarray,
    p2: np.ndarray,
    p3: np.ndarray,
    n: int,
) -> np.ndarray:
    """Gera n pontos ao longo de uma curva Bézier cúbica."""
    t = np.linspace(0.0, 1.0, n)[:, None]
    return (
        (1 - t) ** 3 * p0
        + 3 * (1 - t) ** 2 * t * p1
        + 3 * (1 - t) * t**2 * p2
        + t**3 * p3
    )


def _gera_frames_caminho(centers: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Gera frames locais (normais, binormais) via parallel transport."""
    n = len(centers)
    tangents = np.empty_like(centers)
    tangents[1:-1] = centers[2:] - centers[:-2]
    tangents[0] = centers[1] - centers[0]
    tangents[-1] = centers[-1] - centers[-2]
    tangents /= np.linalg.norm(tangents, axis=-1, keepdims=True).clip(1e-10)

    t0 = tangents[0]
    up = np.array([0.0, 1.0, 0.0]) if abs(t0[1]) < 0.9 else np.array([1.0, 0.0, 0.0])
    normal = np.cross(t0, up)
    normal /= np.linalg.norm(normal)

    normals = np.empty_like(centers)
    normals[0] = normal
    for i in range(1, n):
        t = tangents[i]
        normal = normals[i - 1] - np.dot(normals[i - 1], t) * t
        norm = np.linalg.norm(normal)
        if norm < 1e-10:
            normal = np.cross(t, np.array([0.0, 0.0, 1.0]))
            norm = np.linalg.norm(normal)
        normals[i] = normal / norm

    binormals = np.cross(tangents, normals)
    return normals, binormals


def gera_aneis_cilindro(
    raio: float, raio_tubo: float, num_pontos: int, num_lados: int, n_sub: int
) -> np.ndarray:
    """Gera anéis circulares ao longo da espiral Fibonacci densa.

    :return: array (total, num_lados, 3)
    """
    caminho = gera_caminho_espiral(raio, num_pontos, n_sub)
    normals, binormals = _gera_frames_caminho(caminho)
    phi = np.linspace(0, 2 * np.pi, num_lados, endpoint=False)
    return caminho[:, None, :] + raio_tubo * (
        np.cos(phi)[None, :, None] * normals[:, None, :]
        + np.sin(phi)[None, :, None] * binormals[:, None, :]
    )


def _cor_int(c: str | int) -> int:
    """Converte valor de paleta para inteiro de cor py5."""
    return c if isinstance(c, int) else py5.color(c)


def _monta_grupo_cilindro(
    aneis: np.ndarray,
    paletas_forma: Mapping[str, deque[str | int]],
) -> py5.Py5Shape:
    """Monta um GROUP de faces quádruplas a partir de anéis pré-calculados.

    Atribui uma cor por anel e usa set_fills/set_strokes por vértice,
    produzindo gradiente suave ao longo do eixo do cilindro.
    """
    n_aneis, num_lados, _ = aneis.shape
    key = "preenchimento"
    pal = paletas_forma[key]

    pal_len = len(pal)
    direction = -1
    cores_anel = [_cor_int(pal[0])]
    for step in range(1, n_aneis):
        pal.rotate(direction)
        if step % pal_len == 0:
            direction = -direction
            pal.rotate(direction * (pal_len // 3))
        cores_anel.append(_cor_int(pal[0]))

    grupo = py5.create_shape(py5.GROUP)
    for i in range(n_aneis - 1):
        ci, cj = cores_anel[i], cores_anel[i + 1]
        bi = lerp_color_rgba(ci, py5.color("#000"), t=0.15)
        bj = lerp_color_rgba(cj, py5.color("#000"), t=0.15)

        for j in range(num_lados):
            j_next = (j + 1) % num_lados
            p0, p1 = aneis[i, j], aneis[i, j_next]
            p2, p3 = aneis[i + 1, j_next], aneis[i + 1, j]

            face = py5.create_shape()
            with face.begin_closed_shape():
                face.vertex(*p0)
                face.vertex(*p1)
                face.vertex(*p2)
                face.vertex(*p3)
            face.set_fills([ci, ci, cj, cj])
            face.set_strokes([bi, bi, bj, bj])
            grupo.add_child(face)

    return grupo


def criar_forma(
    raio: float,
    num_pontos: int,
    paletas_forma: Mapping[str, deque[str | int]],
    raio_tubo: float = 80.0,
    num_lados: int = 8,
    n_sub: int = 8,
) -> py5.Py5Shape:
    """Cria um cilindro em espiral sobre a esfera, do polo N ao polo S."""
    aneis = gera_aneis_cilindro(raio, raio_tubo, num_pontos, num_lados, n_sub)
    return _monta_grupo_cilindro(aneis, paletas_forma)


def criar_cilindro_polos(
    raio: float,
    paletas_forma: Mapping[str, deque[str | int]],
    raio_tubo: float = 30.0,
    num_lados: int = 16,
    curva: float = 0.3,
    n_pontos: int = 60,
) -> py5.Py5Shape:
    """Cria um cilindro curvado unindo o polo N ao polo S pelo interior da esfera.

    Usa uma Bézier cúbica em S: os dois pontos de controle ficam em lados
    opostos do eixo vertical, produzindo uma curva suave e simétrica.
    No ponto médio (t=0.5) o cilindro passa exatamente pelo centro da esfera.

    :param curva: deslocamento lateral dos controles como fração do raio.
    """
    p_norte = np.array([0.0, float(raio), 0.0])
    ctrl1 = np.array([curva * raio, raio / 3.0, 0.0])
    ctrl2 = np.array([-curva * raio, -raio / 3.0, 0.0])
    p_sul = np.array([0.0, -float(raio), 0.0])

    caminho = gera_caminho_bezier_cubico(p_norte, ctrl1, ctrl2, p_sul, n_pontos)
    normals, binormals = _gera_frames_caminho(caminho)

    phi = np.linspace(0, 2 * np.pi, num_lados, endpoint=False)
    aneis = caminho[:, None, :] + raio_tubo * (
        np.cos(phi)[None, :, None] * normals[:, None, :]
        + np.sin(phi)[None, :, None] * binormals[:, None, :]
    )
    return _monta_grupo_cilindro(aneis, paletas_forma)


def inicializa():
    global forma, cilindro_polos
    local_paletas = paletas[:]
    shuffle(local_paletas)

    paletas_espiral: dict[str, deque[str | int]] = {
        "preenchimento": deque(local_paletas[0]),
    }
    forma = criar_forma(800, 60, paletas_espiral, raio_tubo=30.0, num_lados=16, n_sub=8)

    paletas_polos: dict[str, deque[str | int]] = {
        "preenchimento": deque(local_paletas[1]),
    }
    cilindro_polos = criar_cilindro_polos(800, paletas_polos)


def setup():
    py5.size(*helpers.DIMENSOES.external, py5.P3D)
    inicializa()


def draw():
    py5.background(cor_fundo)
    with py5.push():
        py5.translate(*helpers.DIMENSOES.centro, rotacao.dist_z)
        py5.rotate_y(py5.radians(rotacao.y))
        py5.rotate_x(py5.radians(rotacao.x))
        py5.rotate_z(py5.radians(rotacao.z))
        py5.shape(forma)
        py5.shape(cilindro_polos)

    # Créditos e encerramento
    canvas.sketch_frame(
        sketch,
        cor_fundo,
        "large_transparent_white",
        "transparent_white",
        version=2,
    )


def key_pressed():
    key = py5.key
    match py5.key:
        case " ":
            save_and_close()
        case "r":
            inicializa()
        case "+":
            rotacao.dist_z += 50
        case "-":
            rotacao.dist_z -= 50
    match py5.key_code:
        case py5.UP:
            rotacao.x += 3
        case py5.DOWN:
            rotacao.x -= 3
        case py5.LEFT:
            rotacao.y += 3
        case py5.RIGHT:
            rotacao.y -= 3
    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()