"""2026-06-21
Caquinhos Redux 03
Homenagem aos pisos de caquinhos de São Paulo (Usando Voronoi).
ericof.com|https://bsky.app/profile/cefzanella.bsky.social/post/3mokxh3ae3s2o
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), 12),
(py5.color(218, 160, 57), 3),
(py5.color(0), 1),
)
celula_x = 20
celula_y = 20
mult_x = 15
mult_y = 15
borda = 0.15
faixa_bagunca = 40
profundidade_caco = 8
# 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 = 100
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.internal, 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.internal)
def draw():
py5.background(cor_fundo)
with py5.push():
py5.translate(*helpers.DIMENSOES.pos_interno, -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)
py5.lights()
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}"
)
# 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()