Caquinhos Redux 05

2026-06-25

"""2026-06-25
Caquinhos Redux 05
Pisos de caquinhos de São Paulo, realidade alternativa.
ericof.com
png
Sketch,py5,CreativeCoding,Voronoi
"""

from opensimplex import OpenSimplex
from random import shuffle
from scipy.spatial import Voronoi
from sketches.utils.draw import canvas
from sketches.utils.draw.grade import cria_grade_ex
from sketches.utils.helpers import sketches as helpers
from sketches.utils.helpers.timing import report_time

import math
import numpy as np
import py5


sketch = helpers.info_for_sketch(__file__, __doc__)

cor_fundo = py5.color(0, 0, 0)
cor_fundo_interna = py5.color(60, 60, 45)

CORES_PESOS = (
    (py5.color(152, 59, 47), 93),
    (py5.color(218, 160, 57), 2),
    (py5.color(0), 3),
)

celula_x = 30
celula_y = 30
mult_x = 25
mult_y = 25
borda = 0.15
faixa_bagunca = 20
profundidade_caco = 6

# Ponto de luz controlado pelo mouse: x,y vem do cursor, altura (z) e tom sao
# fixos. A ambiente baixa evita que os cacos longe da luz fiquem pretos.
luz_altura = 800
luz_cor = py5.color(255, 244, 214)
luz_ambiente = py5.color(40, 40, 40)

# Distancia do anel de sementes-sentinela alem da area interna. Precisa ser
# maior que o deslocamento maximo de uma semente interna (mult + faixa_bagunca)
# para que toda semente visivel fique cercada e sua celula seja finita.
margem_guarda = 400

noise_generator = OpenSimplex(seed=py5.random_int(20_000))

cacos: list[py5.Py5Shape] = []


def cores_pesos() -> list[int]:
    """Expande ``CORES_PESOS`` numa lista com cada cor repetida conforme seu peso.

    A lista e embaralhada e serve de base para o sorteio ponderado de cores
    em :func:`cor_caco`.

    :returns: Lista de cores com repeticao proporcional ao peso de cada uma.
    """
    cores = []
    for cor, peso in CORES_PESOS:
        cores.extend([cor] * peso)
    shuffle(cores)
    return cores


CORES = cores_pesos()


def par_bagunca(faixa_bagunca: int) -> tuple[float, float]:
    """Sorteia um deslocamento aleatorio em x e y dentro de uma faixa.

    :param faixa_bagunca: Amplitude maxima do deslocamento em cada eixo.
    :returns: Par ``(bx, by)`` de deslocamentos.
    """
    bx = py5.random(-faixa_bagunca, faixa_bagunca)
    by = py5.random(-faixa_bagunca, faixa_bagunca)
    return bx, by


def cor_caco() -> int:
    """Sorteia a cor de um caco com probabilidade proporcional aos pesos.

    O sorteio e feito sobre ``CORES`` (a lista expandida por :func:`cores_pesos`),
    de modo que cada cor aparece na frequencia definida em ``CORES_PESOS``.

    :returns: Cor do caco.
    """
    return py5.random_choice(CORES)


def gera_sementes(
    largura: int,
    altura: int,
    celula_x: int,
    celula_y: int,
) -> tuple[list[tuple[float, float]], int]:
    """Gera as sementes do diagrama de Voronoi.

    As sementes internas vem de uma grade jitterada por noise e bagunca; em
    seguida um anel de sementes-sentinela e adicionado fora da area interna
    para garantir que toda celula visivel seja finita (fechada), dispensando
    o recorte das regioes abertas do Voronoi.

    :param largura: Largura da area interna.
    :param altura: Altura da area interna.
    :param celula_x: Passo horizontal da grade.
    :param celula_y: Passo vertical da grade.
    :returns: Par ``(pontos, n_internas)`` onde as primeiras ``n_internas``
        entradas de ``pontos`` sao as sementes visiveis e o restante e o
        anel-sentinela.
    """
    pontos: list[tuple[float, float]] = []
    for _, x0, _, y0 in cria_grade_ex(largura, altura, 0, 0, celula_x, celula_y, True):
        noise = noise_generator.noise2(x0, y0)
        bx, by = par_bagunca(faixa_bagunca)
        pontos.append((x0 + mult_x * noise + bx, y0 + mult_y * noise + by))
    n_internas = len(pontos)

    passo = max(celula_x, celula_y)
    for c in range(-margem_guarda, largura + margem_guarda + 1, passo):
        pontos.append((c, -margem_guarda))
        pontos.append((c, altura + margem_guarda))
    for c in range(-margem_guarda, altura + margem_guarda + 1, passo):
        pontos.append((-margem_guarda, c))
        pontos.append((largura + margem_guarda, c))
    return pontos, n_internas


def cria_forma_caco(verts: list[tuple[float, float]], cor: int) -> py5.Py5Shape:
    """Monta o prisma 3D de um caco a partir da sua celula de Voronoi.

    Os vertices sao ordenados angularmente em torno do centroide (a celula e
    convexa, logo a ordem angular descreve o poligono simples) e encolhidos em
    direcao ao centroide pelo fator ``1 - borda`` -- a folga entre cacos
    vizinhos revela o fundo, simulando o rejunte. O poligono resultante e
    extrudado em ``profundidade_caco`` ao longo de z: a base fica em ``z = 0``
    (assentada no fundo) e o topo em ``z = profundidade_caco`` (voltado para a
    camera).

    A forma e um ``GROUP`` com uma sub-forma por face -- topo mais uma parede
    por aresta --, cada uma com sua propria ``normal()`` para que a iluminacao
    incida corretamente (uma unica ``normal()`` vale por ``Py5Shape``).

    :param verts: Vertices da celula de Voronoi.
    :param cor: Cor de preenchimento do caco.
    :returns: ``GROUP`` com as faces do prisma, pronto para ser desenhado.
    """
    cx = sum(v[0] for v in verts) / len(verts)
    cy = sum(v[1] for v in verts) / len(verts)
    verts = sorted(verts, key=lambda v: math.atan2(v[1] - cy, v[0] - cx))
    escala = 1 - borda
    pts = [(cx + (vx - cx) * escala, cy + (vy - cy) * escala) for vx, vy in verts]

    grupo = py5.create_shape(py5.GROUP)

    # Topo: face voltada para a camera (normal +z), no nivel z = profundidade.
    topo = py5.create_shape()
    topo.set_fill(cor)
    topo.set_stroke_weight(0)
    with topo.begin_closed_shape():
        topo.normal(0, 0, 1)
        for px, py_ in pts:
            topo.vertex(px, py_, profundidade_caco)
    grupo.add_child(topo)

    # Paredes: uma face por aresta, ligando a base (z=0) ao topo (z=prof).
    n = len(pts)
    for i in range(n):
        x0, y0 = pts[i]
        x1, y1 = pts[(i + 1) % n]
        dx, dy = x1 - x0, y1 - y0
        comp = math.hypot(dx, dy) or 1.0
        nx, ny = dy / comp, -dx / comp  # normal externa, no plano xy
        parede = py5.create_shape()
        parede.set_fill(cor)
        parede.set_stroke_weight(0)
        with parede.begin_closed_shape():
            parede.normal(nx, ny, 0)
            parede.vertex(x0, y0, 0)
            parede.vertex(x1, y1, 0)
            parede.vertex(x1, y1, profundidade_caco)
            parede.vertex(x0, y0, profundidade_caco)
        grupo.add_child(parede)
    return grupo


def popula_cacos(
    largura: int,
    altura: int,
    celula_x: int,
    celula_y: int,
) -> list[py5.Py5Shape]:
    """Constroi os cacos tesselando a area interna com um diagrama de Voronoi.

    :param largura: Largura da area interna.
    :param altura: Altura da area interna.
    :param celula_x: Passo horizontal da grade de sementes.
    :param celula_y: Passo vertical da grade de sementes.
    :returns: Lista de prismas (``GROUP``) prontos para desenho.
    """
    pontos, n_internas = gera_sementes(largura, altura, celula_x, celula_y)
    vor = Voronoi(np.array(pontos))
    cacos = []
    for i in range(n_internas):
        regiao = vor.regions[vor.point_region[i]]
        if not regiao or -1 in regiao:
            # Celula aberta: nao deve ocorrer para sementes internas gracas ao
            # anel-sentinela, mas ignoramos por seguranca.
            continue
        verts = [tuple(vor.vertices[v]) for v in regiao]
        cacos.append(cria_forma_caco(verts, cor_caco()))
    return cacos


def inicializa():
    global cacos
    with report_time("Calculando..."):
        cacos = popula_cacos(*helpers.DIMENSOES.external, celula_x, celula_y)


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


def desenha_fundo(cor: int):
    """Desenha o fundo por baixo dos caquinhos."""
    with py5.push():
        py5.fill(cor)
        py5.no_stroke()
        py5.rect(0, 0, *helpers.DIMENSOES.external)


def draw():
    py5.background(cor_fundo)
    # Posicao da luz no espaco interno: o mouse e screen-space, entao
    # descontamos a origem do painel (pos_interno).
    luz_x = py5.mouse_x - helpers.DIMENSOES.pos_interno[0]
    luz_y = py5.mouse_y - helpers.DIMENSOES.pos_interno[1]
    with py5.push():
        py5.translate(0, 0, -15)
        # Fundo desenhado antes das luzes para manter o rejunte chapado; os
        # prismas se assentam nele (base em z=0) e sobem em direcao a camera.
        desenha_fundo(cor_fundo_interna)
        # Luz pontual seguindo o mouse + ambiente baixa, posicionadas no mesmo
        # espaco dos cacos. Atuam so aqui; o no_lights() abaixo protege o frame.
        py5.ambient_light(
            py5.red(luz_ambiente), py5.green(luz_ambiente), py5.blue(luz_ambiente)
        )
        py5.point_light(
            py5.red(luz_cor),
            py5.green(luz_cor),
            py5.blue(luz_cor),
            luz_x,
            luz_y,
            luz_altura,
        )
        for forma in cacos:
            py5.shape(forma)
    # Apaga as luzes antes do overlay 2D para nao lavar o frame/creditos.
    py5.no_lights()
    msg = (
        f"celula (x - y): {celula_x} - {celula_y} | "
        f"borda: {borda:.2f} | "
        f"bagunca: {faixa_bagunca} | "
        f"prof: {profundidade_caco} | "
        f"luz (x - y): {luz_x} - {luz_y}"
    )
    # Credits and go
    canvas.sketch_frame(
        sketch,
        cor_fundo,
        "large_transparent_white",
        "transparent_white",
        version=2,
        msg=msg,
    )


def key_pressed():
    global celula_x, celula_y, borda, faixa_bagunca, profundidade_caco
    key = py5.key
    match key:
        case "r":
            inicializa()
        case ">" | "<":
            passo = 2 if key == ">" else -2
            celula_x = max(4, celula_x + passo)
            celula_y = max(4, celula_y + passo)
            inicializa()
        case "w" | "s":
            borda = min(0.6, max(0.0, borda + (0.05 if key == "w" else -0.05)))
            inicializa()
        case "+" | "-":
            faixa_bagunca = max(0, faixa_bagunca + (5 if key == "+" else -5))
            inicializa()
        case "e" | "d":
            profundidade_caco = max(0, profundidade_caco + (1 if key == "e" else -1))
            inicializa()
        case " ":
            save_and_close()


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


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