Hero e movimento - cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py): marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao cursor e, parada no topo, passa sozinha pelos estados com legenda - três níveis de aparelho (full, lite, static); sem WebGL fica o pôster - título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha." - faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções - rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa compartilhada entre a lista de cases e o case Ofertas - atendimento no WhatsApp com IA e nota fiscal para a reforma tributária (páginas de serviço, cartões da home, formulário e aviso com data de validade) - página /ia atualizada, com fontes - peças 3D em loop nos cartões e topos de página (3d/pecas.py) Design - hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta - um destino por rótulo: "Falar sobre meu projeto" leva ao formulário, "Chamar no WhatsApp" abre o WhatsApp - logo montado em blocos, 404 redesenhado, grades sem célula vazia - ícones do PWA e manifesto novos Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
342 lines
14 KiB
Python
342 lines
14 KiB
Python
"""Peças pré-renderizadas dos cartões e do 404: o kit de blocos.py animado em loop de 3 s.
|
|
|
|
Rodar da raiz do repositório: blender -b --python 3d/pecas.py
|
|
Renderiza os quadros em 3d/out/pecas/<nome>/ e o ffmpeg do sistema gera em public/3d/
|
|
peca-<nome>.webm (VP9), .mp4 (H.264) e .webp (pôster). Mesmos bloco, materiais e luz do pôster do hero.
|
|
"""
|
|
|
|
import ctypes
|
|
import math
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
import bpy
|
|
from mathutils import Matrix, Vector
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from blocos import ( # noqa: E402
|
|
LIMAS,
|
|
PUBLICO,
|
|
SAIDA,
|
|
criar_bloco,
|
|
criar_materiais,
|
|
estado_marca,
|
|
falhar,
|
|
ligar_cycles,
|
|
luz_de_estudio,
|
|
sair,
|
|
svg_para_mundo,
|
|
)
|
|
|
|
QUADROS = 72 # 3 s a 24 por segundo; o quadro 72 seria de novo o 0
|
|
LADO = (640, 400)
|
|
TINTA = "0x0a0a0a" # --ink
|
|
AMOSTRAS = 64
|
|
LIMITE_VIDEO, LIMITE_POSTER = 250_000, 40_000
|
|
|
|
|
|
def encaixe(f, inicio, duracao):
|
|
"""0 antes, 1 assentado: chega rápido e assenta com ease-out cúbico, sem passar do ponto."""
|
|
t = min(max((f - inicio) / duracao, 0), 1)
|
|
return 1 - (1 - t) ** 3
|
|
|
|
|
|
# ---------------------------------------------------------------- peças
|
|
# Cada peça recebe novo(posição, escala, tom) e devolve pose(f), que põe os blocos no quadro f.
|
|
# Coordenadas do Blender: X para a direita, -Y para a câmera, Z para cima; bloco de lado 1.
|
|
|
|
|
|
def sistema(novo):
|
|
# planilha deitada de 7 x 5; a linha do fundo, em lima, é o cabeçalho
|
|
for c in range(7):
|
|
for r in range(5):
|
|
novo((c - 3, 2 - r, 0), 1, int(r == 0))
|
|
# as barras moram dentro das células das colunas 1 a 5, logo à frente do cabeçalho, e sobem telescópicas
|
|
barras = [[novo((k - 2, 1, 0), 0.98, int(j == h - 1)) for j in range(h)] for k, h in enumerate((2, 3, 5, 4, 6))]
|
|
|
|
def pose(f):
|
|
for k, pilha in enumerate(barras):
|
|
sobe = encaixe(f, 6 + 4 * k, 8) - encaixe(f, 42 + 4 * k, 8)
|
|
for j, obj in enumerate(pilha):
|
|
obj.location.z = (1 + j) * sobe
|
|
|
|
return pose
|
|
|
|
|
|
def whatsapp(novo):
|
|
# celular em pé de 9 x 16 (x = c - 4, z = 7.5 - r), de cantos arredondados; o miolo vazio é a tela
|
|
for c in range(9):
|
|
for r in range(16):
|
|
borda = (c in (0, 8) or r in (0, 15)) and not (c in (0, 8) and r in (0, 15))
|
|
canto_de_dentro = c in (1, 7) and r in (1, 14)
|
|
if borda or canto_de_dentro or r == 2: # r = 2 fecha a barra de cima
|
|
novo((c - 4, 0, 7.5 - r), 1, 0)
|
|
novo((-2, 0, 6.5), 1, 1) # foto do contato
|
|
for i in range(4):
|
|
novo((-0.8 + 0.7 * i, 0, 6.5), 0.7, 0) # nome, nos blocos menores dos balões, para ler como texto
|
|
|
|
# balões de 6 x 3 blocos menores, alternando os lados; o canto de baixo do lado do balão fica reto (é o rabo)
|
|
p = 0.7
|
|
|
|
def balao(k, esquerda, tom):
|
|
borda = -3.15 if esquerda else 3.15 # parede da tela menos meio bloco de folga
|
|
sentido = 1 if esquerda else -1
|
|
topo = 2.95 - 3.15 * k
|
|
blocos = []
|
|
for i in range(6):
|
|
for j in range(3):
|
|
if i in (0, 5) and j in (0, 2) and (i, j) != (0, 2):
|
|
continue
|
|
alvo = Vector((borda + sentido * (i + 0.5) * p, 0, topo - j * p))
|
|
blocos.append((novo(alvo, p, tom), alvo))
|
|
return blocos, Vector((borda, 0, topo - 2.5 * p))
|
|
|
|
baloes = [balao(0, True, 0), balao(1, False, 1), balao(2, True, 0)]
|
|
pontos = [novo((-2.25 + d, 0, 2.95 - 6.3 - p), 0.55, 0) for d in range(3)] # "digitando", no lugar do terceiro
|
|
|
|
def pose(f):
|
|
some = min(max((f - 60) / 8, 0), 1) ** 3 # somem juntos, encolhendo para o rabo
|
|
for (blocos, rabo), chega in zip(baloes, (2, 14, 44)):
|
|
s = encaixe(f, chega, 8) * (1 - some)
|
|
for obj, alvo in blocos:
|
|
obj.location = rabo + (alvo - rabo) * s
|
|
obj.scale = (p * max(s, 0.001),) * 3
|
|
obj.hide_render = s <= 0
|
|
aparece = encaixe(f, 26, 4) * (1 - encaixe(f, 41, 3))
|
|
for d, obj in enumerate(pontos):
|
|
pulso = max(0.0, math.sin(math.tau * (f - 28) / 8 - d * math.tau / 6)) # um depois do outro
|
|
s = aparece * 0.55 * (1 + 0.45 * pulso)
|
|
obj.scale = (max(s, 0.001),) * 3
|
|
obj.hide_render = s <= 0
|
|
|
|
return pose
|
|
|
|
|
|
def quatrocentos(novo):
|
|
# o quadrado lima mais alto se solta para a frente (passa diante do outro) e cai; volta de cima direto no lugar
|
|
x0, y1 = svg_para_mundo(*LIMAS[2][:2])
|
|
x1, y0 = svg_para_mundo(*LIMAS[2][2:])
|
|
solto = []
|
|
for x, y, z, sx, sy, sz, tom in estado_marca():
|
|
obj = novo((x, -z, y), (sx, sz, sy), tom)
|
|
if tom and x0 < x < x1 and y0 < y < y1:
|
|
solto.append((obj, Vector((x, -z, y))))
|
|
centro = sum((alvo for _, alvo in solto), Vector()) / len(solto)
|
|
|
|
def pose(f):
|
|
if f < 32:
|
|
queda = min(max((f - 12) / 12, 0), 1) ** 2 # ease-in: a gravidade acelera
|
|
d = Vector((0, -1.4 * encaixe(f, 8, 4), -16 * queda))
|
|
giro = Matrix.Rotation(0.6 * queda, 3, "Y") # tomba de lado enquanto cai
|
|
else:
|
|
d = Vector((0, 0, 12 * (1 - encaixe(f, 40, 8))))
|
|
giro = Matrix.Identity(3)
|
|
for obj, alvo in solto:
|
|
obj.location = centro + giro @ (alvo - centro) + d
|
|
obj.rotation_euler = giro.to_euler()
|
|
|
|
return pose
|
|
|
|
|
|
def selo_conferido(novo, x0, z0, y, passo):
|
|
"""Selo lima de 7 x 7 com o V de conferido vazado; (x0, z0) é o centro do bloco de cima à esquerda."""
|
|
desenho = ("....#", "...##", "#.##.", "###..", ".#...")
|
|
vazado = {(1 + i, 1 + j) for j, linha in enumerate(desenho) for i, x in enumerate(linha) if x == "#"}
|
|
blocos = []
|
|
for c in range(7):
|
|
for r in range(7):
|
|
if (c, r) not in vazado:
|
|
alvo = Vector((x0 + c * passo, y, z0 - r * passo))
|
|
blocos.append((novo(alvo, passo, 1), alvo))
|
|
return blocos
|
|
|
|
|
|
def fiscal(novo):
|
|
# nota em pé de 11 x 16; as quatro linhas de texto são vãos na folha, a partir da coluna 1
|
|
linhas = {2: 7, 4: 9, 6: 6, 8: 8}
|
|
for c in range(11):
|
|
for r in range(16):
|
|
if not 1 <= c <= linhas.get(r, 0):
|
|
novo((c - 5, 0, 7.5 - r), 1, 0)
|
|
selo = selo_conferido(novo, -1, -1.5, -1, 1) # à frente do canto de baixo à direita; a folha aparece pelo V
|
|
de_fora = Vector((22, -6, 4)) # chega pela direita, vindo da frente
|
|
para_cima = Vector((6, -4, 20))
|
|
|
|
def pose(f):
|
|
sai = min(max((f - 46) / 10, 0), 1) ** 3 # solta devagar e sai acelerando
|
|
d = de_fora * (1 - encaixe(f, 6, 10)) + para_cima * sai
|
|
for obj, alvo in selo:
|
|
obj.location = alvo + d
|
|
|
|
return pose
|
|
|
|
|
|
REVISAO_CAMERA = (-0.25, -0.95, 0.15)
|
|
|
|
|
|
def revisao(novo):
|
|
# janela de sistema de 13 x 13 (x = c - 6, z = 6 - r): moldura, três pontos, barra e quatro linhas de conteúdo
|
|
linhas = {4: 8, 6: 10, 8: 6, 10: 9}
|
|
# três blocos das linhas, da esquerda para a direita, saltados para a frente e girados: (deslocamento, giro)
|
|
fora = {
|
|
(3, 6): ((-0.4, -2.4, 1.1), (0.6, 0.3, 0.7)),
|
|
(5, 4): ((0.5, -2.0, -0.9), (-0.5, 0.4, -0.6)),
|
|
(7, 10): ((-0.3, -2.6, 0.9), (0.4, -0.6, 0.5)),
|
|
}
|
|
soltos = []
|
|
for c in range(13):
|
|
for r in range(13):
|
|
if c in (0, 12) or r in (0, 2, 12) or (r == 1 and c in (2, 4, 6)) or 2 <= c <= linhas.get(r, 0):
|
|
obj = novo(Vector((c - 6, 0, 6 - r)), 1, 0)
|
|
if (c, r) in fora:
|
|
desloc, giro = fora[(c, r)]
|
|
soltos.append((obj, obj.location.copy(), Vector(desloc), Vector(giro)))
|
|
|
|
# lupa: aro octogonal em volta de um vão de 3 x 3 e cabo na diagonal, para baixo e à direita
|
|
lupa = []
|
|
for dx in range(-2, 3):
|
|
for dz in range(-2, 3):
|
|
if 1.5 < math.hypot(dx, dz) <= 2.6:
|
|
lupa.append((novo((0, 0, 0), 1, 1), Vector((dx, 0, dz))))
|
|
for k in range(3):
|
|
d = (3.0 + 0.95 * k) / math.sqrt(2)
|
|
cabo = novo((0, 0, 0), 1, 1)
|
|
cabo.rotation_euler = (0, math.pi / 4, 0)
|
|
lupa.append((cabo, Vector((d, 0, -d))))
|
|
lima = lupa[0][0].material_slots[0].material
|
|
porcelana = soltos[0][0].material_slots[0].material
|
|
|
|
# a lupa anda 3,5 à frente da janela, na linha de visão de cada bloco solto: o bloco aparece no meio do aro
|
|
rumo = Vector(REVISAO_CAMERA).normalized()
|
|
|
|
def na_frente(p):
|
|
return Vector(p) + rumo * (3.5 / -rumo.y)
|
|
|
|
a, b, c = (alvo for _, alvo, _, _ in soltos)
|
|
caminho = [(0, na_frente((-18, 0, 1))), (9, na_frente(a)), (13, na_frente(a)), (21, na_frente(b)),
|
|
(25, na_frente(b)), (33, na_frente(c)), (37, na_frente(c)), (47, na_frente((28, 0, -3)))] # fmt: skip
|
|
chegadas = (9, 21, 33)
|
|
selo = selo_conferido(novo, 2.3, -3.2, -1, 0.6) # menor, por dentro do canto de baixo à direita
|
|
de_fora = Vector((0, 0, -11)) # chega e sai reto por baixo, sem invadir a direita do quadro
|
|
|
|
def lupa_em(f):
|
|
for (f0, p0), (f1, p1) in zip(caminho, caminho[1:]):
|
|
if f <= f1:
|
|
t = min(max((f - f0) / (f1 - f0), 0), 1)
|
|
return p0.lerp(p1, t * t * (3 - 2 * t))
|
|
return caminho[-1][1]
|
|
|
|
def pose(f):
|
|
centro = lupa_em(f)
|
|
for obj, desvio in lupa:
|
|
obj.location = centro + desvio
|
|
for i, ((obj, alvo, desloc, giro), chega) in enumerate(zip(soltos, chegadas)):
|
|
s = encaixe(f, chega, 6) - encaixe(f, 62 + 2 * i, 5) # no fim voltam a saltar, para o loop emendar
|
|
obj.location = alvo + desloc * (1 - s)
|
|
obj.rotation_euler = giro * (1 - s)
|
|
obj.material_slots[0].material = lima if chega <= f < chega + 10 else porcelana
|
|
d = de_fora * (1 - encaixe(f, 44, 8) + min(max((f - 60) / 6, 0), 1) ** 3)
|
|
for obj, alvo in selo:
|
|
obj.location = alvo + d
|
|
|
|
return pose
|
|
|
|
|
|
# nome, peça, direção da câmera, distância, alvo, quadro do pôster (peça montada)
|
|
PECAS = [
|
|
("sistema", sistema, (-0.5, -0.8, 0.6), 44, (0, 0.5, 2.3), 36),
|
|
("whatsapp", whatsapp, (-0.3, -0.93, 0.18), 64, (-3.8, 0, 0), 56),
|
|
("404", quatrocentos, (-0.42, -0.86, 0.2), 46, (0, 0, 0), 0),
|
|
("fiscal", fiscal, (-0.42, -0.86, 0.2), 72, (0, 0, 0), 36),
|
|
("revisao", revisao, REVISAO_CAMERA, 68, (6.3, 0, -0.6), 20),
|
|
]
|
|
|
|
|
|
# ------------------------------------------------------------- ffmpeg
|
|
|
|
|
|
def ffmpeg(*args):
|
|
fundo = ["-f", "lavfi", "-i", f"color=c={TINTA}:s={LADO[0]}x{LADO[1]}:r=24"]
|
|
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *fundo, *args], check=True)
|
|
|
|
|
|
def codificar(pasta, nome, poster):
|
|
destino = os.path.join(PUBLICO, f"peca-{nome}")
|
|
# quadros com alfa sobre a tinta opaca, em yuv420p BT.709 (o padrão dos navegadores)
|
|
compor = "[0][1]overlay=shortest=1:format=rgb,scale=out_color_matrix=bt709:out_range=tv,format=yuv420p"
|
|
entrada = ["-framerate", "24", "-i", os.path.join(pasta, "%04d.png"), "-filter_complex", compor, "-an"]
|
|
cor = ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
|
|
ffmpeg(*entrada, *cor, "-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-row-mt", "1", "-cpu-used", "1",
|
|
f"{destino}.webm") # fmt: skip
|
|
ffmpeg(*entrada, *cor, "-c:v", "libx264", "-preset", "slow", "-crf", "22", "-movflags", "+faststart",
|
|
f"{destino}.mp4") # fmt: skip
|
|
ffmpeg("-i", poster, "-filter_complex", "[0][1]overlay", "-frames:v", "1", "-c:v", "libwebp", "-quality", "80",
|
|
f"{destino}.webp") # fmt: skip
|
|
for ext, limite in (("webm", LIMITE_VIDEO), ("mp4", LIMITE_VIDEO), ("webp", LIMITE_POSTER)):
|
|
tamanho = os.path.getsize(f"{destino}.{ext}")
|
|
print(f"peca-{nome}.{ext}: {tamanho} bytes", flush=True)
|
|
if tamanho > limite:
|
|
falhar(f"peca-{nome}.{ext} tem {tamanho} bytes, limite {limite}")
|
|
|
|
|
|
# ------------------------------------------------------------- Blender
|
|
|
|
|
|
def main():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
cena = bpy.context.scene
|
|
cena.view_settings.view_transform = "Standard"
|
|
cena.world = bpy.data.worlds.new("fundo")
|
|
cena.render.use_persistent_data = True
|
|
ligar_cycles(cena)
|
|
luz_de_estudio(cena)
|
|
|
|
bloco = criar_bloco()
|
|
cena.collection.objects.unlink(bloco) # a malha fica só nas instâncias
|
|
malha = bloco.data
|
|
bpy.data.objects.remove(bloco)
|
|
materiais = criar_materiais()
|
|
malha.materials.append(materiais[0])
|
|
|
|
camera = bpy.data.objects.new("camera", bpy.data.cameras.new("camera"))
|
|
cena.collection.objects.link(camera)
|
|
camera.data.lens = 85
|
|
cena.camera = camera
|
|
|
|
colecoes = {}
|
|
for nome, peca, direcao, distancia, alvo, quadro_poster in PECAS:
|
|
col = bpy.data.collections.new(nome)
|
|
cena.collection.children.link(col)
|
|
colecoes[nome] = col
|
|
|
|
def novo(posicao, escala, tom, col=col):
|
|
obj = bpy.data.objects.new(f"{col.name}.{len(col.objects):03d}", malha)
|
|
col.objects.link(obj)
|
|
obj.location = posicao
|
|
obj.scale = escala if isinstance(escala, tuple) else (escala,) * 3
|
|
obj.material_slots[0].link = "OBJECT"
|
|
obj.material_slots[0].material = materiais[tom]
|
|
return obj
|
|
|
|
pose = peca(novo)
|
|
for outro, c in colecoes.items():
|
|
c.hide_render = outro != nome
|
|
direcao = Vector(direcao).normalized()
|
|
camera.location = Vector(alvo) + direcao * distancia
|
|
camera.rotation_euler = (-direcao).to_track_quat("-Z", "Y").to_euler()
|
|
|
|
pasta = os.path.join(SAIDA, "pecas", nome)
|
|
os.makedirs(pasta, exist_ok=True)
|
|
for f in range(QUADROS):
|
|
pose(f)
|
|
sair(cena, os.path.join(pasta, f"{f:04d}.png"), LADO, "PNG", "RGBA", AMOSTRAS, True)
|
|
codificar(pasta, nome, os.path.join(pasta, f"{quadro_poster:04d}.png"))
|
|
|
|
ctypes.CDLL(None).fflush(None) # despeja o buffer do C (banner do Blender) antes do OK
|
|
print(f"OK pecas={len(PECAS)}", flush=True)
|
|
sys.exit(0) # sai já, para o OK ser a última linha
|
|
|
|
|
|
main()
|