"""2026-03-25
Esfera de Retalhos 06
Esfera formada por um cilindro com gradientes de cores diversas
ericof.com
png
Sketch,py5,CreativeCoding
"""
from collections import deque
from dataclasses import dataclass
from random import shuffle
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)
z_factor = 0.04
@dataclass
class EsferaRotacao:
x: float = -15
y: float = 45
z: float = 0
dist_z: float = -2000
rotacao = EsferaRotacao()
formas: list[py5.Py5Shape] = []
def gera_caminho_espiral(
raio: float,
num_pontos: int,
n_sub: int,
fase: float = 0.0,
) -> np.ndarray:
"""Gera caminho denso da espiral Fibonacci sobre a superfície da esfera.
Trata o índice como parâmetro contínuo — sem interpolação externa.
Produz (num_pontos - 1) * n_sub + 1 pontos na superfície.
:param n_sub: amostras entre cada par de pontos Fibonacci consecutivos.
:param fase: offset angular em radianos aplicado a theta. Use np.pi para
a segunda hélice — coloca as espirais em lados opostos da esfera
mantendo a suavidade original da trajetória.
:return: array (total, 3)
"""
angulo_dourado = np.pi * (3.0 - np.sqrt(5.0))
total = (num_pontos - 1) * n_sub + 1
t = np.linspace(0.0, num_pontos - 1, total)
y = 1.0 - (2.0 * t) / (num_pontos - 1)
raio_xy = np.sqrt(np.maximum(0.0, 1.0 - y * y))
theta = angulo_dourado * t + fase
coords = np.stack([np.cos(theta) * raio_xy, y, np.sin(theta) * raio_xy], axis=-1)
return coords * raio
def _gera_frames_caminho(centers: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Gera frames locais (normais, binormais) via parallel transport."""
n = len(centers)
tangents = np.empty_like(centers)
tangents[1:-1] = centers[2:] - centers[:-2]
tangents[0] = centers[1] - centers[0]
tangents[-1] = centers[-1] - centers[-2]
tangents /= np.linalg.norm(tangents, axis=-1, keepdims=True).clip(1e-10)
t0 = tangents[0]
up = np.array([0.0, 1.0, 0.0]) if abs(t0[1]) < 0.9 else np.array([1.0, 0.0, 0.0])
normal = np.cross(t0, up)
normal /= np.linalg.norm(normal)
normals = np.empty_like(centers)
normals[0] = normal
for i in range(1, n):
t = tangents[i]
normal = normals[i - 1] - np.dot(normals[i - 1], t) * t
norm = np.linalg.norm(normal)
if norm < 1e-10:
normal = np.cross(t, np.array([0.0, 0.0, 1.0]))
norm = np.linalg.norm(normal)
normals[i] = normal / norm
binormals = np.cross(tangents, normals)
return normals, binormals
def gera_aneis_cilindro(
raio: float,
raio_tubo: float,
num_pontos: int,
num_lados: int,
n_sub: int,
fase: float = 0.0,
) -> np.ndarray:
"""Gera anéis circulares ao longo da espiral Fibonacci densa.
:return: array (total, num_lados, 3)
"""
caminho = gera_caminho_espiral(raio, num_pontos, n_sub, fase)
normals, binormals = _gera_frames_caminho(caminho)
phi = np.linspace(0, 2 * np.pi, num_lados, endpoint=False)
return caminho[:, None, :] + raio_tubo * (
np.cos(phi)[None, :, None] * normals[:, None, :]
+ np.sin(phi)[None, :, None] * binormals[:, None, :]
)
def _cor_int(c: str | int) -> int:
"""Converte valor de paleta para inteiro de cor py5."""
return c if isinstance(c, int) else py5.color(c)
def _monta_grupo_cilindro(
aneis: np.ndarray,
paleta_forma: deque[str | int],
) -> py5.Py5Shape:
"""Monta um GROUP de faces quádruplas a partir de anéis pré-calculados.
Atribui uma cor por anel e usa set_fills/set_strokes por vértice,
produzindo gradiente suave ao longo do eixo do cilindro.
"""
n_aneis, num_lados, _ = aneis.shape
pal = paleta_forma
pal_len = len(pal)
direction = -1
cores_anel = [_cor_int(pal[0])]
for step in range(1, n_aneis):
pal.rotate(direction)
if step % pal_len == 0:
direction = -direction
pal.rotate(direction * (pal_len // 3))
cores_anel.append(_cor_int(pal[0]))
grupo = py5.create_shape(py5.GROUP)
for i in range(n_aneis - 1):
ci, cj = cores_anel[i], cores_anel[i + 1]
bi = lerp_color_rgba(ci, py5.color("#000"), t=0.15)
bj = lerp_color_rgba(cj, py5.color("#000"), t=0.15)
for j in range(num_lados):
j_next = (j + 1) % num_lados
p0, p1 = aneis[i, j], aneis[i, j_next]
p2, p3 = aneis[i + 1, j_next], aneis[i + 1, j]
face = py5.create_shape()
with face.begin_closed_shape():
face.vertex(*p0)
face.vertex(*p1)
face.vertex(*p2)
face.vertex(*p3)
face.set_fills([ci, ci, cj, cj])
face.set_strokes([bi, bi, bj, bj])
grupo.add_child(face)
return grupo
def criar_forma(
raio: float,
num_pontos: int,
paleta_forma: deque[str | int],
raio_tubo: float = 80.0,
num_lados: int = 8,
n_sub: int = 8,
fase: float = 0.0,
) -> py5.Py5Shape:
"""Cria um cilindro em espiral sobre a esfera, do polo N ao polo S."""
aneis = gera_aneis_cilindro(raio, raio_tubo, num_pontos, num_lados, n_sub, fase)
return _monta_grupo_cilindro(aneis, paleta_forma)
def inicializa():
global formas
formas = []
local_paletas = [
deque(gera_paleta("laranja-01", True)),
deque(gera_paleta("tons-azul-01", True)),
]
shuffle(local_paletas)
for paleta, fase in zip(local_paletas, [0.0, np.pi], strict=True):
forma = criar_forma(
800,
60,
paleta,
raio_tubo=15.0,
num_lados=32,
n_sub=8,
fase=fase,
)
formas.append(forma)
def setup():
py5.size(*helpers.DIMENSOES.external, py5.P3D)
inicializa()
def draw():
py5.background(cor_fundo)
with py5.push():
py5.translate(*helpers.DIMENSOES.centro, rotacao.dist_z)
py5.rotate_y(py5.radians(rotacao.y))
py5.rotate_x(py5.radians(rotacao.x))
py5.rotate_z(py5.radians(rotacao.z))
for forma in formas:
py5.shape(forma)
# Créditos e encerramento
canvas.sketch_frame(
sketch,
cor_fundo,
"large_transparent_white",
"transparent_white",
version=2,
)
def key_pressed():
key = py5.key
match py5.key:
case " ":
save_and_close()
case "r":
inicializa()
case "+":
rotacao.dist_z += 50
case "-":
rotacao.dist_z -= 50
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
if key == " ":
save_and_close()
def save_and_close():
py5.no_loop()
canvas.save_sketch_image(sketch)
py5.exit_sketch()
if __name__ == "__main__":
py5.run_sketch()