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.
436 lines
16 KiB
TypeScript
436 lines
16 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import {
|
|
caixaDosEstados,
|
|
cicloAt,
|
|
createFrameBudget,
|
|
ESTADOS,
|
|
type Estado,
|
|
framePiece,
|
|
legendaDe,
|
|
papeis,
|
|
parseBlocos,
|
|
seguir,
|
|
stateAt,
|
|
} from "./blocos";
|
|
|
|
/** O arquivo que o Blender exporta (`3d/blocos.py`) e a cena baixa. */
|
|
const real = () =>
|
|
JSON.parse(
|
|
readFileSync(join(process.cwd(), "public/3d/blocos.json"), "utf8"),
|
|
) as { n: number; passo: number; estados: string[]; dados: number[] };
|
|
|
|
describe("stateAt (which block state the dive progress shows)", () => {
|
|
it("parks on marca at the top of the dive", () => {
|
|
expect(stateAt(0)).toEqual({ from: 0, to: 0, t: 0 });
|
|
expect(stateAt(0.1)).toEqual({ from: 0, to: 0, t: 0 });
|
|
expect(stateAt(0.16)).toEqual({ from: 0, to: 0, t: 0 });
|
|
});
|
|
|
|
it("is halfway from marca to tela in the middle of the first stretch", () => {
|
|
const { from, to, t } = stateAt(0.23);
|
|
expect([from, to]).toEqual([0, 1]);
|
|
expect(t).toBeCloseTo(0.5, 6);
|
|
});
|
|
|
|
it.each([
|
|
[0.35, 1],
|
|
[0.63, 2],
|
|
[0.93, 3],
|
|
[1, 3],
|
|
])("parks at %p on state %p", (p, state) => {
|
|
expect(stateAt(p)).toEqual({ from: state, to: state, t: 0 });
|
|
});
|
|
|
|
it("clamps the progress to 0..1", () => {
|
|
expect(stateAt(-3)).toEqual({ from: 0, to: 0, t: 0 });
|
|
expect(stateAt(7)).toEqual({ from: 3, to: 3, t: 0 });
|
|
expect(stateAt(Number.NaN)).toEqual({ from: 0, to: 0, t: 0 });
|
|
});
|
|
|
|
it("eases in and out inside a stretch (smoothstep)", () => {
|
|
expect(stateAt(0.44 + 0.12 * 0.25).t).toBeCloseTo(0.15625, 6);
|
|
expect(stateAt(0.7 + 0.12 * 0.75).t).toBeCloseTo(0.84375, 6);
|
|
});
|
|
|
|
it("never goes back as the progress grows", () => {
|
|
let previous = -1;
|
|
for (let i = 0; i <= 1000; i++) {
|
|
const { from, to, t } = stateAt(i / 1000);
|
|
const position = from + (to - from) * t;
|
|
expect(position).toBeGreaterThanOrEqual(previous);
|
|
previous = position;
|
|
}
|
|
expect(previous).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("cicloAt (the resting cycle: time parked -> state)", () => {
|
|
it("rests on marca first, then goes marca, tela, integracoes, dados and back, 3 s on each", () => {
|
|
expect(cicloAt(0, 3, 1.2)).toEqual({ from: 0, to: 0, t: 0, wait: 3 });
|
|
expect(cicloAt(2, 3, 1.2)).toEqual({ from: 0, to: 0, t: 0, wait: 1 });
|
|
const indo = cicloAt(3.6, 3, 1.2);
|
|
expect([indo.from, indo.to, indo.wait]).toEqual([0, 1, 0]);
|
|
expect(indo.t).toBeCloseTo(0.5, 6);
|
|
const parados = [4.3, 8.5, 12.7, 16.9].map((clock) =>
|
|
cicloAt(clock, 3, 1.2),
|
|
);
|
|
expect(parados.map(({ from, to, t }) => [from, to, t])).toEqual([
|
|
[1, 1, 0],
|
|
[2, 2, 0],
|
|
[3, 3, 0],
|
|
[0, 0, 0],
|
|
]);
|
|
// de dados volta direto à marca
|
|
const volta = cicloAt(4.2 * 3 + 3.6, 3, 1.2);
|
|
expect([volta.from, volta.to]).toEqual([3, 0]);
|
|
});
|
|
|
|
it("says how long until the next change while resting", () => {
|
|
expect(cicloAt(4.2 + 1, 3, 1.2).wait).toBeCloseTo(2, 6);
|
|
expect(cicloAt(5, 4, 1.2).wait).toBe(0); // 4 s no lite, já na troca
|
|
});
|
|
});
|
|
|
|
describe("seguir (the scroll taking the piece back from the cycle, and the cycle from the scroll)", () => {
|
|
const parado = (e: number): Estado => ({ from: e, to: e, t: 0 });
|
|
/** Passos de `passo` até alcançar o alvo; devolve os estados do caminho. */
|
|
const caminho = (atual: Estado, alvo: Estado, passo = 0.1) => {
|
|
const passos = [atual];
|
|
for (let i = 0; i < 100; i++) {
|
|
const proximo = seguir(passos[passos.length - 1], alvo, passo);
|
|
passos.push(proximo);
|
|
if (
|
|
proximo.from === alvo.from &&
|
|
proximo.to === alvo.to &&
|
|
proximo.t === alvo.t
|
|
)
|
|
return passos;
|
|
}
|
|
throw new Error("não alcançou o alvo");
|
|
};
|
|
|
|
it("stays put when it already shows the target", () => {
|
|
expect(seguir(parado(2), parado(2), 0.1)).toEqual(parado(2));
|
|
});
|
|
|
|
it("goes straight from a resting state to another one, one step at a time", () => {
|
|
expect(seguir(parado(1), parado(0), 0.1)).toEqual({
|
|
from: 1,
|
|
to: 0,
|
|
t: 0.1,
|
|
});
|
|
const passos = caminho(parado(3), parado(0), 0.25);
|
|
expect(passos.map(({ t }) => t)).toEqual([0, 0.25, 0.5, 0.75, 0]);
|
|
expect(passos[passos.length - 1]).toEqual(parado(0));
|
|
});
|
|
|
|
it("enters a scroll transition by the end it shares with it, without a jump", () => {
|
|
const alvo = { from: 0, to: 1, t: 0.35 };
|
|
expect(seguir(parado(0), alvo, 0.1)).toEqual({ from: 0, to: 1, t: 0.1 });
|
|
expect(seguir(parado(1), alvo, 0.1)).toEqual({ from: 0, to: 1, t: 0.9 });
|
|
expect(seguir({ from: 0, to: 1, t: 0.3 }, alvo, 0.1)).toEqual(alvo);
|
|
});
|
|
|
|
it("turns a transition running the other way around instead of restarting it", () => {
|
|
expect(
|
|
seguir({ from: 1, to: 0, t: 0.25 }, { from: 0, to: 1, t: 0.3 }, 0.25),
|
|
).toEqual({ from: 0, to: 1, t: 0.5 });
|
|
});
|
|
|
|
it("finishes a transition that leads elsewhere at its nearest end, then heads for the target", () => {
|
|
// o ciclo indo de integrações para dados, e a rolagem pede a marca
|
|
const passos = caminho({ from: 2, to: 3, t: 0.8 }, parado(0));
|
|
expect(passos[1]).toEqual({ from: 2, to: 3, t: 0.9 });
|
|
expect(passos).toContainEqual(parado(3));
|
|
expect(passos[passos.length - 1]).toEqual(parado(0));
|
|
// a troca em curso já vai para o alvo (ou sai dele): só anda nela
|
|
expect(seguir({ from: 3, to: 0, t: 0.5 }, parado(0), 0.1)).toEqual({
|
|
from: 3,
|
|
to: 0,
|
|
t: 0.6,
|
|
});
|
|
expect(seguir({ from: 0, to: 1, t: 0.5 }, parado(0), 0.1)).toEqual({
|
|
from: 0,
|
|
to: 1,
|
|
t: 0.4,
|
|
});
|
|
});
|
|
|
|
it("moves t by at most one step per call", () => {
|
|
const passos = caminho({ from: 1, to: 2, t: 0.45 }, parado(3), 0.05);
|
|
for (let i = 1; i < passos.length; i++) {
|
|
const [a, b] = [passos[i - 1], passos[i]];
|
|
if (a.from === b.from && a.to === b.to)
|
|
expect(Math.abs(b.t - a.t)).toBeLessThanOrEqual(0.05 + 1e-9);
|
|
// ou parou numa ponta, ou começou uma troca nova a partir dela
|
|
else if (b.t !== 0) expect([b.from, b.t]).toEqual([a.to, 0.05]);
|
|
}
|
|
});
|
|
|
|
it("keeps heading for the scroll when its request moves on mid-way (no back and forth)", () => {
|
|
// o ciclo parado nas integrações; a rolagem pede a marca e, logo depois, a troca da marca para a tela
|
|
let atual = seguir(parado(2), parado(0), 0.1);
|
|
expect(atual).toEqual({ from: 2, to: 0, t: 0.1 });
|
|
const alvo = { from: 0, to: 1, t: 0.9 };
|
|
const vistos = [atual];
|
|
for (let i = 0; i < 40; i++) {
|
|
atual = seguir(atual, alvo, 0.1);
|
|
vistos.push(atual);
|
|
}
|
|
expect(atual).toEqual(alvo);
|
|
// nunca volta para as integrações
|
|
expect(vistos.slice(1)).not.toContainEqual(parado(2));
|
|
});
|
|
});
|
|
|
|
describe("caixaDosEstados (where tela, integracoes and dados sit in the resting cycle)", () => {
|
|
const host = { width: 1440, height: 900 };
|
|
|
|
it("drops the left 22% of the piece box, keeps the 17 x 12 proportion and the height center of the piece", () => {
|
|
const caixa = caixaDosEstados(
|
|
{ x: 700, y: 100, width: 600, height: 600 },
|
|
host,
|
|
);
|
|
expect(caixa.x).toBeCloseTo(832, 6);
|
|
expect(caixa.width).toBeCloseTo(468, 6);
|
|
expect(caixa.height).toBeCloseTo((468 * 12) / 17, 6);
|
|
expect(caixa.y + caixa.height / 2).toBeCloseTo(400, 6);
|
|
});
|
|
|
|
it("stops 24 px short of the right edge when the piece box runs past it", () => {
|
|
const caixa = caixaDosEstados(
|
|
{ x: 694, y: -4, width: 795, height: 795 },
|
|
host,
|
|
);
|
|
expect(caixa.x + caixa.width).toBeCloseTo(1416, 6);
|
|
});
|
|
});
|
|
|
|
describe("legendaDe (the caption under the piece)", () => {
|
|
it.each([
|
|
[0, null],
|
|
[1, "Sites e sistemas"],
|
|
[2, "Integrações"],
|
|
[3, "Painéis e dados"],
|
|
[null, null],
|
|
[7, null],
|
|
])("state %p -> %p", (estado, texto) => {
|
|
expect(legendaDe(estado)).toBe(texto);
|
|
});
|
|
});
|
|
|
|
describe("parseBlocos (the block states exported from Blender)", () => {
|
|
it("reads the real file: 238 blocks, one vector of n * 7 per state", () => {
|
|
const blocos = parseBlocos(real());
|
|
|
|
expect(blocos.n).toBe(238);
|
|
expect(blocos.estados).toHaveLength(ESTADOS.length);
|
|
for (const estado of blocos.estados) {
|
|
expect(estado).toBeInstanceOf(Float32Array);
|
|
expect(estado).toHaveLength(238 * 7);
|
|
}
|
|
// o primeiro bloco da marca: x, y, z, escala e tom, na ordem do arquivo
|
|
const json = real();
|
|
expect(Array.from(blocos.estados[0].slice(0, 7))).toEqual(
|
|
Array.from(new Float32Array(json.dados.slice(0, 7))),
|
|
);
|
|
// o segundo estado começa logo depois dos n blocos do primeiro
|
|
expect(blocos.estados[1][0]).toBe(Math.fround(json.dados[238 * 7]));
|
|
});
|
|
|
|
it.each([
|
|
["not an object", null, /objeto/],
|
|
[
|
|
"states out of order",
|
|
{ ...real(), estados: ["tela", "marca", "integracoes", "dados"] },
|
|
/estados/,
|
|
],
|
|
[
|
|
"a short data array",
|
|
{ ...real(), dados: real().dados.slice(7) },
|
|
/dados/,
|
|
],
|
|
["a different step", { ...real(), passo: 8 }, /passo/],
|
|
["no block count", { ...real(), n: 0 }, /n/],
|
|
])("throws a clear error for %s", (_label, json, message) => {
|
|
expect(() => parseBlocos(json)).toThrow(message);
|
|
});
|
|
});
|
|
|
|
describe("papeis (which blocks move in each resting state, from the real file)", () => {
|
|
const blocos = parseBlocos(real());
|
|
const { vida, botao, chao } = papeis(blocos);
|
|
const papel = (estado: number) =>
|
|
Array.from({ length: blocos.n }, (_, i) => vida[i * 4 + estado]);
|
|
/** Os índices dos blocos visíveis do estado com o papel que `filtro` aceita. */
|
|
const com = (estado: number, filtro: (valor: number) => boolean) =>
|
|
papel(estado).flatMap((valor, i) =>
|
|
blocos.estados[estado][i * 7 + 3] > 0 && filtro(valor) ? [i] : [],
|
|
);
|
|
const tom = (estado: number, i: number) => blocos.estados[estado][i * 7 + 6];
|
|
|
|
it("splits the lime blocks of marca into its four squares, left to right", () => {
|
|
const marca = blocos.estados[0];
|
|
const quadrados = [0, 1, 2, 3].map((q) => com(0, (v) => v === q));
|
|
|
|
expect(quadrados.map((q) => q.length)).toEqual([40, 32, 18, 18]);
|
|
expect(com(0, (v) => v > 3)).toEqual([]);
|
|
// todo bloco lima visível está num quadrado, e só eles
|
|
expect(com(0, (v) => v >= 0).sort((a, b) => a - b)).toEqual(
|
|
com(0, () => true).filter((i) => tom(0, i) === 1),
|
|
);
|
|
const xs = quadrados.map(
|
|
(q) => q.reduce((soma, i) => soma + marca[i * 7], 0) / q.length,
|
|
);
|
|
expect(xs).toEqual([...xs].sort((a, b) => a - b));
|
|
});
|
|
|
|
it("marks the lime button of tela and its center", () => {
|
|
const tela = blocos.estados[1];
|
|
const botoes = com(1, (v) => v === 1);
|
|
|
|
expect(botoes).toHaveLength(12);
|
|
for (const i of botoes) expect(tom(1, i)).toBe(1);
|
|
const x = botoes.reduce((soma, i) => soma + tela[i * 7], 0) / 12;
|
|
expect(botao[0]).toBeCloseTo(x, 5);
|
|
// o botão fica na metade de baixo e à esquerda da tela
|
|
expect(botao[0]).toBeLessThan(0);
|
|
expect(botao[1]).toBeLessThan(0);
|
|
});
|
|
|
|
it("lays the porcelain line blocks of integracoes from the core (0) to the nodes (1)", () => {
|
|
const integracoes = blocos.estados[2];
|
|
const linhas = com(2, (v) => v >= 0);
|
|
|
|
expect(linhas.length).toBeGreaterThan(30);
|
|
for (const i of linhas) expect(tom(2, i)).toBe(0);
|
|
expect(Math.min(...linhas.map((i) => vida[i * 4 + 2]))).toBe(0);
|
|
expect(Math.max(...linhas.map((i) => vida[i * 4 + 2]))).toBe(1);
|
|
// mais longe do centro, mais adiante na linha (o núcleo fica na origem)
|
|
const raio = (i: number) =>
|
|
Math.hypot(integracoes[i * 7], integracoes[i * 7 + 1]);
|
|
const perto = linhas.filter((i) => vida[i * 4 + 2] < 0.2);
|
|
const longe = linhas.filter((i) => vida[i * 4 + 2] > 0.8);
|
|
expect(Math.max(...perto.map(raio))).toBeLessThan(
|
|
Math.min(...longe.map(raio)),
|
|
);
|
|
// seis linhas, uma por nó: cada uma começa no núcleo
|
|
expect(perto.filter((i) => vida[i * 4 + 2] === 0)).toHaveLength(6);
|
|
});
|
|
|
|
it("gives each bar of dados its own phase and the floor it grows from", () => {
|
|
const dados = blocos.estados[3];
|
|
const barras = com(3, (v) => v >= 0);
|
|
const fases = [...new Set(barras.map((i) => vida[i * 4 + 3]))].sort();
|
|
|
|
expect(fases).toEqual([0, 0.2, 0.4, 0.6, 0.8].map(Math.fround));
|
|
// cinco barras de duas colunas, com 3, 5, 2, 6 e 4 camadas
|
|
expect(barras).toHaveLength(40);
|
|
for (const i of com(3, () => true))
|
|
expect(dados[i * 7 + 2] - dados[i * 7 + 5] / 2).toBeGreaterThanOrEqual(
|
|
chao - 1e-6,
|
|
);
|
|
// só as barras saem da camada de base
|
|
const alto = (i: number) =>
|
|
dados[i * 7 + 2] - dados[i * 7 + 5] / 2 > chao + 1e-3;
|
|
expect(com(3, (v) => v < 0).filter(alto)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("createFrameBudget (does this device keep up with the scene?)", () => {
|
|
const feed = (budget: (delta: number) => string, deltas: number[]) =>
|
|
deltas.map((delta) => budget(delta));
|
|
|
|
it("is pending until 60 frames, then ok for 16 ms frames, and keeps that verdict", () => {
|
|
const budget = createFrameBudget();
|
|
const verdicts = feed(budget, Array(60).fill(16));
|
|
|
|
expect(verdicts.slice(0, 59).every((v) => v === "pending")).toBe(true);
|
|
expect(verdicts[59]).toBe("ok");
|
|
expect(budget(200)).toBe("ok");
|
|
});
|
|
|
|
it("is slow when 60 frames average more than 28 ms", () => {
|
|
const budget = createFrameBudget();
|
|
const verdicts = feed(budget, Array(60).fill(40));
|
|
|
|
expect(verdicts[58]).toBe("pending");
|
|
expect(verdicts[59]).toBe("slow");
|
|
expect(budget(16)).toBe("slow");
|
|
});
|
|
|
|
it("ignores a stall above 250 ms (a tab switch, a GC pause) instead of counting it", () => {
|
|
const budget = createFrameBudget();
|
|
const verdicts = feed(budget, [
|
|
...Array(30).fill(16),
|
|
900,
|
|
...Array(29).fill(16),
|
|
]);
|
|
|
|
expect(verdicts.at(-1)).toBe("pending");
|
|
expect(budget(16)).toBe("ok");
|
|
});
|
|
|
|
it("takes a custom limit and frame count", () => {
|
|
const budget = createFrameBudget(10, 2);
|
|
expect(budget(12)).toBe("pending");
|
|
expect(budget(12)).toBe("slow");
|
|
});
|
|
});
|
|
|
|
describe("framePiece (where the piece sits in the host)", () => {
|
|
const host = { width: 1700, height: 900 };
|
|
const placement = { x: 1000, y: 100, width: 520, height: 650 };
|
|
|
|
it("fits the 17 x 12 box in the whole host, centered, away from marca (w = 0)", () => {
|
|
const expected = { x: 850, y: 450, scale: 75 }; // min(1700 / 17, 900 / 12)
|
|
expect(framePiece(host, null, 0)).toEqual(expected);
|
|
expect(framePiece(host, placement, 0)).toEqual(expected);
|
|
});
|
|
|
|
it("fits the poster box (15.25 x 15.25) in the whole host when there is no placement (w = 1)", () => {
|
|
const { x, y, scale } = framePiece(host, null, 1);
|
|
expect([x, y]).toEqual([850, 450]);
|
|
expect(scale).toBeCloseTo(900 / 15.25, 9);
|
|
});
|
|
|
|
it("fits the poster box inside the placement, centered on it (w = 1)", () => {
|
|
const { x, y, scale } = framePiece(host, placement, 1);
|
|
expect([x, y]).toEqual([1260, 425]);
|
|
expect(scale).toBeCloseTo(520 / 15.25, 9);
|
|
});
|
|
|
|
it("interpolates center and size in between, and clamps w", () => {
|
|
const half = framePiece(host, placement, 0.5);
|
|
expect(half.x).toBeCloseTo((850 + 1260) / 2, 9);
|
|
expect(half.y).toBeCloseTo((450 + 425) / 2, 9);
|
|
expect(half.scale).toBeCloseTo((75 + 520 / 15.25) / 2, 9);
|
|
expect(framePiece(host, placement, 2)).toEqual(
|
|
framePiece(host, placement, 1),
|
|
);
|
|
expect(framePiece(host, placement, -1)).toEqual(
|
|
framePiece(host, placement, 0),
|
|
);
|
|
});
|
|
|
|
it("fits the 17 x 12 box in the useful stage area, centered on it, when one is given (w = 0)", () => {
|
|
const stage = { x: 86, y: 84, width: 1267, height: 546 };
|
|
const { x, y, scale } = framePiece(host, placement, 0, stage);
|
|
expect([x, y]).toEqual([86 + 1267 / 2, 84 + 546 / 2]);
|
|
expect(scale).toBeCloseTo(546 / 12, 9); // min(1267 / 17, 546 / 12)
|
|
// a marca continua no retângulo da peça
|
|
expect(framePiece(host, placement, 1, stage)).toEqual(
|
|
framePiece(host, placement, 1),
|
|
);
|
|
});
|
|
|
|
it("works in a portrait host (the width limits the box)", () => {
|
|
expect(framePiece({ width: 390, height: 844 }, null, 0)).toEqual({
|
|
x: 195,
|
|
y: 422,
|
|
scale: 390 / 17,
|
|
});
|
|
});
|
|
});
|