Dyson 03 / Language 15

2026-04-12

"""2026-04-12
Dyson 03 / Language 15
Dodecaedro que atua como uma esfera de Dyson com textura de grades compostas por padrões de traços e círculos.
ericof.com
png
Sketch,py5,CreativeCoding
"""  # noqa: E501

from collections.abc import Sequence
from dataclasses import dataclass
from random import shuffle
from sketches.padroes import biblioteca as b
from sketches.padroes import tipos as t
from sketches.padroes.fabrica import GradeLinearPadroes
from sketches.utils.draw import canvas
from sketches.utils.draw.cores.paletas import gera_paleta
from sketches.utils.helpers import sketches as helpers
from sketches.utils.helpers.window import title

import math
import numpy as np
import py5


sketch = helpers.info_for_sketch(__file__, __doc__)

cor_fundo = py5.color(0)
celula_fundo = py5.color("#222")
celulas = 32
espacamento = 0
traco = 2
rotacoes = range(0, 360, 45)
camadas = 3
fundo = py5.color("#000")
borda = t.Borda(fundo, espacamento)
raio_dodecaedro = 300
profundidade_face = 20


@dataclass
class Rotacao:
    x: float = -64
    y: float = 21
    z: float = 0
    dist_z: float = -100
    explosao: float = 80


rotacao = Rotacao()


def gera_colecao(largura: float, altura: float) -> list[t.Padrao]:
    """Cria instâncias de todos os padrões da categoria "tracos".

    :param largura: Largura de cada padrão em pixels.
    :param altura: Altura de cada padrão em pixels.
    :returns: Lista de instâncias de :class:`t.Padrao`.
    """
    payload = {"traco": traco, "largura": largura, "altura": altura}
    colecao = [
        padrao(**payload) for padrao in b.Biblioteca.get_categoria("tracos").values()
    ]
    return colecao


def gera_paletas() -> tuple[Sequence[int | str], ...]:
    """Carrega as paletas de cores usadas no sketch.

    :returns: Tupla com paletas que comecem com `op-`.
    """
    paletas = []
    nomes_paletas = ["Warhol"]
    for nome in nomes_paletas:
        paletas.append(gera_paleta(nome))
    shuffle(paletas)
    return tuple(paletas)


PALETAS = gera_paletas()


def gera_cores_padrao(idy: int) -> t.CoresPadrao:
    """Sorteia cores distintas de uma paleta para um padrão.

    :param idy: Índice da linha que estamos utilizando.
    :returns: :class:`t.CoresPadrao` com traço e preenchimento distintos
        e diferentes de branco.
    """
    paleta_id = idy % len(PALETAS)
    paleta = PALETAS[paleta_id]
    fundo = None
    valida = False
    while not valida:
        preenchimento = py5.random_choice(paleta)
        traco = py5.random_choice(paleta)
        valida = preenchimento != traco != celula_fundo
    return t.CoresPadrao(traco, preenchimento, fundo)


def calcula_celulas(
    largura: float,
    altura: float,
    celulas_x: int,
    celulas_y: int,
    espacamento_x: int,
    espacamento_y: int,
    colecao: list[t.Padrao],
    borda: t.Borda | None,
) -> list[tuple[t.Celula, tuple[t.Padrao, float, t.CoresPadrao, float]]]:
    """Calcula células e associa padrões, rotações e cores para cada camada.

    Gera 3 camadas sobrepostas (``idz`` 0-2) com profundidades z distintas.
    As cores são sorteadas por linha (``celula.idy``), usando paletas ``op-``.

    :param largura: Largura total da grade em pixels.
    :param altura: Altura total da grade em pixels.
    :param celulas_x: Número de colunas da grade.
    :param celulas_y: Número de linhas da grade.
    :param espacamento_x: Espaçamento horizontal entre células em pixels.
    :param espacamento_y: Espaçamento vertical entre células em pixels.
    :param colecao: Instâncias de padrões disponíveis para sorteio.
    :param borda: Borda opcional a aplicar em cada célula.
    :returns: Lista de tuplas ``(celula, (padrao, rotacao, cores, z))``.
    """
    celulas = []
    for idz in range(0, camadas):
        grade = GradeLinearPadroes(
            largura,
            altura,
            celulas_x,
            celulas_y,
            (espacamento_x, espacamento_y),
            colecao,
            borda=borda,
        )
        grade_padroes = grade.padroes
        z = -10 + idz
        for celula in grade.celulas:
            cores = gera_cores_padrao(celula.idy)
            cores.fundo = celula_fundo
            padrao = next(grade_padroes)
            rotacao = py5.random_choice(rotacoes)
            celulas.append((celula, (padrao, rotacao, cores, z)))
    return celulas


def inicializa_celulas():
    """Calcula as células e suas associações de padrões, rotações e cores."""
    with title("Regenerando células"):
        largura, altura = helpers.DIMENSOES.internal
        colecao = gera_colecao(largura / celulas, altura / celulas)
        retorno = calcula_celulas(
            largura, altura, celulas, celulas, espacamento, espacamento, colecao, borda
        )
    return retorno


def gera_imagem(
    celulas: list[tuple[t.Celula, tuple[t.Padrao, float, t.CoresPadrao, float]]],
):
    """Gera a imagem do sketch a partir das células calculadas.

    :param celulas: Lista de tuplas ``(celula, (padrao, rotacao, cores, z))``.
    """
    pg = py5.create_graphics(*helpers.DIMENSOES.internal, py5.P3D)
    with pg.begin_draw():
        pg.background(celula_fundo)
        for celula, (padrao, rotacao, cores, z) in celulas:
            celula(padrao, rotacao, cores, z=z, pg=pg)
    # Forçar alpha 255 em todos os pixels para evitar transparência em P3D.
    pg.load_np_pixels()
    pg.np_pixels[..., 0] = 255  # canal alpha (ARGB)
    pg.update_np_pixels()
    return pg


def _coords_canonicas_dodecaedro() -> list[tuple[float, float, float]]:
    """Gera as 20 coordenadas canônicas de um dodecaedro (não normalizado).

    :returns: Lista de 20 tuplas ``(x, y, z)``.
    """
    phi = (1 + math.sqrt(5)) / 2
    coords: list[tuple[float, float, float]] = [
        (sx, sy, sz) for sx in (-1, 1) for sy in (-1, 1) for sz in (-1, 1)
    ]
    coords.extend((0, sy / phi, sz * phi) for sy in (-1, 1) for sz in (-1, 1))
    coords.extend((sx / phi, sy * phi, 0) for sx in (-1, 1) for sy in (-1, 1))
    coords.extend((sx * phi, 0, sz / phi) for sx in (-1, 1) for sz in (-1, 1))
    return coords


def _vertices_dodecaedro(raio: float) -> list[np.ndarray]:
    """Calcula os 20 vértices de um dodecaedro regular inscrito em esfera.

    :param raio: Raio da esfera circunscrita.
    :returns: Lista de 20 arrays ``(x, y, z)``.
    """
    coords = _coords_canonicas_dodecaedro()
    vertices = []
    for c in coords:
        v = np.array(c, dtype=float)
        v = v / np.linalg.norm(v) * raio
        vertices.append(v)
    return vertices


def _adjacencia_dodecaedro(
    vertices: list[np.ndarray],
) -> dict[int, list[int]]:
    """Calcula adjacência entre vértices usando a menor distância como aresta.

    :param vertices: 20 vértices do dodecaedro.
    :returns: Dicionário de vizinhos por índice de vértice.
    """
    n = len(vertices)
    dist_min = float("inf")
    for i in range(n):
        for j in range(i + 1, n):
            d = float(np.linalg.norm(vertices[i] - vertices[j]))
            if d < dist_min:
                dist_min = d
    tolerancia = dist_min * 1.1
    vizinhos: dict[int, list[int]] = {i: [] for i in range(n)}
    for i in range(n):
        for j in range(i + 1, n):
            if np.linalg.norm(vertices[i] - vertices[j]) < tolerancia:
                vizinhos[i].append(j)
                vizinhos[j].append(i)
    return vizinhos


def _ciclos_pentagonais(
    n: int,
    vizinhos: dict[int, list[int]],
) -> set[tuple[int, ...]]:
    """Encontra todos os ciclos de 5 vértices adjacentes (faces pentagonais).

    :param n: Número total de vértices.
    :param vizinhos: Dicionário de adjacência.
    :returns: Conjunto de tuplas de 5 índices ordenados.
    """
    faces_set: set[tuple[int, ...]] = set()
    for a in range(n):
        for b_idx in vizinhos[a]:
            for c in vizinhos[b_idx]:
                if c == a:
                    continue
                for d in vizinhos[c]:
                    if d in (a, b_idx):
                        continue
                    for e in vizinhos[d]:
                        if e in (b_idx, c):
                            continue
                        if a in vizinhos[e]:
                            face = tuple(sorted([a, b_idx, c, d, e]))
                            faces_set.add(face)
    return faces_set


def _ordena_pentagono(
    ids: list[int],
    vertices: list[np.ndarray],
) -> list[int]:
    """Ordena 5 vértices em sentido anti-horário visto de fora.

    :param ids: 5 índices de vértices.
    :param vertices: Lista completa de vértices.
    :returns: Índices reordenados em sentido anti-horário.
    """
    centro = sum(vertices[i] for i in ids) / 5
    normal = np.cross(
        vertices[ids[1]] - vertices[ids[0]],
        vertices[ids[2]] - vertices[ids[0]],
    )
    if np.dot(normal, centro) < 0:
        normal = -normal
    eixo_x = vertices[ids[0]] - centro
    eixo_x = eixo_x / np.linalg.norm(eixo_x)
    eixo_y = np.cross(normal / np.linalg.norm(normal), eixo_x)
    angulos = [
        math.atan2(
            float(np.dot(vertices[i] - centro, eixo_y)),
            float(np.dot(vertices[i] - centro, eixo_x)),
        )
        for i in ids
    ]
    return [i for _, i in sorted(zip(angulos, ids, strict=True))]


def _faces_dodecaedro(
    vertices: list[np.ndarray],
) -> list[list[int]]:
    """Descobre as 12 faces pentagonais do dodecaedro a partir dos vértices.

    :param vertices: 20 vértices do dodecaedro.
    :returns: Lista de 12 listas de 5 índices (em ordem anti-horária).
    """
    vizinhos = _adjacencia_dodecaedro(vertices)
    ciclos = _ciclos_pentagonais(len(vertices), vizinhos)
    return [_ordena_pentagono(list(face), vertices) for face in ciclos]


def _uv_pentagono(
    vertices_3d: list[np.ndarray],
    centro: np.ndarray,
    normal: np.ndarray,
) -> list[tuple[float, float]]:
    """Calcula coordenadas UV (0..1) para um pentágono projetado no plano local.

    :param vertices_3d: 5 vértices do pentágono em coordenadas 3D.
    :param centro: Centro do pentágono.
    :param normal: Normal da face (apontando para fora).
    :returns: Lista de 5 tuplas ``(u, v)`` normalizadas em ``[0, 1]``.
    """
    normal_u = normal / np.linalg.norm(normal)
    eixo_x = vertices_3d[0] - centro
    eixo_x = eixo_x / np.linalg.norm(eixo_x)
    eixo_y = np.cross(normal_u, eixo_x)
    coords_2d = []
    for v in vertices_3d:
        rel = v - centro
        coords_2d.append((float(np.dot(rel, eixo_x)), float(np.dot(rel, eixo_y))))
    min_x = min(c[0] for c in coords_2d)
    max_x = max(c[0] for c in coords_2d)
    min_y = min(c[1] for c in coords_2d)
    max_y = max(c[1] for c in coords_2d)
    rng_x = max_x - min_x if max_x > min_x else 1.0
    rng_y = max_y - min_y if max_y > min_y else 1.0
    # Margem para evitar bleeding de textura nas bordas UV 0/1.
    margem = 0.01
    return [
        (
            margem + (1 - 2 * margem) * (c[0] - min_x) / rng_x,
            margem + (1 - 2 * margem) * (c[1] - min_y) / rng_y,
        )
        for c in coords_2d
    ]


def cria_face_pentagono(
    vertices_ext: list[np.ndarray],
    centro: np.ndarray,
    normal: np.ndarray,
    profundidade: float,
    textura,
) -> py5.Py5Shape:
    """Cria um slab pentagonal com textura em todas as faces.

    O pentágono externo fica deslocado ``+profundidade/2`` ao longo da normal;
    o pentágono interno fica em ``-profundidade/2``. Ambos e as 5 laterais
    recebem a textura mapeada com UV completo.

    :param vertices_ext: 5 vértices do pentágono (em coordenadas 3D mundo).
    :param centro: Centro geométrico da face.
    :param normal: Normal unitária da face (apontando para fora).
    :param profundidade: Espessura do slab.
    :param textura: Py5Graphics aplicado em todas as faces.
    :returns: Py5Shape com o slab pentagonal texturizado.
    """
    normal_u = normal / np.linalg.norm(normal)
    d = profundidade / 2
    ext = [v + normal_u * d for v in vertices_ext]
    inn = [v - normal_u * d for v in vertices_ext]

    uvs = _uv_pentagono(vertices_ext, centro, normal)
    cx = sum(u for u, _ in uvs) / 5
    cy = sum(v for _, v in uvs) / 5

    grupo = py5.create_shape(py5.GROUP)

    # Face externa (TRIANGLE_FAN a partir do centro).
    face_ext = py5.create_shape()
    with face_ext.begin_shape(py5.TRIANGLE_FAN):
        face_ext.no_stroke()
        face_ext.fill(255)
        face_ext.texture_mode(py5.NORMAL)
        face_ext.texture(textura)
        face_ext.vertex(
            float(centro[0] + normal_u[0] * d),
            float(centro[1] + normal_u[1] * d),
            float(centro[2] + normal_u[2] * d),
            cx,
            cy,
        )
        for i in range(5):
            face_ext.vertex(
                float(ext[i][0]),
                float(ext[i][1]),
                float(ext[i][2]),
                uvs[i][0],
                uvs[i][1],
            )
        # Fechar o fan.
        face_ext.vertex(
            float(ext[0][0]),
            float(ext[0][1]),
            float(ext[0][2]),
            uvs[0][0],
            uvs[0][1],
        )
    grupo.add_child(face_ext)

    # Face interna (TRIANGLE_FAN, ordem inversa).
    face_inn = py5.create_shape()
    with face_inn.begin_shape(py5.TRIANGLE_FAN):
        face_inn.no_stroke()
        face_inn.fill(255)
        face_inn.texture_mode(py5.NORMAL)
        face_inn.texture(textura)
        face_inn.vertex(
            float(centro[0] - normal_u[0] * d),
            float(centro[1] - normal_u[1] * d),
            float(centro[2] - normal_u[2] * d),
            cx,
            cy,
        )
        for i in range(4, -1, -1):
            face_inn.vertex(
                float(inn[i][0]),
                float(inn[i][1]),
                float(inn[i][2]),
                uvs[i][0],
                uvs[i][1],
            )
        face_inn.vertex(
            float(inn[4][0]),
            float(inn[4][1]),
            float(inn[4][2]),
            uvs[4][0],
            uvs[4][1],
        )
    grupo.add_child(face_inn)

    # 5 laterais (QUADS) — cor metálica com normais para iluminação.
    cor_metalica = py5.color(180, 180, 195)
    for i in range(5):
        j = (i + 1) % 5
        # Normal da lateral: perpendicular à aresta e à normal da face.
        aresta = ext[j] - ext[i]
        lateral_normal = np.cross(aresta, normal_u)
        norm_len = np.linalg.norm(lateral_normal)
        if norm_len > 0:
            lateral_normal = lateral_normal / norm_len
        nx = float(lateral_normal[0])
        ny = float(lateral_normal[1])
        nz = float(lateral_normal[2])
        quad = py5.create_shape()
        with quad.begin_shape(py5.QUADS):
            quad.no_stroke()
            quad.fill(cor_metalica)
            quad.shininess(80)
            quad.specular(220, 220, 230)
            quad.emissive(15, 15, 20)
            quad.normal(nx, ny, nz)
            quad.vertex(float(ext[i][0]), float(ext[i][1]), float(ext[i][2]))
            quad.vertex(float(ext[j][0]), float(ext[j][1]), float(ext[j][2]))
            quad.vertex(float(inn[j][0]), float(inn[j][1]), float(inn[j][2]))
            quad.vertex(float(inn[i][0]), float(inn[i][1]), float(inn[i][2]))
        grupo.add_child(quad)

    return grupo


def gera_faces(
    textura,
) -> list[tuple[py5.Py5Shape, np.ndarray]]:
    """Gera as 12 faces pentagonais do dodecaedro como slabs texturizados.

    Cada face é um slab pentagonal posicionado em coordenadas absolutas. A
    normal unitária é guardada para aplicar o deslocamento de explosão no
    momento do desenho.

    :param textura: Py5Graphics aplicado em todas as faces de cada slab.
    :returns: Lista ``[(shape, normal_unitaria), ...]`` com 12 entradas.
    """
    vertices = _vertices_dodecaedro(raio_dodecaedro)
    faces_idx = _faces_dodecaedro(vertices)
    resultado = []
    for ids in faces_idx:
        verts = [vertices[i] for i in ids]
        centro = sum(verts, np.zeros(3)) / 5
        normal = centro / np.linalg.norm(centro)
        forma = cria_face_pentagono(
            verts,
            centro,
            normal,
            profundidade_face,
            textura,
        )
        resultado.append((forma, normal))
    return resultado


def setup():
    """Inicializa o sketch: cria a janela, calcula grade e padrões."""
    global imagem, faces
    py5.size(*helpers.DIMENSOES.external, py5.P3D)
    py5.background(cor_fundo)
    imagem = gera_imagem(inicializa_celulas())
    faces = gera_faces(imagem)


def draw():
    """Renderiza todas as células e adiciona os créditos do sketch."""
    py5.background(cor_fundo)

    with py5.push():
        py5.translate(*helpers.DIMENSOES.centro, rotacao.dist_z)
        py5.rotate_y(float(py5.radians(rotacao.y)))
        py5.rotate_x(float(py5.radians(rotacao.x)))
        py5.rotate_z(float(py5.radians(rotacao.z)))
        # Luz pontual no centro do dodecaedro.
        py5.point_light(255, 250, 240, 0, 0, 0)
        for forma, normal in faces:
            with py5.push():
                # Empurra cada face ao longo da sua normal para fora.
                deslocamento = normal * rotacao.explosao
                py5.translate(
                    float(deslocamento[0]),
                    float(deslocamento[1]),
                    float(deslocamento[2]),
                )
                py5.shape(forma)
    msg = (
        f"R: {rotacao.x:.1f}, {rotacao.y:.1f}, {rotacao.z:.1f} | "
        f"Z: {rotacao.dist_z} | "
        f"E: {rotacao.explosao}"
    )
    # Resetar iluminação para o frame não ser afetado pela point_light.
    py5.no_lights()
    canvas.sketch_frame(
        sketch,
        cor_fundo,
        "large_transparent_white",
        "transparent_white",
        version=2,
        msg=msg,
    )


def trata_tecla(key: str) -> None:
    """Trata teclas de caractere: salvar, regenerar, zoom e explosão."""
    global imagem, faces
    match key:
        case " ":
            save_and_close()
        case "r":
            imagem = gera_imagem(inicializa_celulas())
            faces = gera_faces(imagem)
        case "+":
            rotacao.dist_z += 100
        case "-":
            rotacao.dist_z -= 100
        case "]":
            rotacao.explosao += 10
        case "[":
            rotacao.explosao = max(0, rotacao.explosao - 10)


def key_pressed():
    """Captura teclas: ``espaço`` salva e fecha o sketch."""
    trata_tecla(py5.key)
    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


def save_and_close():
    """Para o loop, salva a imagem do sketch e encerra."""
    py5.no_loop()
    canvas.save_sketch_image(sketch)
    py5.exit_sketch()


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