"""2026-07-30
Formas Geométricas Redux 06
Exercício de grade de formas geométricas
ericof.com|https://ericof.com/en/sketches/2024-06-29
png
Sketch,py5,CreativeCoding,Vornoi
"""
from opensimplex import OpenSimplex
from scipy.spatial import Voronoi
from shapely import make_valid
from shapely.geometry import Polygon
from sketches.padroes.cacos import gera_sementes
from sketches.padroes.poligonos import vertices_poligono_regular
from sketches.utils.draw import canvas
from sketches.utils.draw.cores.paletas import gera_paleta
from sketches.utils.draw.grade import cria_grade_ex
from sketches.utils.helpers import sketches as helpers
import math
import numpy as np
import py5
sketch = helpers.info_for_sketch(__file__, __doc__)
cor_fundo = py5.color(0)
celula_x: int = 80
celula_y: int = 20
# Escala aplicada ao poligono antes do recorte. Ate ~0.9 o poligono cabe
# inteiro na sua celula (aparecem folgas); a partir de ~1.0 ele transborda e e
# recortado na fronteira de Voronoi, fazendo formas vizinhas compartilharem a
# aresta -- e ai que elas "se interseccionam". ~1.15 mantem o caráter do
# poligono e ja recorta as sobreposicoes.
forma_escala: float = 5.50
alternada: bool = True
preencher: bool = True
# Lados dos poligonos sorteados por posicao (indice (idx + idy) na grade).
LADOS: tuple[int, ...] = (8, 6, 8, 6, 6, 7, 8)
# Jitter opcional das sementes (0 = mosaico geometrico limpo; aumente para
# celulas organicas). mult desloca por noise; bagunca desloca aleatoriamente.
jitter_mult: float = 40.0
jitter_bagunca: int = 2
# Distancia do anel de sementes-sentinela alem da area visivel. Precisa superar
# o deslocamento maximo de uma semente (jitter_mult + jitter_bagunca) para que
# toda celula visivel fique fechada e o recorte nunca falhe por celula aberta.
margem_guarda: int = 300
traco: float = 3.4
cor_traco_preenchido = py5.color(255, 255, 255, 0.8)
traco_preenchido: float = 7.5
# Extrusao 3D: cada forma 2D vira um tronco (base cheia + topo elevado e menor).
# A camera P3D e frontal, entao e a reducao do topo -- e nao a altura sozinha --
# que revela as paredes/bisel e faz nascer o volume. altura desloca o topo em z
# na direcao da camera; reducao_topo encolhe o topo em torno do centroide;
# sombra_parede escurece as paredes rumo ao fundo para o relevo se ler.
altura: float = 55.0
reducao_topo: float = 0.60
sombra_parede: float = 0.8
noise_generator = OpenSimplex(seed=py5.random_int(20_000))
formas: list[py5.Py5Shape] = []
def celula_como_poligono(vor: Voronoi, indice: int) -> Polygon | None:
"""Extrai a celula de Voronoi de uma semente como poligono shapely.
A regiao de Voronoi devolve os vertices sem ordem garantida; como a celula e
convexa, ordena-los angularmente em torno do centroide descreve o poligono
simples. Sementes internas sempre tem celula fechada gracas ao anel-sentinela.
:param vor: Diagrama de Voronoi ja calculado.
:param indice: Indice da semente em ``vor.point_region``.
:returns: A celula como :class:`~shapely.geometry.Polygon` valida, ou
``None`` se a regiao for aberta/degenerada.
"""
regiao = vor.regions[vor.point_region[indice]]
if not regiao or -1 in regiao:
return None
verts = [tuple(vor.vertices[v]) for v in regiao]
cx = sum(v[0] for v in verts) / len(verts)
cy = sum(v[1] for v in verts) / len(verts)
verts.sort(key=lambda v: math.atan2(v[1] - cy, v[0] - cx))
return make_valid(Polygon(verts))
def poligono_na_semente(cx: float, cy: float, lados: int) -> Polygon:
"""Constroi o poligono regular escalado, centrado na semente.
Os vertices vem centrados em ``(tam / 2, tam / 2)``; sao transladados para a
origem e reposicionados em ``(cx, cy)`` para casar com a celula (que vive em
coordenadas absolutas).
:param cx: Coordenada x do centro (a semente).
:param cy: Coordenada y do centro (a semente).
:param lados: Numero de lados do poligono regular.
:returns: O poligono regular escalado como :class:`~shapely.geometry.Polygon`.
"""
tam_x = celula_x * forma_escala
tam_y = celula_y * forma_escala
verts = vertices_poligono_regular(tam_x, tam_y, lados)
verts = verts - [tam_x / 2, tam_y / 2] + [cx, cy]
return make_valid(Polygon(verts))
def forma_extrudada(geom) -> py5.Py5Shape | None:
"""Extruda a interseccao poligono-celula num tronco 3D (base + topo elevado).
A interseccao de dois convexos e um unico poligono convexo; ainda assim
tratamos ``MultiPolygon`` / ``GeometryCollection`` por seguranca, tomando o
maior anel. A partir do anel-base (``z = 0``) gera-se um anel-topo elevado em
``altura`` e encolhido por ``reducao_topo`` em torno do centroide; as paredes
laterais (um ``Py5Shape`` por aresta) ligam base e topo, e uma tampa fecha o
conjunto. O ``GROUP`` resultante e colorido no ``draw`` (tampa cheia, paredes
escurecidas). Retorna ``None`` quando nada sobra (poligono fora da celula).
:param geom: Geometria shapely resultante do ``intersection``.
:returns: O tronco como ``GROUP`` de faces em coordenadas absolutas, ou
``None`` se vazia/degenerada.
"""
if geom.is_empty:
return None
if geom.geom_type == "Polygon":
maior = geom
else:
poligonos = [g for g in getattr(geom, "geoms", []) if g.geom_type == "Polygon"]
if not poligonos:
return None
maior = max(poligonos, key=lambda g: g.area)
anel = list(maior.exterior.coords[:-1])
if len(anel) < 3:
return None
cx = sum(x for x, _ in anel) / len(anel)
cy = sum(y for _, y in anel) / len(anel)
base = [(x, y, 0.0) for x, y in anel]
topo = [
(cx + reducao_topo * (x - cx), cy + reducao_topo * (y - cy), altura)
for x, y in anel
]
grupo = py5.create_shape(py5.GROUP)
n = len(anel)
for i in range(n):
j = (i + 1) % n
parede = py5.create_shape()
with parede.begin_closed_shape():
parede.vertex(*base[i])
parede.vertex(*base[j])
parede.vertex(*topo[j])
parede.vertex(*topo[i])
grupo.add_child(parede)
tampa = py5.create_shape()
with tampa.begin_closed_shape():
for vertice in topo:
tampa.vertex(*vertice)
grupo.add_child(tampa)
return grupo
def inicializa():
global formas
grade_ex = cria_grade_ex(
*helpers.DIMENSOES.external, 0, 0, celula_x, celula_y, alternada=alternada
)
grade = [(x, y) for _, x, _, y in grade_ex]
lados_por_semente = [LADOS[(idx + idy) % len(LADOS)] for idx, _, idy, _ in grade_ex]
pontos, n_internas = gera_sementes(
*helpers.DIMENSOES.external,
celula_x,
celula_y,
grade,
jitter_mult,
jitter_mult,
jitter_bagunca,
margem_guarda,
noise_generator,
)
vor = Voronoi(np.array(pontos))
formas = []
for i in range(n_internas):
celula = celula_como_poligono(vor, i)
if celula is None:
continue
cx, cy = pontos[i]
poligono = poligono_na_semente(cx, cy, lados_por_semente[i])
forma = forma_extrudada(poligono.intersection(celula))
if forma is not None:
formas.append(forma)
def setup():
py5.size(*helpers.DIMENSOES.external, py5.P3D)
inicializa()
def estiliza(forma: py5.Py5Shape, cor) -> None:
"""Aplica cor e traco as faces de um tronco extrudado e o desenha.
Percorre as faces do ``GROUP``: as paredes (todas menos a ultima) recebem
``cor`` escurecida rumo ao fundo por ``sombra_parede``, para o relevo se ler;
a tampa (ultima face) recebe ``cor`` cheia. Em modo contorno (``preencher``
falso) todas as faces ficam so com traco em ``cor``.
:param forma: O ``GROUP`` de faces (paredes + tampa) a estilizar.
:param cor: A cor base da forma (entrada corrente da paleta).
"""
n = forma.get_child_count()
cor_parede = py5.lerp_color(cor, cor_fundo, sombra_parede)
for i in range(n):
filho = forma.get_child(i)
if preencher:
filho.set_fill(cor_parede if i < n - 1 else cor)
filho.set_stroke(cor_traco_preenchido)
filho.set_stroke_weight(traco_preenchido)
else:
filho.set_fill(False)
filho.set_stroke(cor)
filho.set_stroke_weight(traco)
py5.shape(forma)
def draw():
py5.background(cor_fundo)
paleta = gera_paleta("south-africa", True)
with py5.push():
py5.translate(0, 0, -150)
for forma in formas:
estiliza(forma, paleta[0])
paleta.rotate(1)
msg = (
f"escala: {forma_escala:.2f} | altura: {altura:.0f} | "
f"topo: {reducao_topo:.2f} | celulas: {len(formas)}"
)
# Credits and go
canvas.sketch_frame(
sketch,
cor_fundo,
"large_transparent_white",
"transparent_white",
version=2,
msg=msg,
)
def key_pressed():
global forma_escala, preencher, jitter_bagunca
global altura, reducao_topo, sombra_parede
key = py5.key
match key:
case " ":
save_and_close()
case "r":
inicializa()
case "f":
preencher = not preencher
case "+" | "-":
forma_escala = max(0.5, forma_escala + (0.05 if key == "+" else -0.05))
inicializa()
case "j" | "k":
jitter_bagunca = max(0, jitter_bagunca + (5 if key == "j" else -5))
inicializa()
case "a" | "z":
altura = max(0.0, altura + (5.0 if key == "a" else -5.0))
inicializa()
case "e" | "d":
reducao_topo = min(
1.0, max(0.30, reducao_topo + (0.05 if key == "e" else -0.05))
)
inicializa()
case "s" | "x":
sombra_parede = min(
0.9, max(0.0, sombra_parede + (0.05 if key == "s" else -0.05))
)
def save_and_close():
py5.no_loop()
canvas.save_sketch_image(sketch)
py5.exit_sketch()
if __name__ == "__main__":
py5.run_sketch()