Files
evandro c7d09d63d4
CI / audit (push) Successful in 10m38s
CI / verify (push) Successful in 13m10s
CI / deploy (push) Successful in 1m50s
CI / e2e (push) Successful in 18m48s
feat(site): blocos 3D da marca, novas ofertas e hero mais abrangente
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.
2026-11-10 03:48:25 -03:00

392 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A parte da cena de blocos que não depende do Three.js: os estados exportados do Blender (`public/3d/blocos.json`,
* gerado por `3d/blocos.py`), qual estado o mergulho mostra e o orçamento de quadro que decide se o aparelho segura a
* cena.
*/
export const ESTADOS = ["marca", "tela", "integracoes", "dados"] as const;
/** Números por bloco em cada estado: x, y, z, escala x, y, z e tom (0 porcelana, 1 lima). Escala 0 = bloco oculto. */
const PASSO = 7;
/** Um `Float32Array` de `n * 7` por estado, na ordem de `ESTADOS`. */
export type Blocos = { n: number; estados: Float32Array[] };
/** Valida o JSON exportado e o separa por estado. Lança se o arquivo não for o que a cena espera. */
export function parseBlocos(json: unknown): Blocos {
if (typeof json !== "object" || json === null)
throw new Error("blocos.json: esperava um objeto");
const { n, passo, estados, dados } = json as Record<string, unknown>;
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0)
throw new Error(`blocos.json: n inválido (${String(n)})`);
if (passo !== PASSO)
throw new Error(`blocos.json: passo ${String(passo)}, esperava ${PASSO}`);
if (
!Array.isArray(estados) ||
estados.length !== ESTADOS.length ||
estados.some((nome, i) => nome !== ESTADOS[i])
)
throw new Error(`blocos.json: estados devem ser ${ESTADOS.join(", ")}`);
const tamanho = n * PASSO;
if (
!Array.isArray(dados) ||
dados.length !== ESTADOS.length * tamanho ||
dados.some((v) => typeof v !== "number")
)
throw new Error(
`blocos.json: dados devem ter ${ESTADOS.length * tamanho} números`,
);
return {
n,
estados: ESTADOS.map(
(_, i) => new Float32Array(dados.slice(i * tamanho, (i + 1) * tamanho)),
),
};
}
/** Grupos de blocos encostados (centros a até 1,2 vez o maior lado do bloco), entre os índices `membros`. */
function grupos(d: Float32Array, membros: number[]) {
const livres = new Set(membros);
const saida: number[][] = [];
for (const inicio of membros) {
if (!livres.delete(inicio)) continue;
const grupo = [inicio];
for (let g = 0; g < grupo.length; g++) {
const a = grupo[g] * PASSO;
const alcance = 1.2 * Math.max(d[a + 3], d[a + 4], d[a + 5]);
for (const j of livres) {
const b = j * PASSO;
const dist = Math.hypot(
d[a] - d[b],
d[a + 1] - d[b + 1],
d[a + 2] - d[b + 2],
);
if (dist <= alcance) {
livres.delete(j);
grupo.push(j);
}
}
}
saida.push(grupo);
}
return saida;
}
/** Centro (média dos centros) de um grupo de blocos. */
function centro(d: Float32Array, grupo: number[]): [number, number, number] {
const c: [number, number, number] = [0, 0, 0];
for (const i of grupo)
for (let k = 0; k < 3; k++) c[k] += d[i * PASSO + k] / grupo.length;
return c;
}
/** Papel de cada bloco na vida em repouso de cada estado. */
export type Papeis = {
/**
* Quatro números por bloco: o quadrado lima da marca (0 a 3, da esquerda para a direita; -1 fora deles), 1 se é do
* botão da tela (senão 0), a posição na linha do núcleo até um nó nas integrações (0 no núcleo, 1 no nó; -1 fora
* das linhas) e a fase da barra dos dados (0 a 1; -1 fora das barras).
*/
vida: Float32Array;
/** Centro do botão da tela, em volta do qual ele pulsa. */
botao: [number, number, number];
/** Fundo da peça dos dados (z da face de trás da camada de base): a barra cresce a partir dele. */
chao: number;
};
/**
* Quem faz o quê na vida em repouso, tirado do tom e da posição de cada estado (sem mexer no JSON): na marca, os
* grupos lima são os quadrados; na tela, o grupo lima é o botão; nas integrações, o maior grupo lima é o núcleo, os
* outros marcam os nós, e as linhas são os blocos de porcelana de uma camada só no caminho do núcleo até um nó; nos
* dados, as barras são as colunas que saem em +Z da camada de base.
*/
export function papeis({ n, estados }: Blocos): Papeis {
const vida = new Float32Array(n * 4).fill(-1);
const visiveis = (d: Float32Array) =>
Array.from({ length: n }, (_, i) => i).filter((i) => d[i * PASSO + 3] > 0);
const lima = (d: Float32Array) =>
visiveis(d).filter((i) => d[i * PASSO + 6] > 0.5);
const [marca, tela, integracoes, dados] = estados;
const quadrados = grupos(marca, lima(marca)).sort(
(a, b) => centro(marca, a)[0] - centro(marca, b)[0],
);
quadrados.forEach((grupo, q) => {
for (const i of grupo) vida[i * 4] = q;
});
for (let i = 0; i < n; i++) vida[i * 4 + 1] = 0;
const botao = lima(tela);
for (const i of botao) vida[i * 4 + 1] = 1;
const [nucleo, ...nos] = grupos(integracoes, lima(integracoes))
.sort((a, b) => b.length - a.length)
.map((grupo) => centro(integracoes, grupo));
const vistos = visiveis(integracoes);
const raios = nos.map(() => [] as [number, number][]);
for (const i of vistos) {
const a = i * PASSO;
if (integracoes[a + 6] > 0.5) continue;
// os nós e o núcleo têm duas camadas; a linha, uma
const empilhado = vistos.some(
(j) =>
j !== i &&
Math.abs(integracoes[a] - integracoes[j * PASSO]) < 1e-3 &&
Math.abs(integracoes[a + 1] - integracoes[j * PASSO + 1]) < 1e-3,
);
if (empilhado) continue;
// o nó cuja reta desde o núcleo passa por este bloco (a menos de 3/4 de bloco), entre o núcleo e o nó
const x = integracoes[a] - nucleo[0];
const y = integracoes[a + 1] - nucleo[1];
nos.forEach(([nx, ny], k) => {
const ux = nx - nucleo[0];
const uy = ny - nucleo[1];
const comprimento = Math.hypot(ux, uy);
const aoLongo = (x * ux + y * uy) / comprimento;
const fora = Math.abs(x * uy - y * ux) / comprimento;
if (
aoLongo > 0 &&
aoLongo < comprimento &&
fora < 0.75 * integracoes[a + 3]
)
raios[k].push([i, aoLongo]);
});
}
for (const raio of raios) {
const ds = raio.map(([, d]) => d);
const min = Math.min(...ds);
const faixa = Math.max(...ds) - min || 1;
for (const [i, d] of raio) vida[i * 4 + 2] = (d - min) / faixa;
}
const base = visiveis(dados);
const fundo = (i: number) => dados[i * PASSO + 2] - dados[i * PASSO + 5] / 2;
const chao = Math.min(...base.map(fundo));
const altos = base.filter((i) => fundo(i) > chao + 1e-3);
const barras = grupos(
dados,
base.filter((i) =>
altos.some(
(j) =>
Math.abs(dados[i * PASSO] - dados[j * PASSO]) < 1e-3 &&
Math.abs(dados[i * PASSO + 1] - dados[j * PASSO + 1]) < 1e-3,
),
),
).sort((a, b) => centro(dados, b)[1] - centro(dados, a)[1]);
barras.forEach((grupo, k) => {
for (const i of grupo) vida[i * 4 + 3] = k / barras.length;
});
return {
vida,
botao: botao.length ? centro(tela, botao) : [0, 0, 0],
chao,
};
}
/**
* Trechos do mergulho em que a peça troca de estado (início, fim, de, para); entre eles ela fica parada. Batem com as
* legendas de `hero-captions.ts` (tela, integrações, dados).
*/
const TRECHOS = [
// a peça só se desmonta quando o título do hero já quase se apagou
[0.16, 0.3, 0, 1],
[0.44, 0.56, 1, 2],
[0.7, 0.82, 2, 3],
] as const;
/** Um estado da peça: parado em `from` (`from === to` e `t === 0`) ou em trânsito de `from` para `to` (`t` de 0 a 1). */
export type Estado = { from: number; to: number; t: number };
/**
* O estado que o progresso do mergulho (0 a 1) mostra. Parado: `from === to` e `t === 0`; em trânsito, `t` vai de 0 a
* 1 com smoothstep dentro do trecho.
*/
export function stateAt(p: number): Estado {
const x = Math.min(1, Math.max(0, p || 0));
let parado = 0;
for (const [inicio, fim, from, to] of TRECHOS) {
if (x <= inicio) break;
if (x < fim) {
const k = (x - inicio) / (fim - inicio);
return { from, to, t: k * k * (3 - 2 * k) };
}
parado = to;
}
return { from: parado, to: parado, t: 0 };
}
/**
* O ciclo em repouso, depois de `clock` segundos parado: `fica` s em cada estado e `troca` s para o seguinte (com
* smoothstep), na ordem marca, tela, integrações, dados e de volta à marca. Começa parado na marca. `wait` diz em
* quantos segundos vem a próxima troca (0 durante uma).
*/
export function cicloAt(
clock: number,
fica: number,
troca: number,
): Estado & { wait: number } {
const ciclo = fica + troca;
const atual = Math.floor(clock / ciclo) % ESTADOS.length;
const dentro = (clock % ciclo) - fica;
if (dentro < 0) return { from: atual, to: atual, t: 0, wait: -dentro };
const k = Math.min(1, dentro / troca);
return {
from: atual,
to: (atual + 1) % ESTADOS.length,
t: k * k * (3 - 2 * k),
wait: 0,
};
}
/** O estado em que a peça está parada, ou `null` em trânsito. */
function paradoEm({ from, to, t }: Estado) {
if (t <= 0) return from;
if (t >= 1) return to;
return null;
}
/** Leva `t` até `alvo`, andando no máximo `passo`; numa ponta, o estado fica parado nela. */
function mover({ from, to, t }: Estado, alvo: number, passo: number): Estado {
const k =
Math.abs(alvo - t) <= passo ? alvo : t + Math.sign(alvo - t) * passo;
if (k <= 0) return { from, to: from, t: 0 };
if (k >= 1) return { from: to, to, t: 0 };
return { from, to, t: k };
}
/**
* Um passo do estado mostrado (`atual`) em direção ao pedido (`alvo`), sem salto: `t` anda no máximo `passo` por
* chamada. Na mesma troca, `t` vai ao do alvo; parado num estado da troca pedida, entra nela por essa ponta; fora
* dela, troca direto para a ponta do pedido mais perto dele; no meio de outra troca, anda para a ponta que é um
* estado do pedido (ou, sem nenhuma, para a mais perto) e segue de lá. É como a rolagem retoma a peça do ponto em
* que o ciclo em repouso a deixou (e o ciclo, da rolagem). Quando chega, devolve exatamente o alvo.
*/
export function seguir(atual: Estado, alvo: Estado, passo: number): Estado {
const a = paradoEm(atual);
const b = paradoEm(alvo);
if (a !== null) {
if (b === a) return { from: a, to: a, t: 0 };
if (b !== null) return mover({ from: a, to: b, t: 0 }, 1, passo);
if (a === alvo.from)
return mover({ from: a, to: alvo.to, t: 0 }, alvo.t, passo);
if (a === alvo.to)
return mover({ from: alvo.from, to: a, t: 1 }, alvo.t, passo);
// fora da troca pedida: direto para a ponta dela onde o alvo está mais perto
const ponta = alvo.t < 0.5 ? alvo.from : alvo.to;
return mover({ from: a, to: ponta, t: 0 }, 1, passo);
}
if (b === null && atual.from === alvo.from && atual.to === alvo.to)
return mover(atual, alvo.t, passo);
// a mesma troca, no sentido contrário: vira para o sentido do alvo
if (b === null && atual.from === alvo.to && atual.to === alvo.from)
return mover(
{ from: alvo.from, to: alvo.to, t: 1 - atual.t },
alvo.t,
passo,
);
// outra troca: vai à ponta que já é um estado do pedido (sem voltar atrás quando o pedido muda), ou à mais perto
const pedidos = b === null ? [alvo.from, alvo.to] : [b];
if (pedidos.includes(atual.to)) return mover(atual, 1, passo);
if (pedidos.includes(atual.from)) return mover(atual, 0, passo);
return mover(atual, atual.t < 0.5 ? 0 : 1, passo);
}
/** O que a peça está montando em cada estado, na legenda abaixo dela; a marca não tem legenda. */
const LEGENDAS = [
null,
"Sites e sistemas",
"Integrações",
"Painéis e dados",
] as const;
/** A legenda do estado (índice de `ESTADOS`), ou `null` sem legenda (a marca, nenhum estado ou um índice inválido). */
export function legendaDe(estado: number | null): string | null {
return estado === null ? null : (LEGENDAS[estado] ?? null);
}
export type FrameVerdict = "pending" | "ok" | "slow";
/**
* Mede se o aparelho segura a cena: recebe o tempo de cada quadro (ms) e devolve `"pending"` até juntar `frames`
* quadros, depois `"slow"` se a média passou de `limitMs` (senão `"ok"`), e repete esse veredito dali em diante.
* Quadros acima de 250 ms (aba trocada, pausa do coletor de lixo) não contam.
*/
export function createFrameBudget(limitMs = 28, frames = 60) {
let soma = 0;
let contados = 0;
let veredito: FrameVerdict = "pending";
return (deltaMs: number): FrameVerdict => {
if (veredito !== "pending" || !(deltaMs <= 250)) return veredito;
soma += deltaMs;
contados += 1;
if (contados >= frames)
veredito = soma / contados > limitMs ? "slow" : "ok";
return veredito;
};
}
/** Retângulo em pixels CSS, relativo ao elemento hospedeiro do canvas. */
export type Rect = { x: number; y: number; width: number; height: number };
/**
* Caixas (unidades da cena) em que a peça é enquadrada. Na `marca`, o quadro do próprio pôster (`3d/blocos.py`: câmera a
* 36 unidades, lente de 85 mm em filme de 36 mm, 36 × 36 / 85 ≈ 15,25 de lado), para o canvas cair sobre ele; nos
* outros estados, 17 x 12.
*/
const CAIXA_MARCA = { width: 15.25, height: 15.25 };
const CAIXA_ESTADOS = { width: 17, height: 12 };
/** A caixa ajustada dentro do retângulo, centrada nele: centro (px) e pixels por unidade. */
function fit(rect: Rect, caixa: { width: number; height: number }) {
return {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
scale: Math.min(rect.width / caixa.width, rect.height / caixa.height),
};
}
/**
* Onde a peça aparece no hospedeiro: centro (`x`, `y`, px a partir do canto de cima à esquerda) e `scale` (px por
* unidade da cena). `w` é o peso do estado `marca` (1 parado nela, 0 nos outros, entre os dois em trânsito): a marca
* vai no retângulo `placement` (ou no hospedeiro inteiro sem ele), os outros estados na área útil `stage` (ou no
* hospedeiro inteiro sem ela).
*/
export function framePiece(
host: { width: number; height: number },
placement: Rect | null,
w: number,
stage?: Rect | null,
) {
const inteiro = { x: 0, y: 0, width: host.width, height: host.height };
const marca = fit(placement ?? inteiro, CAIXA_MARCA);
const resto = fit(stage ?? inteiro, CAIXA_ESTADOS);
const k = Math.min(1, Math.max(0, w || 0));
return {
x: resto.x + (marca.x - resto.x) * k,
y: resto.y + (marca.y - resto.y) * k,
scale: resto.scale + (marca.scale - resto.scale) * k,
};
}
/** Margem (px) entre a caixa dos estados no topo e a borda direita do hospedeiro. */
const MARGEM_ESTADOS = 24;
/**
* Onde tela, integrações e dados ficam no ciclo em repouso do topo (e onde vai a legenda, logo abaixo): dentro da
* caixa da peça, sem a faixa da esquerda (22%), que a marca deixa vazia e que fica perto do texto do hero, e sem
* passar da borda direita do hospedeiro; na proporção da caixa dos estados (17 x 12), centrada na altura da peça.
*/
export function caixaDosEstados(
piece: Rect,
host: { width: number; height: number },
): Rect {
const x = piece.x + piece.width * 0.22;
const width = Math.max(
0,
Math.min(piece.x + piece.width, host.width - MARGEM_ESTADOS) - x,
);
const height = (width * CAIXA_ESTADOS.height) / CAIXA_ESTADOS.width;
return { x, y: piece.y + (piece.height - height) / 2, width, height };
}