Cores e esferóide 01

2026-11-05

"""2026-11-05
Cores e esferóide 01
Exercício de uso de gradientes em faces de um esferóide
ericof.com
png
Sketch,py5,CreativeCoding
"""

from collections import deque
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.helpers import sketches as helpers

import numpy as np
import py5


sketch = helpers.info_for_sketch(__file__, __doc__)

cor_fundo = py5.color(0)
paleta: deque[int] = gera_paleta("bright-colors", como_deque=True)

RAIO = 300
NIVEIS = 6
PROB_DIVISAO = 0.8
TRACO = 1.5
BRILHO = 40.0
EMISSAO = 12

CUBO_VERTICES = (
    (-1, -1, -1),
    (1, -1, -1),
    (1, 1, -1),
    (-1, 1, -1),
    (-1, -1, 1),
    (1, -1, 1),
    (1, 1, 1),
    (-1, 1, 1),
)
CUBO_FACES = (
    (0, 1, 2, 3),
    (5, 4, 7, 6),
    (4, 0, 3, 7),
    (1, 5, 6, 2),
    (4, 5, 1, 0),
    (3, 2, 6, 7),
)

niveis = NIVEIS
brilho = BRILHO
facetado = True

vertices: list[np.ndarray] = []
faces: list[tuple[int, ...]] = []
cores: list[tuple[int, int]] = []
forma: py5.Py5Shape | None = None


def _normaliza(v: np.ndarray) -> np.ndarray:
    """Projeta ``v`` na esfera unitária."""
    return v / np.linalg.norm(v)


def _novo_vertice(vs: list[np.ndarray], ponto: np.ndarray) -> int:
    """Adiciona ``ponto``, projetado na esfera, e retorna seu índice."""
    vs.append(_normaliza(ponto))
    return len(vs) - 1


def _aresta(a: int, b: int) -> tuple[int, int]:
    """Chave canônica de uma aresta, independente da ordem dos vértices."""
    return (a, b) if a < b else (b, a)


def divide_quadrado(quad: tuple[int, ...], vs: list[np.ndarray]) -> list[tuple]:
    """Divide uma face do cubo em triângulos, como em 2026-10-01.

    Todo ponto novo é interno à face, então a malha continua sem frestas.

    :param quad: Índices dos quatro vértices da face.
    :param vs: Lista de vértices, que recebe os pontos novos.
    :returns: Triângulos resultantes (2, 4 ou 4 com centro deslocado).
    """
    a, b, c, d = quad
    div = py5.random_int(2, 4)
    if div == 4:
        g = _novo_vertice(vs, vs[a] + vs[b] + vs[c] + vs[d])
        return [(a, b, g), (b, c, g), (c, d, g), (d, a, g)]
    ac = np.linalg.norm(vs[a] - vs[c])
    bd = np.linalg.norm(vs[b] - vs[d])
    p, q, r, s = (a, b, c, d) if ac < bd else (b, c, d, a)
    if div == 2:
        return [(p, q, r), (p, r, s)]
    g = _novo_vertice(vs, vs[p] + vs[q] + vs[r])
    return [(p, q, g), (q, r, g), (r, p, g), (p, r, s)]


def _meios(tri: tuple[int, ...], meios: dict) -> tuple[int | None, ...]:
    """Pontos médios já criados para as arestas ``ab``, ``bc`` e ``ca``."""
    return tuple(meios.get(_aresta(tri[i], tri[(i + 1) % 3])) for i in range(3))


def divide_triangulo(tri: tuple[int, ...], meios: dict) -> list[tuple]:
    """Divide um triângulo conforme as arestas marcadas para divisão.

    :param tri: Índices dos três vértices.
    :param meios: Mapa aresta -> índice do ponto médio, compartilhado entre
        vizinhos, o que mantém a malha conforme.
    :returns: De 1 a 4 triângulos.
    """
    mab, mbc, mca = _meios(tri, meios)
    divididas = sum(m is not None for m in (mab, mbc, mca))
    if divididas == 0:
        return [tri]
    a, b, c = tri
    if divididas == 3:
        return [(a, mab, mca), (mab, b, mbc), (mca, mbc, c), (mab, mbc, mca)]
    # Gira o triângulo até que ``ab`` esteja dividida e ``ca`` não
    while mab is None or mca is not None:
        a, b, c = b, c, a
        mab, mbc, mca = _meios((a, b, c), meios)
    if mbc is None:
        return [(a, mab, c), (mab, b, c)]
    return [(mab, b, mbc), (a, mab, mbc), (a, mbc, c)]


def divide_nivel(fs: list[tuple], vs: list[np.ndarray]) -> list[tuple]:
    """Aplica um nível de subdivisão a todos os triângulos.

    Cada aresta é dividida ao acaso, com chance proporcional ao seu
    comprimento, de modo que faces grandes tendem a se fragmentar primeiro.
    """
    arestas = {_aresta(f[i], f[(i + 1) % 3]) for f in fs for i in range(3)}
    comps = {e: float(np.linalg.norm(vs[e[0]] - vs[e[1]])) for e in arestas}
    maior = max(comps.values())
    meios = {
        e: _novo_vertice(vs, vs[e[0]] + vs[e[1]])
        for e, comp in comps.items()
        if py5.random(1) < PROB_DIVISAO * comp / maior
    }
    novas = []
    for f in fs:
        novas.extend(divide_triangulo(f, meios))
    return novas


def gera_malha(total: int) -> tuple[list[np.ndarray], list[tuple]]:
    """Gera a malha da esfera unitária a partir de um cubo.

    :param total: Quantidade de níveis de subdivisão (o primeiro divide as
        faces do cubo).
    :returns: Vértices e triângulos.
    """
    vs = [_normaliza(np.array(v, dtype=float)) for v in CUBO_VERTICES]
    fs = []
    for quad in CUBO_FACES:
        fs.extend(divide_quadrado(quad, vs))
    for _ in range(total - 1):
        fs = divide_nivel(fs, vs)
    return vs, fs


def constroi_forma():
    """Monta o ``GROUP`` com um ``Py5Shape`` por triângulo.

    Cada triângulo recebe um gradiente entre duas cores vizinhas da paleta e
    o material (especular, brilho, emissão) que reage às luzes. Com
    ``facetado`` a normal é a da face; sem ele, a de cada vértice.
    """
    global forma
    grupo = py5.create_shape(py5.GROUP)
    for tri, (cor, cor_proxima) in zip(faces, cores, strict=True):
        pts = [vertices[i] for i in tri]
        total = len(pts) - 1
        poly = py5.create_shape()
        with poly.begin_closed_shape():
            poly.stroke(cor_fundo)
            poly.stroke_weight(TRACO)
            poly.specular(255, 255, 255)
            poly.shininess(brilho)
            poly.emissive(EMISSAO, EMISSAO, EMISSAO)
            if facetado:
                poly.normal(*_normaliza(sum(pts)))
            for i, p in enumerate(pts):
                if not facetado:
                    poly.normal(*p)
                poly.fill(lerp_color_rgba(cor, cor_proxima, i / total))
                poly.vertex(*(p * RAIO))
        grupo.add_child(poly)
    forma = grupo


def inicializa():
    global vertices, faces, cores
    vertices, faces = gera_malha(niveis)
    cores = []
    for _ in faces:
        cores.append((paleta[0], paleta[1]))
        paleta.rotate()
    constroi_forma()
    print(f"Níveis: {niveis} - Triângulos: {len(faces)}")


def luzes():
    """Luz principal branca, preenchimento frio e um ponto quente de brilho."""
    cx, cy = helpers.DIMENSOES.centro
    py5.ambient_light(35, 35, 45)
    py5.light_specular(255, 255, 255)
    py5.directional_light(170, 170, 170, 0.5, 0.6, -1)
    py5.light_specular(120, 140, 255)
    py5.directional_light(50, 70, 140, -0.7, -0.3, -0.5)
    py5.light_specular(255, 240, 220)
    py5.point_light(255, 230, 200, cx - RAIO, cy - RAIO, 500)


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


def draw():
    py5.background(cor_fundo)
    radianos = float(py5.radians(py5.frame_count))
    luzes()
    with py5.push():
        py5.translate(*helpers.DIMENSOES.centro, 0)
        py5.rotate_y(radianos)
        py5.rotate_z(radianos)
        if forma:
            py5.shape(forma)
    py5.no_lights()
    fps = py5.get_frame_rate()
    py5.window_title(f"Níveis: {niveis} - Brilho: {brilho} - FPS: {fps:.2f}")
    # Credits and go
    canvas.sketch_frame(
        sketch,
        cor_fundo,
        "large_transparent_white",
        "transparent_white",
        version=2,
    )


def key_pressed():
    global niveis, brilho, facetado
    key = py5.key
    if key == " ":
        save_and_close()
    elif key == "r":
        inicializa()
    elif key in ("a", "d"):
        niveis = max(1, niveis + (1 if key == "d" else -1))
        inicializa()
    elif key == "f":
        facetado = not facetado
        constroi_forma()
    elif key in ("w", "s"):
        brilho = max(1.0, brilho + (10.0 if key == "w" else -10.0))
        constroi_forma()


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


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