/** * 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; 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 }; }