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.
392 lines
15 KiB
TypeScript
392 lines
15 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|