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.
This commit is contained in:
1 parent
4671837c44
commit
c7d09d63d4
165 files changed
+7940
-3790
No files matched your search
@@ -0,0 +1,614 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import type {
|
||||
InstancedBufferGeometry,
|
||||
Mesh,
|
||||
ShaderMaterial,
|
||||
Texture,
|
||||
} from "three";
|
||||
import { stateAt } from "@/lib/blocos";
|
||||
import { isKnownSlowScene, isKnownSoftwareWebGL } from "@/lib/device";
|
||||
import BlocosScene from "./BlocosScene";
|
||||
|
||||
/*
|
||||
* A cena de blocos: baixa os ativos, cria o renderizador do Three.js, compila o shader em tarefas separadas e desenha
|
||||
* sob demanda. O WebGL do jsdom não existe: o renderizador é um dublê, e os ativos (o bloco do glTF, os matcaps e o
|
||||
* JSON dos estados) vêm de dublês também; o JSON é o arquivo real.
|
||||
*/
|
||||
|
||||
type RendererOptions = {
|
||||
alpha?: boolean;
|
||||
antialias?: boolean;
|
||||
powerPreference?: string;
|
||||
};
|
||||
const mockRenderers: MockRenderer[] = [];
|
||||
/** O nome do renderizador que o contexto de teste conta (WEBGL_debug_renderer_info); `null` = a extensão não existe. */
|
||||
let mockRendererName: string | null = null;
|
||||
|
||||
class MockRenderer {
|
||||
domElement = document.createElement("canvas");
|
||||
pixelRatio = 1;
|
||||
options: RendererOptions;
|
||||
render = jest.fn();
|
||||
compileAsync = jest.fn(() => Promise.resolve());
|
||||
dispose = jest.fn();
|
||||
forceContextLoss = jest.fn();
|
||||
setSize = jest.fn();
|
||||
setPixelRatio = (value: number) => {
|
||||
this.pixelRatio = value;
|
||||
};
|
||||
getPixelRatio = () => this.pixelRatio;
|
||||
getContext = () => ({
|
||||
getExtension: (name: string) =>
|
||||
name === "WEBGL_debug_renderer_info" && mockRendererName !== null
|
||||
? { UNMASKED_RENDERER_WEBGL: 37446 }
|
||||
: null,
|
||||
getParameter: (parameter: number) =>
|
||||
parameter === 37446 ? mockRendererName : null,
|
||||
});
|
||||
constructor(options: RendererOptions) {
|
||||
this.options = options;
|
||||
mockRenderers.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
jest.mock("three", () => {
|
||||
const three = jest.requireActual("three");
|
||||
return {
|
||||
...three,
|
||||
WebGLRenderer: jest.fn(
|
||||
(options: RendererOptions) => new MockRenderer(options),
|
||||
),
|
||||
// o jsdom não carrega imagens: o matcap é uma textura vazia
|
||||
TextureLoader: jest.fn(() => ({
|
||||
loadAsync: () => Promise.resolve(new three.Texture()),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("three/examples/jsm/loaders/GLTFLoader.js", () => {
|
||||
const { BoxGeometry, Group, Mesh } = jest.requireActual("three");
|
||||
return {
|
||||
GLTFLoader: jest.fn(() => ({
|
||||
loadAsync: () => {
|
||||
const scene = new Group();
|
||||
scene.add(new Mesh(new BoxGeometry(1, 1, 1)));
|
||||
return Promise.resolve({ scene });
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const blocosJson = JSON.parse(
|
||||
readFileSync(join(process.cwd(), "public/3d/blocos.json"), "utf8"),
|
||||
);
|
||||
let mockFetchOk = true;
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
const props = () => ({
|
||||
progressRef: { current: 0 },
|
||||
visible: true,
|
||||
onReady: jest.fn(),
|
||||
onError: jest.fn(),
|
||||
});
|
||||
|
||||
function setDevicePixelRatio(value: number) {
|
||||
Object.defineProperty(window, "devicePixelRatio", {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockRenderers.length = 0;
|
||||
mockRendererName = null;
|
||||
mockFetchOk = true;
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: mockFetchOk,
|
||||
status: mockFetchOk ? 200 : 404,
|
||||
json: () => Promise.resolve(blocosJson),
|
||||
}),
|
||||
) as unknown as typeof fetch;
|
||||
setDevicePixelRatio(1);
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
global.fetch = originalFetch;
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
/** Deixa as tarefas da montagem correrem (ativos, renderizador, compilação do shader, primeiro quadro pedido). */
|
||||
const buildScene = (ms = 10) =>
|
||||
act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
|
||||
async function mount(ui: React.ReactElement) {
|
||||
const result = render(ui);
|
||||
await buildScene();
|
||||
return result;
|
||||
}
|
||||
|
||||
const advance = (ms: number) =>
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(ms);
|
||||
});
|
||||
|
||||
/** A malha que o último quadro desenhou (a cena tem uma só). */
|
||||
function drawnMesh() {
|
||||
const calls = mockRenderers[0].render.mock.calls as unknown as [
|
||||
{ children: Mesh[] },
|
||||
][];
|
||||
return calls.at(-1)?.[0].children[0] as Mesh<
|
||||
InstancedBufferGeometry,
|
||||
ShaderMaterial
|
||||
>;
|
||||
}
|
||||
const uniform = (name: string) =>
|
||||
drawnMesh().material.uniforms[name].value as number;
|
||||
|
||||
// precisa ser o primeiro: os ativos ficam em cache depois do primeiro download que dá certo
|
||||
describe("BlocosScene when the assets do not download", () => {
|
||||
it("tells the page once, creates no context, and lets the next mount try again", async () => {
|
||||
mockFetchOk = false;
|
||||
const scene = props();
|
||||
|
||||
const { container, unmount } = await mount(<BlocosScene {...scene} />);
|
||||
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers).toHaveLength(0);
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
unmount();
|
||||
|
||||
mockFetchOk = true;
|
||||
const again = props();
|
||||
await mount(<BlocosScene {...again} />);
|
||||
expect(again.onError).not.toHaveBeenCalled();
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BlocosScene", () => {
|
||||
it("creates a transparent, antialiased renderer on the high-performance GPU, and puts its canvas in the page", async () => {
|
||||
const { container } = await mount(<BlocosScene {...props()} />);
|
||||
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
expect(mockRenderers[0].options).toEqual({
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
const canvas = container.querySelector("canvas");
|
||||
expect(canvas).toBe(mockRenderers[0].domElement);
|
||||
expect(canvas).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["full", 3, 1.75],
|
||||
["full", 1.5, 1.5],
|
||||
["lite", 3, 1.5],
|
||||
["lite", 0.5, 1],
|
||||
] as const)(
|
||||
"caps the pixel ratio on %s (device %p -> %p)",
|
||||
async (tier, device, expected) => {
|
||||
setDevicePixelRatio(device);
|
||||
await mount(<BlocosScene {...props()} tier={tier} />);
|
||||
expect(mockRenderers[0].pixelRatio).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("draws every block in one instanced call that starts on marca", async () => {
|
||||
await mount(<BlocosScene {...props()} />);
|
||||
advance(20);
|
||||
|
||||
const mesh = drawnMesh();
|
||||
expect(mesh.geometry.type).toBe("InstancedBufferGeometry");
|
||||
expect(mesh.geometry.instanceCount).toBe(238);
|
||||
expect(uniform("uFrom")).toBe(0);
|
||||
expect(uniform("uTo")).toBe(0);
|
||||
});
|
||||
|
||||
it("tells the page it is ready on the second frame drawn", async () => {
|
||||
const scene = props();
|
||||
await mount(<BlocosScene {...scene} />);
|
||||
|
||||
advance(17);
|
||||
expect(mockRenderers[0].render).toHaveBeenCalledTimes(1);
|
||||
expect(scene.onReady).not.toHaveBeenCalled();
|
||||
|
||||
advance(17);
|
||||
expect(mockRenderers[0].render).toHaveBeenCalledTimes(2);
|
||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||
|
||||
advance(3000);
|
||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("draws nothing while not visible", async () => {
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
const scene = props();
|
||||
|
||||
const { rerender } = await mount(
|
||||
<BlocosScene {...scene} visible={false} />,
|
||||
);
|
||||
advance(3000);
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
|
||||
rerender(<BlocosScene {...scene} visible />);
|
||||
advance(100);
|
||||
expect(mockRenderers[0].render).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("draws at screen rate through the entry and the first 60 frames, then only on the 30 fps pulse", async () => {
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
await mount(<BlocosScene {...props()} />);
|
||||
|
||||
advance(1500); // entrada de 1,4 s, no ritmo da tela
|
||||
const entry = frames.mock.calls.length;
|
||||
expect(entry).toBeGreaterThan(80);
|
||||
expect(uniform("uIntro")).toBe(1);
|
||||
|
||||
advance(1000);
|
||||
const pulse = frames.mock.calls.length - entry;
|
||||
expect(pulse).toBeGreaterThan(20);
|
||||
expect(pulse).toBeLessThanOrEqual(34);
|
||||
});
|
||||
|
||||
it("follows the dive progress on full", async () => {
|
||||
const scene = props();
|
||||
await mount(<BlocosScene {...scene} />);
|
||||
advance(1500);
|
||||
|
||||
scene.progressRef.current = 0.63;
|
||||
advance(4000);
|
||||
|
||||
expect(uniform("uFrom")).toBe(2);
|
||||
expect(uniform("uTo")).toBe(2);
|
||||
expect(uniform("uT")).toBe(0);
|
||||
});
|
||||
|
||||
it("changes state by itself on lite, 4 s on each, even with nobody touching the page", async () => {
|
||||
const scene = props();
|
||||
scene.progressRef.current = 0.93; // ignorado no lite
|
||||
await mount(<BlocosScene {...scene} tier="lite" />);
|
||||
|
||||
advance(1400 + 3500); // entrada e 3,5 s parado na marca
|
||||
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 0]);
|
||||
|
||||
advance(1100); // no meio da troca de 1,2 s
|
||||
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
|
||||
expect(uniform("uT")).toBeGreaterThan(0);
|
||||
|
||||
advance(1000);
|
||||
expect([uniform("uFrom"), uniform("uTo"), uniform("uT")]).toEqual([
|
||||
1, 1, 0,
|
||||
]);
|
||||
});
|
||||
|
||||
/** O hospedeiro com 800 x 600 px no canto da janela (o jsdom não faz layout). */
|
||||
const hostOf800x600 = () => {
|
||||
jest.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(800);
|
||||
jest.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(600);
|
||||
jest
|
||||
.spyOn(Element.prototype, "getBoundingClientRect")
|
||||
.mockReturnValue(new DOMRect(0, 0, 800, 600));
|
||||
};
|
||||
const fire = (event: Event) =>
|
||||
act(() => {
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
it("brings the resting state to life after the entry and fades it out while the piece changes state", async () => {
|
||||
const scene = props();
|
||||
await mount(<BlocosScene {...scene} />);
|
||||
advance(1000);
|
||||
expect(uniform("uVida")).toBe(0); // a entrada ainda assenta
|
||||
|
||||
advance(1500);
|
||||
expect(uniform("uVida")).toBeGreaterThan(0.9);
|
||||
expect(uniform("uEstado")).toBe(0);
|
||||
const tempo = uniform("uTempo");
|
||||
advance(500);
|
||||
expect(uniform("uTempo")).toBeCloseTo(tempo + 0.5, 1);
|
||||
|
||||
// no meio da troca da marca para a tela
|
||||
fire(new Event("scroll"));
|
||||
scene.progressRef.current = 0.23;
|
||||
advance(1000);
|
||||
expect(uniform("uT")).toBeGreaterThan(0);
|
||||
expect(uniform("uVida")).toBeLessThan(0.05);
|
||||
|
||||
fire(new Event("scroll"));
|
||||
scene.progressRef.current = 0.35;
|
||||
advance(3000);
|
||||
expect(uniform("uEstado")).toBe(1);
|
||||
expect(uniform("uVida")).toBeGreaterThan(0.9);
|
||||
});
|
||||
|
||||
it("freezes the resting life with the pulse once nobody moves", async () => {
|
||||
const scene = props();
|
||||
scene.progressRef.current = 0.35; // fora do topo: sem o ciclo em repouso
|
||||
await mount(<BlocosScene {...scene} />);
|
||||
advance(6000); // a batida para 5 s depois do último gesto
|
||||
const drawn = mockRenderers[0].render.mock.calls.length;
|
||||
const tempo = uniform("uTempo");
|
||||
|
||||
advance(3000);
|
||||
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
|
||||
expect(uniform("uTempo")).toBe(tempo);
|
||||
|
||||
fire(new Event("scroll"));
|
||||
advance(500);
|
||||
expect(uniform("uTempo")).toBeGreaterThan(tempo);
|
||||
});
|
||||
|
||||
/** O estado que o último quadro desenhou: de, para e t. */
|
||||
const shown = () => [uniform("uFrom"), uniform("uTo"), uniform("uT")];
|
||||
|
||||
it("parked at the top on full, cycles by itself through the four states, about 3 s on each, drawing only the changes", async () => {
|
||||
const scene = { ...props(), onEstado: jest.fn() };
|
||||
await mount(<BlocosScene {...scene} />);
|
||||
|
||||
advance(1400 + 2500); // entrada e 2,5 s parado na marca
|
||||
expect(shown()).toEqual([0, 0, 0]);
|
||||
advance(1100); // na troca de 1,2 s para a tela
|
||||
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
|
||||
expect(uniform("uT")).toBeGreaterThan(0);
|
||||
// durante a troca, nenhuma legenda: os blocos da marca ainda ocupam o lugar dela
|
||||
expect(scene.onEstado).toHaveBeenLastCalledWith(null);
|
||||
advance(1500);
|
||||
expect(shown()).toEqual([1, 1, 0]);
|
||||
expect(scene.onEstado).toHaveBeenLastCalledWith(1); // assentada na tela
|
||||
|
||||
// a batida parou (5 s sem gesto): parada na tela, nenhum quadro até a próxima troca
|
||||
const drawn = mockRenderers[0].render.mock.calls.length;
|
||||
advance(1000);
|
||||
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
|
||||
|
||||
advance(4200 * 2 + 1000); // integrações, dados
|
||||
expect(shown()).toEqual([3, 3, 0]);
|
||||
advance(4200);
|
||||
expect(shown()).toEqual([0, 0, 0]);
|
||||
// cada legenda entra com a peça assentada e sai quando a troca seguinte começa
|
||||
expect(scene.onEstado.mock.calls.map(([estado]) => estado)).toEqual([
|
||||
0,
|
||||
null,
|
||||
1,
|
||||
null,
|
||||
2,
|
||||
null,
|
||||
3,
|
||||
null,
|
||||
0,
|
||||
]);
|
||||
});
|
||||
|
||||
it("hands the piece back to the scroll at once and without a jump, and starts the cycle again only after 3 s at the top", async () => {
|
||||
const scene = { ...props(), onEstado: jest.fn() };
|
||||
await mount(<BlocosScene {...scene} />);
|
||||
advance(1400 + 3000 + 1200 + 1000);
|
||||
expect(shown()).toEqual([1, 1, 0]); // parada na tela, pelo ciclo
|
||||
|
||||
fire(new Event("scroll"));
|
||||
scene.progressRef.current = 0.02;
|
||||
advance(50); // a próxima batida
|
||||
// a rolagem pede a marca: a peça sai da tela para ela, do ponto em que estava
|
||||
expect([uniform("uFrom"), uniform("uTo")]).toEqual([1, 0]);
|
||||
expect(uniform("uT")).toBeGreaterThan(0);
|
||||
expect(uniform("uT")).toBeLessThan(0.1);
|
||||
expect(scene.onEstado).toHaveBeenLastCalledWith(null); // em troca: sem legenda
|
||||
advance(1000);
|
||||
expect(shown()).toEqual([0, 0, 0]);
|
||||
|
||||
// de volta ao topo: 3 s parada na marca antes da troca seguinte
|
||||
fire(new Event("scroll"));
|
||||
scene.progressRef.current = 0;
|
||||
advance(3000);
|
||||
expect(shown()).toEqual([0, 0, 0]);
|
||||
advance(1500);
|
||||
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("follows the dive once the scroll leaves the top, and tells the legend the piece has left its box", async () => {
|
||||
const scene = { ...props(), onEstado: jest.fn() };
|
||||
await mount(<BlocosScene {...scene} />);
|
||||
advance(1400 + 3000 + 1200 + 1000);
|
||||
expect(scene.onEstado).toHaveBeenLastCalledWith(1);
|
||||
|
||||
fire(new Event("scroll"));
|
||||
scene.progressRef.current = 0.63;
|
||||
advance(4000);
|
||||
expect(shown()).toEqual([2, 2, 0]);
|
||||
expect(scene.onEstado).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it("takes the piece from the cycle into the middle of a dive transition and keeps it there", async () => {
|
||||
const scene = props();
|
||||
await mount(<BlocosScene {...scene} />);
|
||||
advance(1400 + 4200 + 4200 + 1000);
|
||||
expect(shown()).toEqual([2, 2, 0]); // parada nas integrações, pelo ciclo
|
||||
|
||||
// a rolagem para no meio da troca da marca para a tela
|
||||
fire(new Event("scroll"));
|
||||
scene.progressRef.current = 0.26;
|
||||
advance(3000);
|
||||
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
|
||||
expect(uniform("uT")).toBeCloseTo(stateAt(0.26).t, 6);
|
||||
});
|
||||
|
||||
it("lite: tells the legend every state it builds", async () => {
|
||||
const scene = { ...props(), onEstado: jest.fn() };
|
||||
await mount(<BlocosScene {...scene} tier="lite" />);
|
||||
advance(1400 + 4000 + 1200 + 500);
|
||||
expect(scene.onEstado.mock.calls.map(([estado]) => estado)).toEqual([
|
||||
0,
|
||||
null,
|
||||
1,
|
||||
]);
|
||||
});
|
||||
|
||||
it("pushes the blocks under the mouse forward on full and lets them go when the mouse leaves", async () => {
|
||||
hostOf800x600();
|
||||
await mount(<BlocosScene {...props()} />);
|
||||
advance(2500);
|
||||
expect(uniform("uCursorPeso")).toBe(0);
|
||||
|
||||
// o centro do hospedeiro cai no centro da peça
|
||||
fire(new PointerEvent("pointermove", { clientX: 400, clientY: 300 }));
|
||||
advance(1000);
|
||||
expect(uniform("uCursorPeso")).toBeGreaterThan(0.9);
|
||||
const cursor = drawnMesh().material.uniforms.uCursor.value as {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
expect(Math.hypot(cursor.x, cursor.y)).toBeLessThan(0.5);
|
||||
|
||||
// perto do canto de cima à esquerda, longe da peça, o ponto vai junto
|
||||
fire(new PointerEvent("pointermove", { clientX: 100, clientY: 100 }));
|
||||
advance(1000);
|
||||
expect(cursor.x).toBeLessThan(-3);
|
||||
expect(cursor.y).toBeGreaterThan(2);
|
||||
|
||||
fire(new PointerEvent("pointerout", { relatedTarget: null }));
|
||||
advance(1500);
|
||||
expect(uniform("uCursorPeso")).toBeLessThan(0.01);
|
||||
});
|
||||
|
||||
it("gives lite the resting life on the pulse, without the cursor", async () => {
|
||||
hostOf800x600();
|
||||
await mount(<BlocosScene {...props()} tier="lite" />);
|
||||
advance(2500);
|
||||
expect(uniform("uVida")).toBeGreaterThan(0.9);
|
||||
const tempo = uniform("uTempo");
|
||||
|
||||
fire(new PointerEvent("pointermove", { clientX: 400, clientY: 300 }));
|
||||
advance(500);
|
||||
expect(uniform("uTempo")).toBeCloseTo(tempo + 0.5, 1);
|
||||
expect(uniform("uCursorPeso")).toBe(0);
|
||||
});
|
||||
|
||||
it("releases the geometry, the material, the textures, the context, the canvas, the timers and the listeners on unmount", async () => {
|
||||
const removed = jest.spyOn(window, "removeEventListener");
|
||||
const scene = props();
|
||||
const { container, unmount } = await mount(<BlocosScene {...scene} />);
|
||||
advance(20);
|
||||
const mesh = drawnMesh();
|
||||
const disposed = [
|
||||
jest.spyOn(mesh.geometry, "dispose"),
|
||||
jest.spyOn(mesh.material, "dispose"),
|
||||
jest.spyOn(mesh.material.uniforms.uPorcelana.value as Texture, "dispose"),
|
||||
jest.spyOn(mesh.material.uniforms.uLima.value as Texture, "dispose"),
|
||||
];
|
||||
const canvasRemoved = jest.spyOn(
|
||||
mockRenderers[0].domElement,
|
||||
"removeEventListener",
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
for (const dispose of disposed) expect(dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
expect(removed.mock.calls.map(([type]) => type)).toEqual(
|
||||
expect.arrayContaining(["resize", "pointermove", "pointerout"]),
|
||||
);
|
||||
expect(canvasRemoved).toHaveBeenCalledWith(
|
||||
"webglcontextlost",
|
||||
expect.any(Function),
|
||||
);
|
||||
const drawn = mockRenderers[0].render.mock.calls.length;
|
||||
advance(1000);
|
||||
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
|
||||
});
|
||||
|
||||
it("tells the page once when the WebGL context is lost", async () => {
|
||||
const scene = props();
|
||||
await mount(<BlocosScene {...scene} />);
|
||||
|
||||
act(() => {
|
||||
mockRenderers[0].domElement.dispatchEvent(
|
||||
new Event("webglcontextlost", { cancelable: true }),
|
||||
);
|
||||
mockRenderers[0].domElement.dispatchEvent(
|
||||
new Event("webglcontextlost", { cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("gives up once and remembers it when the entry frames average more than 28 ms", async () => {
|
||||
// quadros de 40 ms: um aparelho que não segura a cena
|
||||
jest
|
||||
.spyOn(window, "requestAnimationFrame")
|
||||
.mockImplementation(
|
||||
(callback) =>
|
||||
setTimeout(
|
||||
() => callback(performance.now()),
|
||||
40,
|
||||
) as unknown as number,
|
||||
);
|
||||
jest
|
||||
.spyOn(window, "cancelAnimationFrame")
|
||||
.mockImplementation((id) => clearTimeout(id));
|
||||
const scene = props();
|
||||
await mount(<BlocosScene {...scene} />);
|
||||
|
||||
advance(3000);
|
||||
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
expect(isKnownSlowScene()).toBe(true);
|
||||
const drawn = mockRenderers[0].render.mock.calls.length;
|
||||
expect(drawn).toBeLessThanOrEqual(62);
|
||||
advance(2000);
|
||||
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
|
||||
});
|
||||
|
||||
it("keeps a device that holds 60 fps, without remembering anything", async () => {
|
||||
const scene = props();
|
||||
await mount(<BlocosScene {...scene} />);
|
||||
|
||||
advance(3000);
|
||||
|
||||
expect(scene.onError).not.toHaveBeenCalled();
|
||||
expect(isKnownSlowScene()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BlocosScene: software WebGL", () => {
|
||||
it("draws on the CPU: no canvas, the page is told once, the context is freed and the session remembers it", async () => {
|
||||
mockRendererName = "llvmpipe (LLVM 15.0.7, 256 bits)";
|
||||
const scene = props();
|
||||
|
||||
const { container } = await mount(<BlocosScene {...scene} />);
|
||||
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
expect(scene.onReady).not.toHaveBeenCalled();
|
||||
expect(isKnownSoftwareWebGL()).toBe(true);
|
||||
});
|
||||
|
||||
it("draws on the GPU: the canvas stays and the session is not marked", async () => {
|
||||
mockRendererName = "ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11)";
|
||||
const scene = props();
|
||||
|
||||
const { container } = await mount(<BlocosScene {...scene} />);
|
||||
|
||||
expect(container.querySelector("canvas")).toBe(mockRenderers[0].domElement);
|
||||
expect(scene.onError).not.toHaveBeenCalled();
|
||||
expect(isKnownSoftwareWebGL()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,919 @@
|
||||
"use client";
|
||||
|
||||
import { type RefObject, useEffect, useRef } from "react";
|
||||
import {
|
||||
type BufferGeometry,
|
||||
Euler,
|
||||
InstancedBufferAttribute,
|
||||
InstancedBufferGeometry,
|
||||
MathUtils,
|
||||
Matrix3,
|
||||
Matrix4,
|
||||
Mesh,
|
||||
PerspectiveCamera,
|
||||
Plane,
|
||||
Ray,
|
||||
Scene,
|
||||
ShaderMaterial,
|
||||
SRGBColorSpace,
|
||||
type Texture,
|
||||
TextureLoader,
|
||||
Vector2,
|
||||
Vector3,
|
||||
WebGLRenderer,
|
||||
} from "three";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import {
|
||||
type Blocos,
|
||||
caixaDosEstados,
|
||||
cicloAt,
|
||||
createFrameBudget,
|
||||
type Estado,
|
||||
type FrameVerdict,
|
||||
framePiece,
|
||||
papeis,
|
||||
parseBlocos,
|
||||
type Rect,
|
||||
seguir,
|
||||
stateAt,
|
||||
} from "@/lib/blocos";
|
||||
import {
|
||||
isSoftwareWebGL,
|
||||
rememberSlowScene,
|
||||
rememberSoftwareWebGL,
|
||||
} from "@/lib/device";
|
||||
import { startFramePulse } from "./FramePulse";
|
||||
|
||||
export type BlocosSceneProps = {
|
||||
/** Progresso do mergulho (0 a 1), escrito pela rolagem. */
|
||||
progressRef: RefObject<number>;
|
||||
visible: boolean;
|
||||
onReady: () => void;
|
||||
onError: () => void;
|
||||
/** `full` segue a rolagem e o mouse; `lite` (celular) troca de estado sozinho. Padrão `full`. */
|
||||
tier?: "full" | "lite";
|
||||
/** Onde a peça descansa no estado `marca` (px CSS relativos ao hospedeiro); sem ele, no hospedeiro inteiro. */
|
||||
placementRef?: RefObject<Rect | null>;
|
||||
/** Área útil dos outros estados (px CSS relativos ao hospedeiro); sem ela, o hospedeiro inteiro. */
|
||||
stageRef?: RefObject<Rect | null>;
|
||||
/**
|
||||
* O estado em que a peça está assentada na caixa dela (`placementRef` no `full`, a caixa toda no `lite`), para a
|
||||
* legenda; `null` durante uma troca e fora da caixa (no mergulho). Avisa só quando muda.
|
||||
*/
|
||||
onEstado?: (estado: number | null) => void;
|
||||
};
|
||||
|
||||
/*
|
||||
* Os blocos da marca, em tempo real: uma malha instanciada (um bloco chanfrado do Blender, repetido) com os quatro
|
||||
* estados de `public/3d/blocos.json` em atributos por instância. A troca de estado ("encaixar": cada bloco sai com um
|
||||
* atraso próprio, faz um arco curto para a frente e assenta sem quicar) e a entrada correm no vertex shader; o
|
||||
* JavaScript só atualiza uniforms, a pose e o enquadramento. A luz vem de dois matcaps renderizados no Blender com a
|
||||
* mesma luz do pôster, então o primeiro quadro assume por cima do pôster sem salto.
|
||||
* Parada num estado, a peça tem vida própria (`vida` no vertex shader), calculada pelo tempo; no `full`, os blocos
|
||||
* perto do ponteiro avançam para a frente. O desenho é sob demanda: no ritmo da tela só enquanto a entrada, uma troca
|
||||
* ou a rolagem assenta; fora disso a batida de 30 quadros por segundo enquanto há alguém mexendo na página (é ela que
|
||||
* faz a vida correr e parar) e, no `lite`, um quadro na próxima troca.
|
||||
*/
|
||||
|
||||
type Assets = {
|
||||
bloco: BufferGeometry;
|
||||
blocos: Blocos;
|
||||
porcelana: Texture;
|
||||
lima: Texture;
|
||||
};
|
||||
|
||||
/** Duração da entrada (s): os blocos saem de perto do lugar e assentam na marca, por cima do pôster. */
|
||||
const ENTRADA = 1.4;
|
||||
/** `lite`: tempo parado em cada estado e tempo de cada troca (s). */
|
||||
const FICA = 4;
|
||||
const TROCA = 1.2;
|
||||
/** `full` parado no topo: tempo em cada estado do ciclo em repouso (s); a troca é a mesma do `lite`. */
|
||||
const FICA_TOPO = 3;
|
||||
/** Avanço do mergulho abaixo do qual a página conta como parada no topo. */
|
||||
const TOPO = 0.01;
|
||||
/** Quantas trocas inteiras por `TROCA` a peça anda quando segue um pedido novo (a rolagem que assume o ciclo). */
|
||||
const SEGUE = 2;
|
||||
/** Pose da marca em repouso: a da câmera do pôster em `3d/blocos.py` (pela esquerda, 26°, e de cima, 12°). */
|
||||
const REPOUSO = { yaw: 0.45, pitch: 0.21 };
|
||||
/** Pose dos outros estados, quase de frente: a da câmera das prévias em `3d/blocos.py`. */
|
||||
const FRENTE = { yaw: 0.24, pitch: 0.12 };
|
||||
/** A câmera fica parada; a peça é que se move e muda de tamanho para caber onde `framePiece` manda. */
|
||||
const DISTANCIA = 40;
|
||||
/** O plano da peça (z = 0, no espaço dela), onde o ponteiro é medido. */
|
||||
const PLANO = new Plane(new Vector3(0, 0, 1), 0);
|
||||
|
||||
const VERTEX = /* glsl */ `
|
||||
#define ATRASO 0.35
|
||||
attribute vec4 aPos0;
|
||||
attribute vec4 aPos1;
|
||||
attribute vec4 aPos2;
|
||||
attribute vec4 aPos3;
|
||||
attribute vec3 aEsc0;
|
||||
attribute vec3 aEsc1;
|
||||
attribute vec3 aEsc2;
|
||||
attribute vec3 aEsc3;
|
||||
attribute vec3 aEntrada;
|
||||
attribute float aSemente;
|
||||
// papel na vida em repouso (papeis, em lib/blocos.ts): quadrado da marca, botão da tela, linha, barra
|
||||
attribute vec4 aPapel;
|
||||
uniform float uFrom;
|
||||
uniform float uTo;
|
||||
uniform float uT;
|
||||
uniform float uIntro;
|
||||
// peso da vida (0 na troca e na entrada), estado parado em que ela corre e o relógio dela (s)
|
||||
uniform float uVida;
|
||||
uniform float uEstado;
|
||||
uniform float uTempo;
|
||||
uniform vec3 uBotao;
|
||||
uniform float uChao;
|
||||
// ponteiro no plano z = 0 da peça e o peso dele (0 sem ponteiro, no lite e fora do repouso)
|
||||
uniform vec2 uCursor;
|
||||
uniform float uCursorPeso;
|
||||
varying vec3 vNormal;
|
||||
varying float vTom;
|
||||
|
||||
// posição (xyz) e tom (w) do bloco no estado i
|
||||
vec4 lugar(float i) { return i < 0.5 ? aPos0 : i < 1.5 ? aPos1 : i < 2.5 ? aPos2 : aPos3; }
|
||||
vec3 escala(float i) { return i < 0.5 ? aEsc0 : i < 1.5 ? aEsc1 : i < 2.5 ? aEsc2 : aEsc3; }
|
||||
|
||||
// encaixar: cada bloco sai com um atraso próprio e assenta com ease-out, sem passar do ponto
|
||||
float encaixe(float t) {
|
||||
float k = clamp((t - aSemente * ATRASO) / (1.0 - ATRASO), 0.0, 1.0);
|
||||
return 1.0 - pow(1.0 - k, 3.0);
|
||||
}
|
||||
|
||||
// pulo de um quadrado, u de 0 a 1: sobe rápido e assenta devagar, sem passar do lugar; 0 fora disso
|
||||
float pulo(float u) {
|
||||
if (u <= 0.0 || u >= 1.0) return 0.0;
|
||||
if (u < 0.35) return 1.0 - pow(1.0 - u / 0.35, 2.0);
|
||||
return 1.0 - smoothstep(0.0, 1.0, (u - 0.35) / 0.65);
|
||||
}
|
||||
|
||||
// a vida em repouso do estado: muda posição, escala e tom do bloco
|
||||
void vida(inout vec3 p, inout vec3 s, inout float tom) {
|
||||
if (uEstado < 0.5) {
|
||||
// marca: os quatro quadrados lima pulam 0,25 em sequência, 0,4 s cada, um ciclo a cada 6 s; o primeiro vem 2,5 s
|
||||
// depois de a cena nascer, logo após a entrada (e dentro dos 4 s do lite na marca)
|
||||
if (aPapel.x >= 0.0) p.y += uVida * 0.25 * pulo((mod(uTempo - 2.5, 6.0) - aPapel.x * 0.3) / 0.4);
|
||||
} else if (uEstado < 1.5) {
|
||||
// tela: o botão inteiro pulsa de 1 a 1,08 em volta do centro, a cada 2,4 s
|
||||
float k = 1.0 + uVida * 0.08 * aPapel.y * sin(3.14159265 * min(mod(uTempo, 2.4) / 0.9, 1.0));
|
||||
p = uBotao + (p - uBotao) * k;
|
||||
s *= k;
|
||||
} else if (uEstado < 2.5) {
|
||||
// integrações: um pulso lima corre do núcleo aos nós em 0,9 s, a cada 2,5 s
|
||||
float frente = mod(uTempo, 2.5) / 0.9 * 1.4 - 0.2;
|
||||
if (aPapel.z >= 0.0) tom = max(tom, uVida * (1.0 - smoothstep(0.0, 0.2, abs(aPapel.z - frente))));
|
||||
} else if (aPapel.w >= 0.0) {
|
||||
// dados: cada barra respira 15% em altura a partir do chão, com a fase dela, em 3,2 s
|
||||
float k = 1.0 + uVida * 0.15 * (0.5 - 0.5 * cos(6.2831853 * (uTempo / 3.2 + aPapel.w)));
|
||||
p.z = uChao + (p.z - uChao) * k;
|
||||
s.z *= k;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 a = lugar(uFrom);
|
||||
vec4 b = lugar(uTo);
|
||||
vec3 ea = escala(uFrom);
|
||||
vec3 eb = escala(uTo);
|
||||
// bloco que só existe num dos estados cresce ou some no lugar, sem viajar
|
||||
vec3 pa = ea.x > 0.0 ? a.xyz : b.xyz;
|
||||
vec3 pb = eb.x > 0.0 ? b.xyz : a.xyz;
|
||||
float e = encaixe(uT);
|
||||
vec3 p = mix(pa, pb, e);
|
||||
// arco curto para a frente (+Z), que volta a zero ao assentar; quem não sai do lugar não faz arco
|
||||
p.z += sin(3.14159265 * e) * min(distance(pa, pb), 1.0) * (0.5 + 0.5 * aSemente);
|
||||
vec3 s = mix(ea, eb, e);
|
||||
vTom = mix(a.w, b.w, e);
|
||||
if (uVida > 0.0) vida(p, s, vTom);
|
||||
// cursor: os blocos a até 2 unidades do ponteiro avançam até 0,5 para a frente, com queda suave
|
||||
p.z += 0.5 * uCursorPeso * (1.0 - smoothstep(0.0, 2.0, distance(p.xy, uCursor)));
|
||||
// entrada: de perto do lugar e um pouco menor, com o mesmo encaixe
|
||||
float ei = encaixe(uIntro);
|
||||
p += aEntrada * (1.0 - ei);
|
||||
s *= mix(0.8, 1.0, ei);
|
||||
vec4 mv = modelViewMatrix * vec4(position * s + p, 1.0);
|
||||
// escala não uniforme: a normal vai pela inversa da escala
|
||||
vNormal = normalize(normalMatrix * (normal / max(s, vec3(1e-4))));
|
||||
gl_Position = projectionMatrix * mv;
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAGMENT = /* glsl */ `
|
||||
uniform sampler2D uPorcelana;
|
||||
uniform sampler2D uLima;
|
||||
uniform mat3 uLuz;
|
||||
varying vec3 vNormal;
|
||||
varying float vTom;
|
||||
|
||||
void main() {
|
||||
// matcap pela normal em espaço de visão, desfeita da pose (uLuz): a luz fica presa à peça como no Blender, onde a
|
||||
// câmera do pôster girou em volta da peça e a luz não. Sem a correção de perspectiva do MeshMatcapMaterial, que
|
||||
// prenderia a luz à câmera de novo; só o balanço e o mouse mexem nela
|
||||
vec3 n = uLuz * normalize(vNormal);
|
||||
vec2 uv = n.xy * 0.495 + 0.5;
|
||||
gl_FragColor = vec4(mix(texture2D(uPorcelana, uv).rgb, texture2D(uLima, uv).rgb, vTom), 1.0);
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`;
|
||||
|
||||
let assets: Promise<Assets> | null = null;
|
||||
|
||||
async function loadAssets(): Promise<Assets> {
|
||||
const textures = new TextureLoader();
|
||||
const [gltf, blocos, porcelana, lima] = await Promise.all([
|
||||
new GLTFLoader().loadAsync("/3d/bloco.glb"),
|
||||
fetch("/3d/blocos.json").then((response) => {
|
||||
if (!response.ok) throw new Error(`blocos.json: ${response.status}`);
|
||||
return response.json().then(parseBlocos);
|
||||
}),
|
||||
textures.loadAsync("/3d/matcap-porcelana.webp"),
|
||||
textures.loadAsync("/3d/matcap-lima.webp"),
|
||||
]);
|
||||
let bloco: BufferGeometry | undefined;
|
||||
gltf.scene.traverse((object) => {
|
||||
if (!bloco && object instanceof Mesh) bloco = object.geometry;
|
||||
});
|
||||
if (!bloco) throw new Error("bloco.glb sem malha");
|
||||
// os matcaps estão em sRGB: o three decodifica na amostra e `colorspace_fragment` codifica de volta na saída
|
||||
porcelana.colorSpace = SRGBColorSpace;
|
||||
lima.colorSpace = SRGBColorSpace;
|
||||
return { bloco, blocos, porcelana, lima };
|
||||
}
|
||||
|
||||
/** Os ativos baixados uma vez por página; uma falha esvazia o cache para a próxima montagem tentar de novo. */
|
||||
function loadBlocos() {
|
||||
assets ??= loadAssets().catch((error) => {
|
||||
assets = null;
|
||||
throw error;
|
||||
});
|
||||
return assets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baixa e deixa em cache o bloco, os estados e os matcaps, para quem quer adiantar o download antes de montar a cena.
|
||||
* Nunca rejeita: uma falha aparece ao montar (`onError`).
|
||||
*/
|
||||
export function prepareBlocos(): Promise<void> {
|
||||
return loadBlocos().then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** Número de 0 a 1 fixo para cada bloco e canal: a mesma "sorte" a cada visita. */
|
||||
function sorteio(bloco: number, canal: number) {
|
||||
let h = Math.imul(bloco * 8 + canal + 1, 0x9e3779b1);
|
||||
h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
|
||||
h ^= h >>> 13;
|
||||
return (h >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
/** A cena: câmera, a malha instanciada com os quatro estados e o material de matcap. */
|
||||
function buildWorld({ bloco, blocos, porcelana, lima }: Assets) {
|
||||
const { n, estados } = blocos;
|
||||
const geometry = new InstancedBufferGeometry();
|
||||
geometry.index = bloco.index;
|
||||
geometry.setAttribute("position", bloco.getAttribute("position"));
|
||||
geometry.setAttribute("normal", bloco.getAttribute("normal"));
|
||||
geometry.instanceCount = n;
|
||||
estados.forEach((dados, e) => {
|
||||
const lugar = new Float32Array(n * 4);
|
||||
const escala = new Float32Array(n * 3);
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let k = 0; k < 3; k++) {
|
||||
lugar[i * 4 + k] = dados[i * 7 + k];
|
||||
escala[i * 3 + k] = dados[i * 7 + 3 + k];
|
||||
}
|
||||
lugar[i * 4 + 3] = dados[i * 7 + 6];
|
||||
}
|
||||
geometry.setAttribute(`aPos${e}`, new InstancedBufferAttribute(lugar, 4));
|
||||
geometry.setAttribute(`aEsc${e}`, new InstancedBufferAttribute(escala, 3));
|
||||
});
|
||||
const semente = new Float32Array(n);
|
||||
const entrada = new Float32Array(n * 3);
|
||||
for (let i = 0; i < n; i++) {
|
||||
semente[i] = sorteio(i, 0);
|
||||
// de onde o bloco entra: até 1,2 unidade do lugar, mais para a frente que para trás
|
||||
const x = sorteio(i, 1) - 0.5;
|
||||
const y = sorteio(i, 2) - 0.5;
|
||||
const z = sorteio(i, 3);
|
||||
const k = (0.4 + 0.8 * sorteio(i, 4)) / Math.hypot(x, y, z || 1);
|
||||
entrada[i * 3] = x * k;
|
||||
entrada[i * 3 + 1] = y * k;
|
||||
entrada[i * 3 + 2] = z * k;
|
||||
}
|
||||
geometry.setAttribute("aSemente", new InstancedBufferAttribute(semente, 1));
|
||||
geometry.setAttribute("aEntrada", new InstancedBufferAttribute(entrada, 3));
|
||||
const { vida, botao, chao } = papeis(blocos);
|
||||
geometry.setAttribute("aPapel", new InstancedBufferAttribute(vida, 4));
|
||||
|
||||
// a visão desfeita da pose da peça (ver o fragment shader)
|
||||
const luz = new Matrix3();
|
||||
const material = new ShaderMaterial({
|
||||
vertexShader: VERTEX,
|
||||
fragmentShader: FRAGMENT,
|
||||
uniforms: {
|
||||
uFrom: { value: 0 },
|
||||
uTo: { value: 0 },
|
||||
uT: { value: 0 },
|
||||
uIntro: { value: 0 },
|
||||
uVida: { value: 0 },
|
||||
uEstado: { value: 0 },
|
||||
uTempo: { value: 0 },
|
||||
uBotao: { value: new Vector3(...botao) },
|
||||
uChao: { value: chao },
|
||||
uCursor: { value: new Vector2() },
|
||||
uCursorPeso: { value: 0 },
|
||||
uPorcelana: { value: porcelana },
|
||||
uLima: { value: lima },
|
||||
uLuz: { value: luz },
|
||||
},
|
||||
});
|
||||
const mesh = new Mesh(geometry, material);
|
||||
// a malha é um bloco só: a caixa dela não diz onde estão as instâncias
|
||||
mesh.frustumCulled = false;
|
||||
const scene = new Scene();
|
||||
scene.add(mesh);
|
||||
const camera = new PerspectiveCamera(35, 1, 1, DISTANCIA * 4);
|
||||
camera.position.set(0, 0, DISTANCIA);
|
||||
// o raio do ponteiro sai dela antes do primeiro desenho
|
||||
camera.updateMatrixWorld();
|
||||
return {
|
||||
scene,
|
||||
camera,
|
||||
mesh,
|
||||
geometry,
|
||||
material,
|
||||
porcelana,
|
||||
lima,
|
||||
luz,
|
||||
pose: new Euler(),
|
||||
rotation: new Matrix4(),
|
||||
// o raio do ponteiro levado para dentro da peça e onde ele fura o plano dela
|
||||
inversa: new Matrix4(),
|
||||
raio: new Ray(),
|
||||
mira: new Vector3(),
|
||||
};
|
||||
}
|
||||
|
||||
type World = ReturnType<typeof buildWorld>;
|
||||
type Tier = NonNullable<BlocosSceneProps["tier"]>;
|
||||
|
||||
type SceneState = {
|
||||
/** Progresso amortecido (`full`). */
|
||||
progress: number;
|
||||
/** Entrada, de 0 a 1. */
|
||||
intro: number;
|
||||
/** Relógio da troca sozinha (`lite`, s), que só anda à vista e depois da entrada. */
|
||||
clock: number;
|
||||
/** `full`: tempo (s) com a página parada no topo, sem rolagem; acima de 0, o ciclo em repouso tem o comando. */
|
||||
quieto: number;
|
||||
/** O alvo do progresso no quadro anterior (rolagem é ele mudar). */
|
||||
ultimoAlvo: number;
|
||||
/** O estado mostrado, quem o pede e se ele já alcançou o pedido (aí o segue sem atraso). */
|
||||
mostrado: Estado | null;
|
||||
fonte: "ciclo" | "rolagem";
|
||||
preso: boolean;
|
||||
pointer: Vector2;
|
||||
tilt: Vector2;
|
||||
frames: number;
|
||||
/**
|
||||
* Peso da vida em repouso (0 a 1), o estado em que ela corre e o relógio dela e do balanço (s, só anda quando há
|
||||
* quadro: o balanço não salta quando o desenho volta depois de parado).
|
||||
*/
|
||||
vida: number;
|
||||
vidaEstado: number;
|
||||
vidaTempo: number;
|
||||
/** Ponteiro amortecido no plano da peça e o peso dele (0 a 1). */
|
||||
cursor: Vector2;
|
||||
cursorPeso: number;
|
||||
};
|
||||
|
||||
type StepInput = {
|
||||
tier: Tier;
|
||||
target: number;
|
||||
placement: Rect | null;
|
||||
stage: Rect | null;
|
||||
host: { width: number; height: number };
|
||||
/** Tempo real desde o quadro anterior (ms). */
|
||||
delta: number;
|
||||
/** Ponteiro em px do hospedeiro (`full`, com o mouse dentro dele); `null` sem ele. */
|
||||
cursor: Vector2 | null;
|
||||
};
|
||||
|
||||
/** Peso da marca num estado: 1 parado nela, 0 nos outros, entre os dois numa troca de ou para ela. */
|
||||
function pesoMarca({ from, to, t }: Estado) {
|
||||
return (from === 0 ? 1 - t : 0) + (to === 0 ? t : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Um quadro: avança a entrada, o progresso (ou o relógio da troca), a pose e o enquadramento, a vida em repouso e o
|
||||
* cursor, e escreve os uniforms.
|
||||
* Diz se ainda há algo assentando (o próximo quadro sai de imediato), em quanto tempo (s) vem a próxima troca do
|
||||
* ciclo parado (`lite` e `full` no topo) e o estado assentado que a legenda mostra.
|
||||
*/
|
||||
function stepBlocos(world: World, s: SceneState, input: StepInput) {
|
||||
const { tier, target, placement, stage, host, delta } = input;
|
||||
const dt = Math.min(delta / 1000, 1 / 20);
|
||||
s.intro = Math.min(1, s.intro + dt / ENTRADA);
|
||||
let settling = s.intro < 1;
|
||||
let wait = 0;
|
||||
let pedido: Estado;
|
||||
let fonte: SceneState["fonte"] = "ciclo";
|
||||
// a troca sozinha conta tempo real (o quadro seguinte de uma espera chega só no fim dela)
|
||||
const real = Math.min(delta / 1000, FICA + TROCA);
|
||||
if (tier === "full") {
|
||||
// rolagem amortecida: a peça segue o dedo com um leve atraso
|
||||
s.progress += (target - s.progress) * (1 - Math.exp(-dt * 5));
|
||||
if (Math.abs(target - s.progress) > 4e-4) settling = true;
|
||||
else s.progress = target;
|
||||
// parada no topo, sem rolagem desde o quadro anterior: o ciclo em repouso (que começa parado na marca, `FICA_TOPO`
|
||||
// s) assume; qualquer rolagem devolve o comando à rolagem
|
||||
const noTopo =
|
||||
s.intro >= 1 &&
|
||||
target < TOPO &&
|
||||
target === s.ultimoAlvo &&
|
||||
s.progress === target;
|
||||
s.ultimoAlvo = target;
|
||||
s.quieto = noTopo ? s.quieto + real : 0;
|
||||
if (s.quieto > 0) {
|
||||
const ciclo = cicloAt(s.quieto, FICA_TOPO, TROCA);
|
||||
pedido = ciclo;
|
||||
if (ciclo.wait === 0) settling = true;
|
||||
else wait = ciclo.wait;
|
||||
} else {
|
||||
pedido = stateAt(s.progress);
|
||||
fonte = "rolagem";
|
||||
}
|
||||
s.tilt.lerp(s.pointer, 1 - Math.exp(-dt * 3));
|
||||
} else {
|
||||
if (s.intro >= 1) s.clock += real;
|
||||
const lite = cicloAt(s.clock, FICA, TROCA);
|
||||
pedido = lite;
|
||||
if (lite.wait === 0) settling = true;
|
||||
else wait = lite.wait;
|
||||
}
|
||||
// quem pede mudou: a peça vai do estado em que está até o pedido, sem salto, e daí em diante o segue
|
||||
if (fonte !== s.fonte) s.preso = false;
|
||||
s.fonte = fonte;
|
||||
let estado = pedido;
|
||||
if (s.mostrado && !s.preso) {
|
||||
estado = seguir(s.mostrado, pedido, (dt * SEGUE) / TROCA);
|
||||
s.preso =
|
||||
estado.from === pedido.from &&
|
||||
estado.to === pedido.to &&
|
||||
estado.t === pedido.t;
|
||||
if (!s.preso) settling = true;
|
||||
}
|
||||
s.mostrado = { from: estado.from, to: estado.to, t: estado.t };
|
||||
const u = world.material.uniforms;
|
||||
u.uFrom.value = estado.from;
|
||||
u.uTo.value = estado.to;
|
||||
u.uT.value = estado.t;
|
||||
u.uIntro.value = s.intro;
|
||||
|
||||
// peso da marca: 1 parado nela, 0 nos outros estados; pose e enquadramento seguem a troca
|
||||
const w = pesoMarca(estado);
|
||||
// a marca vai na caixa da peça; os outros estados, no ciclo do topo, na caixa dos estados (dentro da da peça, longe
|
||||
// do texto) e, no mergulho, na área útil. `fora` diz quanto a rolagem já pede um estado fora da marca: 0 no topo,
|
||||
// então a rolagem assume do ciclo sem salto (o progresso amortecido parte de lá)
|
||||
const fora =
|
||||
tier === "full" && s.quieto === 0 ? 1 - pesoMarca(stateAt(s.progress)) : 0;
|
||||
let area = stage;
|
||||
if (tier === "full" && placement) {
|
||||
const topo = caixaDosEstados(placement, host);
|
||||
const fim = stage ?? { x: 0, y: 0, ...host };
|
||||
area = {
|
||||
x: MathUtils.lerp(topo.x, fim.x, fora),
|
||||
y: MathUtils.lerp(topo.y, fim.y, fora),
|
||||
width: MathUtils.lerp(topo.width, fim.width, fora),
|
||||
height: MathUtils.lerp(topo.height, fim.height, fora),
|
||||
};
|
||||
}
|
||||
// px por unidade no plano z = 0, para levar o enquadramento (px) para a cena
|
||||
const k =
|
||||
host.height /
|
||||
(2 * DISTANCIA * Math.tan(MathUtils.degToRad(world.camera.fov / 2)));
|
||||
const frame = framePiece(host, placement, w, area);
|
||||
const x = (frame.x - host.width / 2) / k;
|
||||
const y = (host.height / 2 - frame.y) / k;
|
||||
world.mesh.position.set(x, y, 0);
|
||||
world.mesh.scale.setScalar(frame.scale / k);
|
||||
// a pose vale em relação à linha de visada: fora do centro a câmera já vê a peça de lado (à direita, pela
|
||||
// esquerda dela), e a guinada e a elevação descontam esse ângulo para ela aparecer como no pôster
|
||||
const pose = world.pose.set(
|
||||
MathUtils.lerp(FRENTE.pitch, REPOUSO.pitch, w) + Math.atan2(y, DISTANCIA),
|
||||
MathUtils.lerp(FRENTE.yaw, REPOUSO.yaw, w) - Math.atan2(x, DISTANCIA),
|
||||
0,
|
||||
);
|
||||
const sway = tier === "full" ? 0.08 * Math.sin(s.vidaTempo * 0.35) : 0;
|
||||
world.mesh.rotation.set(
|
||||
pose.x - s.tilt.y * 0.06,
|
||||
pose.y + sway + s.tilt.x * 0.1,
|
||||
0,
|
||||
);
|
||||
// a inversa da pose (uma rotação: a transposta) leva a visão de volta à frente da peça, onde os matcaps foram feitos
|
||||
world.luz
|
||||
.setFromMatrix4(world.rotation.makeRotationFromEuler(pose))
|
||||
.transpose();
|
||||
|
||||
// vida em repouso: só parada num estado e depois da entrada; muda de estado só depois de sumir no anterior
|
||||
const parado = estado.from === estado.to && s.intro >= 1;
|
||||
if (parado && s.vida < 0.01) s.vidaEstado = estado.to;
|
||||
const vida = parado && estado.to === s.vidaEstado ? 1 : 0;
|
||||
s.vida += (vida - s.vida) * (1 - Math.exp(-dt * 5));
|
||||
s.vidaTempo += dt;
|
||||
u.uVida.value = s.vida;
|
||||
u.uEstado.value = s.vidaEstado;
|
||||
u.uTempo.value = s.vidaTempo;
|
||||
|
||||
// cursor: o raio da câmera pelo ponteiro, levado para dentro da peça, fura o plano z = 0 dela
|
||||
let mira = 0;
|
||||
if (input.cursor) {
|
||||
world.mesh.updateMatrixWorld();
|
||||
const { raio, camera } = world;
|
||||
raio.origin.setFromMatrixPosition(camera.matrixWorld);
|
||||
raio.direction
|
||||
.set(
|
||||
(input.cursor.x / host.width) * 2 - 1,
|
||||
1 - (input.cursor.y / host.height) * 2,
|
||||
0.5,
|
||||
)
|
||||
.unproject(camera)
|
||||
.sub(raio.origin);
|
||||
raio.applyMatrix4(world.inversa.copy(world.mesh.matrixWorld).invert());
|
||||
if (raio.intersectPlane(PLANO, world.mira)) mira = 1;
|
||||
}
|
||||
if (mira) {
|
||||
// chegando de fora, o ponto já nasce sob o ponteiro; dentro, segue com um leve atraso
|
||||
const segue = s.cursorPeso < 0.01 ? 1 : 1 - Math.exp(-dt * 12);
|
||||
s.cursor.x += (world.mira.x - s.cursor.x) * segue;
|
||||
s.cursor.y += (world.mira.y - s.cursor.y) * segue;
|
||||
}
|
||||
s.cursorPeso += (mira - s.cursorPeso) * (1 - Math.exp(-dt * 6));
|
||||
u.uCursor.value.copy(s.cursor);
|
||||
u.uCursorPeso.value = s.cursorPeso * s.vida;
|
||||
s.frames += 1;
|
||||
// a legenda diz o estado em que a peça assentou, só com ela na caixa dela (no `lite`, sempre): nenhuma durante uma
|
||||
// troca, quando os blocos do estado anterior ainda ocupam o lugar dela
|
||||
const assentada = estado.from === estado.to && estado.t === 0;
|
||||
const legenda =
|
||||
assentada && (tier === "lite" || fora <= 0.001) ? estado.from : null;
|
||||
return { again: settling, wait, ready: s.frames === 2, legenda };
|
||||
}
|
||||
|
||||
/** Solta o contexto WebGL e tira o canvas da página. */
|
||||
function disposeRenderer(renderer: WebGLRenderer) {
|
||||
renderer.dispose();
|
||||
renderer.forceContextLoss();
|
||||
renderer.domElement.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* O renderizador transparente, com antialias, na GPU de alto desempenho, com o canvas dentro de `host`. Lança se o
|
||||
* contexto não abre ou se ele desenha por software (o veredito fica anotado na sessão: `rememberSoftwareWebGL`).
|
||||
* Razão de pixels até 1,75 (`full`) ou 1,5 (`lite`).
|
||||
*/
|
||||
function createRenderer(host: HTMLElement, tier: Tier) {
|
||||
const renderer = new WebGLRenderer({
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
if (isSoftwareWebGL(renderer.getContext())) {
|
||||
rememberSoftwareWebGL();
|
||||
disposeRenderer(renderer);
|
||||
throw new Error("WebGL por software");
|
||||
}
|
||||
renderer.setPixelRatio(
|
||||
Math.min(
|
||||
tier === "lite" ? 1.5 : 1.75,
|
||||
Math.max(1, window.devicePixelRatio || 1),
|
||||
),
|
||||
);
|
||||
const canvas = renderer.domElement;
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
canvas.style.cssText = "display:block;width:100%;height:100%";
|
||||
host.append(canvas);
|
||||
return renderer;
|
||||
}
|
||||
|
||||
type Parts = { renderer: WebGLRenderer; world: World };
|
||||
|
||||
/** Solta a geometria, o material, as texturas e o contexto WebGL. */
|
||||
function disposeParts({ renderer, world }: Parts) {
|
||||
world.geometry.dispose();
|
||||
world.material.dispose();
|
||||
world.porcelana.dispose();
|
||||
world.lima.dispose();
|
||||
disposeRenderer(renderer);
|
||||
}
|
||||
|
||||
type Callbacks = Pick<
|
||||
BlocosSceneProps,
|
||||
"progressRef" | "placementRef" | "stageRef" | "onEstado"
|
||||
> & {
|
||||
onReady: () => void;
|
||||
onError: () => void;
|
||||
};
|
||||
|
||||
export type BlocosEngine = {
|
||||
setVisible: (visible: boolean) => void;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
/** Liga tamanho, mouse (`full`) e perda de contexto, e desenha sob demanda. */
|
||||
function startBlocos(
|
||||
host: HTMLElement,
|
||||
parts: Parts,
|
||||
{
|
||||
progressRef,
|
||||
placementRef,
|
||||
stageRef,
|
||||
onReady,
|
||||
onError,
|
||||
onEstado,
|
||||
}: Callbacks,
|
||||
tier: Tier,
|
||||
): BlocosEngine {
|
||||
const { renderer, world } = parts;
|
||||
const canvas = renderer.domElement;
|
||||
const s: SceneState = {
|
||||
// nasce no ponto da rolagem em que a página já está, sem varrer os estados no caminho
|
||||
progress: progressRef.current ?? 0,
|
||||
intro: 0,
|
||||
clock: 0,
|
||||
quieto: 0,
|
||||
ultimoAlvo: progressRef.current ?? 0,
|
||||
mostrado: null,
|
||||
fonte: "rolagem",
|
||||
preso: true,
|
||||
pointer: new Vector2(),
|
||||
tilt: new Vector2(),
|
||||
frames: 0,
|
||||
vida: 0,
|
||||
vidaEstado: 0,
|
||||
vidaTempo: 0,
|
||||
cursor: new Vector2(),
|
||||
cursorPeso: 0,
|
||||
};
|
||||
/** Último ponteiro do mouse (px da janela), `null` sem mouse ou fora da janela; e ele em px do hospedeiro. */
|
||||
let mouse: Vector2 | null = null;
|
||||
const noHost = new Vector2();
|
||||
const size = { width: 1, height: 1 };
|
||||
const budget = createFrameBudget();
|
||||
let verdict: FrameVerdict = "pending";
|
||||
let alive = true;
|
||||
let visible = false;
|
||||
let raf = 0;
|
||||
/** O quadro pedido veio do quadro anterior (só esses medem o orçamento). */
|
||||
let chained = false;
|
||||
let last = 0;
|
||||
let wake: ReturnType<typeof setTimeout> | undefined;
|
||||
let stopPulse: (() => void) | undefined;
|
||||
/** O último estado avisado para a legenda. */
|
||||
let legenda: number | null = null;
|
||||
|
||||
const request = (fromFrame: boolean) => {
|
||||
if (raf || !alive || !visible) return;
|
||||
chained = fromFrame;
|
||||
raf = requestAnimationFrame(render);
|
||||
};
|
||||
const invalidate = () => request(false);
|
||||
|
||||
/** Para de desenhar: nenhum quadro, batida nem espera pendente. */
|
||||
const halt = () => {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
clearTimeout(wake);
|
||||
wake = undefined;
|
||||
stopPulse?.();
|
||||
stopPulse = undefined;
|
||||
last = 0;
|
||||
};
|
||||
const fail = () => {
|
||||
if (!alive) return;
|
||||
alive = false;
|
||||
halt();
|
||||
onError();
|
||||
};
|
||||
|
||||
const draw = (now: number) => {
|
||||
const delta = last ? now - last : 1000 / 60;
|
||||
last = now;
|
||||
if (chained) verdict = budget(delta);
|
||||
if (verdict === "slow") {
|
||||
rememberSlowScene();
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
let cursor: Vector2 | null = null;
|
||||
if (mouse) {
|
||||
const r = host.getBoundingClientRect();
|
||||
noHost.set(mouse.x - r.left, mouse.y - r.top);
|
||||
if (
|
||||
noHost.x >= 0 &&
|
||||
noHost.y >= 0 &&
|
||||
noHost.x <= r.width &&
|
||||
noHost.y <= r.height
|
||||
)
|
||||
cursor = noHost;
|
||||
}
|
||||
const step = stepBlocos(world, s, {
|
||||
tier,
|
||||
target: progressRef.current ?? 0,
|
||||
placement: placementRef?.current ?? null,
|
||||
stage: stageRef?.current ?? null,
|
||||
host: size,
|
||||
delta,
|
||||
cursor,
|
||||
});
|
||||
renderer.render(world.scene, world.camera);
|
||||
if (step.ready) onReady();
|
||||
if (step.legenda !== legenda) {
|
||||
legenda = step.legenda;
|
||||
onEstado?.(legenda);
|
||||
}
|
||||
// a entrada e os quadros seguintes correm no ritmo da tela até o orçamento ter veredito
|
||||
if (step.again || verdict === "pending") request(true);
|
||||
else if (step.wait) {
|
||||
clearTimeout(wake);
|
||||
wake = setTimeout(invalidate, step.wait * 1000);
|
||||
}
|
||||
};
|
||||
const render = (now: number) => {
|
||||
raf = 0;
|
||||
if (!alive) return;
|
||||
try {
|
||||
draw(now);
|
||||
} catch {
|
||||
fail();
|
||||
}
|
||||
};
|
||||
|
||||
const resize = () => {
|
||||
size.width = Math.max(1, host.clientWidth);
|
||||
size.height = Math.max(1, host.clientHeight);
|
||||
renderer.setSize(size.width, size.height, false);
|
||||
world.camera.aspect = size.width / size.height;
|
||||
world.camera.updateProjectionMatrix();
|
||||
invalidate();
|
||||
};
|
||||
const onPointer = (event: PointerEvent) => {
|
||||
if (event.pointerType !== "mouse") return;
|
||||
s.pointer.set(
|
||||
(event.clientX / window.innerWidth) * 2 - 1,
|
||||
-(event.clientY / window.innerHeight) * 2 + 1,
|
||||
);
|
||||
mouse ??= new Vector2();
|
||||
mouse.set(event.clientX, event.clientY);
|
||||
};
|
||||
// o mouse saiu da janela: os blocos voltam
|
||||
const onLeave = (event: PointerEvent) => {
|
||||
if (!event.relatedTarget) mouse = null;
|
||||
};
|
||||
const onLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
fail();
|
||||
};
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(resize);
|
||||
observer?.observe(host);
|
||||
if (!observer) window.addEventListener("resize", resize);
|
||||
if (tier === "full") {
|
||||
window.addEventListener("pointermove", onPointer, { passive: true });
|
||||
window.addEventListener("pointerout", onLeave, { passive: true });
|
||||
}
|
||||
canvas.addEventListener("webglcontextlost", onLost);
|
||||
resize();
|
||||
|
||||
return {
|
||||
setVisible(next) {
|
||||
visible = next;
|
||||
if (!next || !alive) return halt();
|
||||
// a batida também dá vida ao `lite` parado; a troca sozinha segue com o próprio relógio
|
||||
stopPulse ??= startFramePulse(invalidate);
|
||||
},
|
||||
dispose() {
|
||||
alive = false;
|
||||
halt();
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", resize);
|
||||
window.removeEventListener("pointermove", onPointer);
|
||||
window.removeEventListener("pointerout", onLeave);
|
||||
canvas.removeEventListener("webglcontextlost", onLost);
|
||||
disposeParts(parts);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Cede a vez ao navegador: o passo seguinte roda noutra tarefa. */
|
||||
const nextTask = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/**
|
||||
* Monta a cena em tarefas separadas, para nenhuma passar de uns poucos milissegundos: os ativos, o renderizador, a
|
||||
* cena com a compilação do shader (fora da thread principal) e o primeiro quadro. Devolve `null` se `isAlive` virou
|
||||
* falso no caminho; lança se os ativos não baixam, se o aparelho não cria um contexto WebGL, se ele desenha por
|
||||
* software ou se o shader não compila.
|
||||
*/
|
||||
export async function mountBlocos(
|
||||
host: HTMLElement,
|
||||
callbacks: Callbacks,
|
||||
tier: Tier,
|
||||
isAlive: () => boolean,
|
||||
): Promise<BlocosEngine | null> {
|
||||
const loaded = await loadBlocos();
|
||||
await nextTask();
|
||||
if (!isAlive()) return null;
|
||||
const renderer = createRenderer(host, tier);
|
||||
let parts: Parts | undefined;
|
||||
try {
|
||||
await nextTask();
|
||||
if (!isAlive()) {
|
||||
disposeRenderer(renderer);
|
||||
return null;
|
||||
}
|
||||
parts = { renderer, world: buildWorld(loaded) };
|
||||
await renderer.compileAsync(parts.world.scene, parts.world.camera);
|
||||
await nextTask();
|
||||
} catch (error) {
|
||||
if (parts) disposeParts(parts);
|
||||
else disposeRenderer(renderer);
|
||||
throw error;
|
||||
}
|
||||
if (!isAlive()) {
|
||||
disposeParts(parts);
|
||||
return null;
|
||||
}
|
||||
return startBlocos(host, parts, callbacks, tier);
|
||||
}
|
||||
|
||||
export default function BlocosScene({
|
||||
progressRef,
|
||||
visible,
|
||||
onReady,
|
||||
onError,
|
||||
tier = "full",
|
||||
placementRef,
|
||||
stageRef,
|
||||
onEstado,
|
||||
}: BlocosSceneProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const engine = useRef<BlocosEngine | null>(null);
|
||||
// sempre a última versão dos avisos, sem refazer a cena quando o pai troca de função
|
||||
const callbacks = useRef({ onReady, onError, onEstado });
|
||||
useEffect(() => {
|
||||
callbacks.current = { onReady, onError, onEstado };
|
||||
});
|
||||
// o que a página pediu até a cena ficar pronta (ela nasce em tarefas separadas, depois do `visible` já ter chegado)
|
||||
const wantsVisible = useRef(visible);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
let alive = true;
|
||||
mountBlocos(
|
||||
host,
|
||||
{
|
||||
progressRef,
|
||||
placementRef,
|
||||
stageRef,
|
||||
onReady: () => callbacks.current.onReady(),
|
||||
onEstado: (estado) => callbacks.current.onEstado?.(estado),
|
||||
onError: () => callbacks.current.onError(),
|
||||
},
|
||||
tier,
|
||||
() => alive,
|
||||
)
|
||||
.then((created) => {
|
||||
if (!created) return;
|
||||
if (!alive) return created.dispose();
|
||||
engine.current = created;
|
||||
created.setVisible(wantsVisible.current);
|
||||
})
|
||||
// sem ativos, sem WebGL, WebGL por software ou shader que não compila: o hero fica no pôster
|
||||
.catch(() => {
|
||||
if (alive) callbacks.current.onError();
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
engine.current?.dispose();
|
||||
engine.current = null;
|
||||
};
|
||||
}, [progressRef, placementRef, stageRef, tier]);
|
||||
|
||||
useEffect(() => {
|
||||
wantsVisible.current = visible;
|
||||
engine.current?.setVisible(visible);
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
aria-hidden="true"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -41,11 +41,11 @@ describe("CinematicHome: dados estruturados", () => {
|
||||
expect(container.firstElementChild).toBe(scripts[0]);
|
||||
});
|
||||
|
||||
it("describes only the three offers as Service nodes tied to the person, without repeating what the layout emits", () => {
|
||||
it("describes only the five offers as Service nodes tied to the person, without repeating what the layout emits", () => {
|
||||
const [data] = structuredData();
|
||||
|
||||
expect(data["@context"]).toBe("https://schema.org");
|
||||
expect(data["@graph"]).toHaveLength(3);
|
||||
expect(data["@graph"]).toHaveLength(5);
|
||||
for (const offer of data["@graph"]) {
|
||||
expect(offer["@type"]).toBe("Service");
|
||||
expect(offer.provider).toEqual({ "@id": SEO_IDS.person });
|
||||
|
||||
@@ -7,8 +7,8 @@ import { useCinematicScroll } from "./useCinematicScroll";
|
||||
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de CinematicHome: o endereço do trabalho em destaque (projeto presente com site,
|
||||
* presente sem site, ausente), a repasse das props opcionais, a ordem das cenas (sem rodapé próprio: ele vem do layout) e a ligação dos hooks.
|
||||
* Testes de caminhos-base de CinematicHome: a repasse das props opcionais, a ordem das cenas (sem rodapé próprio: ele
|
||||
* vem do layout) e a ligação dos hooks.
|
||||
* As cenas e os hooks são trocados por dublês que registram o que recebem.
|
||||
*/
|
||||
|
||||
@@ -75,59 +75,6 @@ beforeEach(() => {
|
||||
jest.mocked(useHomeFragmentNavigation).mockReturnValue(onLayoutReady);
|
||||
});
|
||||
|
||||
describe("CinematicHome: endereço do trabalho em destaque", () => {
|
||||
it("path: the featured project (Lavação 047) is in the list with its own domain -> that domain goes to the hero", () => {
|
||||
render(
|
||||
<CinematicHome
|
||||
projects={[
|
||||
project("sev-modaintima", "https://sevmodaintima.com.br"),
|
||||
project("lavacao-047", "https://www.lavacao047.com.br/"),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastHeroProps().workUrl).toEqual({
|
||||
text: "lavacao047.com.br",
|
||||
secure: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("path: the featured project is in the list but has no site -> no address", () => {
|
||||
render(<CinematicHome projects={[project("lavacao-047", null)]} />);
|
||||
|
||||
expect(lastHeroProps().workUrl).toBeNull();
|
||||
});
|
||||
|
||||
it("path: the featured project is not in the list -> no address", () => {
|
||||
render(
|
||||
<CinematicHome
|
||||
projects={[project("sev-modaintima", "https://sevmodaintima.com.br")]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastHeroProps().workUrl).toBeNull();
|
||||
});
|
||||
|
||||
it("path: the featured project only has a hosting address (vercel.app) -> no address bar text", () => {
|
||||
render(
|
||||
<CinematicHome
|
||||
projects={[
|
||||
project("lavacao-047", "https://landing-page-lavacao047.vercel.app"),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastHeroProps().workUrl).toBeNull();
|
||||
});
|
||||
|
||||
it("path: empty project list -> no address and the page still renders", () => {
|
||||
render(<CinematicHome projects={[]} />);
|
||||
|
||||
expect(lastHeroProps().workUrl).toBeNull();
|
||||
expect(screen.getByTestId("scene-contact")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CinematicHome: props e estrutura", () => {
|
||||
it("path: every optional prop given -> each scene receives its own data", () => {
|
||||
const projects = [project("lavacao-047", "https://a.example.com")];
|
||||
|
||||
@@ -8,14 +8,10 @@ import Contact from "../Contact";
|
||||
import Hero, { type HeroStats } from "../Hero";
|
||||
import HomeFaq from "../HomeFaq";
|
||||
import Projects from "../Projects";
|
||||
import { displayUrl } from "../showcase/BrowserFrame";
|
||||
import Testimonials from "../Testimonials";
|
||||
import { WorkModalities } from "../WorkModalities";
|
||||
import CinematicMain from "./CinematicMain";
|
||||
|
||||
/** Projeto do navegador de HeroWork (a Lavação 047), cujo endereço, se for domínio próprio, aparece na barra. */
|
||||
const HERO_WORK_PROJECT = "lavacao-047";
|
||||
|
||||
/**
|
||||
* Dados estruturados da home: as três ofertas como nós Service (provider ligado à pessoa e área de atendimento). Só elas:
|
||||
* o WebSite e a Person já saem do layout, e repeti-los aqui duplicaria os dados. Sem ProfessionalService nem LocalBusiness
|
||||
@@ -44,19 +40,11 @@ export default function CinematicHome({
|
||||
clients?: ClientLink[];
|
||||
testimonials?: Testimonial[];
|
||||
}) {
|
||||
const shownUrl = displayUrl({
|
||||
demoUrl: projects.find((project) => project.id === HERO_WORK_PROJECT)
|
||||
?.demoUrl,
|
||||
});
|
||||
// endereço de hospedagem (vercel.app) nunca vai para a barra: sem domínio próprio, ela mostra "pré-visualização"
|
||||
const heroWorkUrl =
|
||||
shownUrl && !/vercel\.app/i.test(shownUrl.text) ? shownUrl : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
<CinematicMain>
|
||||
<Hero stats={heroStats} clients={clients} workUrl={heroWorkUrl} />
|
||||
<Hero stats={heroStats} clients={clients} />
|
||||
|
||||
<Projects projects={projects} />
|
||||
|
||||
|
||||
@@ -24,16 +24,19 @@ function MockScene(props: SceneProps) {
|
||||
return require("react").createElement("canvas", { "data-testid": "scene" });
|
||||
}
|
||||
|
||||
jest.mock("./HeroFieldScene", () => ({
|
||||
jest.mock("./BlocosScene", () => ({
|
||||
__esModule: true,
|
||||
// ler o módulo falha quando o teste simula o pacote que não carrega (uma promessa rejeitada)
|
||||
get default() {
|
||||
if (mockImportFails) throw new Error("chunk indisponível");
|
||||
return MockScene;
|
||||
},
|
||||
get prepareField() {
|
||||
get prepareBlocos() {
|
||||
if (mockImportFails) throw new Error("chunk indisponível");
|
||||
return () => mockPrepare();
|
||||
return () => {
|
||||
mockPrepare();
|
||||
return Promise.resolve();
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -345,7 +348,7 @@ describe("HeroField: falhas voltam ao hero simples", () => {
|
||||
});
|
||||
|
||||
it("path: low power -> the WebGL probe, the timer and the gesture listeners are never started", async () => {
|
||||
mockMedia(["(pointer: coarse)"]);
|
||||
mockNavigator({ hardwareConcurrency: 4 });
|
||||
const probe = jest.fn(() => ({ getExtension: () => null }));
|
||||
setContext(probe);
|
||||
const addListener = jest.spyOn(window, "addEventListener");
|
||||
@@ -539,7 +542,7 @@ describe("HeroField: rolagem e visibilidade", () => {
|
||||
});
|
||||
|
||||
describe("HeroField: navegador sem matchMedia", () => {
|
||||
it("path: no matchMedia at all -> the dive is not started, but the ambient scene still mounts", async () => {
|
||||
it("path: no matchMedia at all -> the dive is not started, but the scene still mounts", async () => {
|
||||
window.matchMedia = undefined as unknown as typeof window.matchMedia;
|
||||
|
||||
render(<Hero />);
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { rememberSoftwareWebGL } from "@/lib/device";
|
||||
import { rememberSlowScene, rememberSoftwareWebGL } from "@/lib/device";
|
||||
import HeroField, { DIVE_QUERY } from "./HeroField";
|
||||
|
||||
type SceneProps = {
|
||||
visible: boolean;
|
||||
progressRef: { current: number };
|
||||
placementRef?: { current: unknown };
|
||||
stageRef?: { current: unknown };
|
||||
tier?: string;
|
||||
labelsRef?: unknown;
|
||||
onReady: () => void;
|
||||
onError: () => void;
|
||||
onEstado?: (estado: number | null) => void;
|
||||
};
|
||||
const mockScenes: SceneProps[] = [];
|
||||
const mockPrepare = jest.fn();
|
||||
|
||||
jest.mock("./HeroFieldScene", () => ({
|
||||
jest.mock("./BlocosScene", () => ({
|
||||
__esModule: true,
|
||||
default: function MockScene(props: SceneProps) {
|
||||
mockScenes.push(props);
|
||||
@@ -20,7 +24,10 @@ jest.mock("./HeroFieldScene", () => ({
|
||||
"data-testid": "scene",
|
||||
});
|
||||
},
|
||||
prepareField: () => mockPrepare(),
|
||||
prepareBlocos: () => {
|
||||
mockPrepare();
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
|
||||
const original = {
|
||||
@@ -47,14 +54,41 @@ function mockNavigator(values: Record<string, unknown>) {
|
||||
/** O que o globals.css dá ao hero: a altura do mergulho (telas de hero além dele) vem de `--hero-tail`. */
|
||||
const HERO_TAIL = { "--hero-tail": 1.7 } as React.CSSProperties;
|
||||
|
||||
/** O palco e a peça (o pôster) como o Hero os monta: a peça fica na coluna da direita, 760 px à direita do palco. */
|
||||
function Hero() {
|
||||
return (
|
||||
<section className="hero" style={HERO_TAIL}>
|
||||
<HeroField />
|
||||
<div
|
||||
className="hero-stage"
|
||||
style={{ "--site-header-height": "60px" } as React.CSSProperties}
|
||||
>
|
||||
<HeroField />
|
||||
<div className="hero-piece" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const STAGE = { left: 0, top: -40, width: 1440, height: 900 };
|
||||
const PIECE = { left: 760, top: 110, width: 600, height: 620 };
|
||||
/** A área útil do mergulho: 6% a 94% da largura, do cabeçalho (60 px) + 24 px até 70% da altura. */
|
||||
const STAGE_AREA = { x: 86.4, y: 84, width: 1267.2, height: 546 };
|
||||
/** A caixa da peça medida a partir do palco. */
|
||||
const PLACEMENT = { x: 760, y: 150, width: 600, height: 620 };
|
||||
|
||||
function mockLayout() {
|
||||
jest
|
||||
.spyOn(Element.prototype, "getBoundingClientRect")
|
||||
.mockImplementation(function (this: Element) {
|
||||
const box = this.classList.contains("hero-stage")
|
||||
? STAGE
|
||||
: this.classList.contains("hero-piece")
|
||||
? PIECE
|
||||
: { left: 0, top: 0, width: 0, height: 0 };
|
||||
return { ...box, x: box.left, y: box.top } as DOMRect;
|
||||
});
|
||||
}
|
||||
|
||||
const section = () => document.querySelector(".hero") as HTMLElement;
|
||||
|
||||
/** Rola o hero no meio do mergulho e diz se a rolagem chegou a escrever o progresso no CSS. */
|
||||
@@ -83,6 +117,7 @@ async function idle() {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockLayout();
|
||||
sessionStorage.clear();
|
||||
mockScenes.length = 0;
|
||||
mockPrepare.mockClear();
|
||||
@@ -101,6 +136,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
sessionStorage.clear();
|
||||
window.matchMedia = original.matchMedia;
|
||||
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
|
||||
@@ -108,7 +144,7 @@ afterEach(() => {
|
||||
HTMLCanvasElement.prototype.getContext = original.getContext;
|
||||
});
|
||||
|
||||
describe("HeroField (ambient background)", () => {
|
||||
describe("HeroField (the 3D blocks over the hero poster)", () => {
|
||||
it("mounts the WebGL scene only after the page is idle, on a capable desktop", async () => {
|
||||
render(<Hero />);
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
@@ -175,7 +211,13 @@ describe("HeroField (ambient background)", () => {
|
||||
});
|
||||
|
||||
it.each<[string, () => void]>([
|
||||
["a touch pointer", () => mockMedia([DIVE_QUERY, "(pointer: coarse)"])],
|
||||
[
|
||||
"a weak phone (touch with 5 cores)",
|
||||
() => {
|
||||
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
|
||||
mockNavigator({ hardwareConcurrency: 5 });
|
||||
},
|
||||
],
|
||||
["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
|
||||
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
|
||||
["four cores", () => mockNavigator({ hardwareConcurrency: 4 })],
|
||||
@@ -187,13 +229,14 @@ describe("HeroField (ambient background)", () => {
|
||||
undefined;
|
||||
},
|
||||
],
|
||||
// o veredito vem do contexto da cena (HeroFieldScene), que o anota na sessão; a página seguinte já nasce estática
|
||||
// os vereditos vêm do contexto da cena (BlocosScene), que os anota na sessão; a página seguinte já nasce estática
|
||||
[
|
||||
"a software WebGL found earlier in the session",
|
||||
() => rememberSoftwareWebGL(),
|
||||
],
|
||||
["a slow scene found earlier in the session", () => rememberSlowScene()],
|
||||
])(
|
||||
"keeps the light static background (no WebGL, no dive, no scroll work) with %s",
|
||||
"keeps only the poster (no WebGL, no dive, no scroll work) with %s",
|
||||
async (_label, arrange) => {
|
||||
arrange();
|
||||
const { container } = render(<Hero />);
|
||||
@@ -211,7 +254,7 @@ describe("HeroField (ambient background)", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("marks the hero for the dive (the CSS then gives it its scroll block) and follows the hero scroll with --hero-p and --hero-open", async () => {
|
||||
it("marks the hero for the dive (the CSS then gives it its scroll block) and follows the hero scroll with --hero-p", async () => {
|
||||
const { container } = render(<Hero />);
|
||||
const hero = container.querySelector(".hero") as HTMLElement;
|
||||
expect(hero).toHaveAttribute("data-dive");
|
||||
@@ -237,15 +280,13 @@ describe("HeroField (ambient background)", () => {
|
||||
|
||||
scroll(700); // meio do mergulho (1400 px de trilho)
|
||||
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBeCloseTo(0.5);
|
||||
// a máscara já se abriu por completo: o fundo aparece inteiro
|
||||
expect(Number(hero.style.getPropertyValue("--hero-open"))).toBe(1);
|
||||
|
||||
scroll(1400);
|
||||
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBe(1);
|
||||
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||
});
|
||||
|
||||
it("does not start the dive on a short screen (the CSS does not apply there), but the ambient scene still runs", async () => {
|
||||
it("does not start the dive on a short screen (the CSS does not apply there), but the scene still runs", async () => {
|
||||
mockMedia([]);
|
||||
|
||||
render(<Hero />);
|
||||
@@ -277,4 +318,134 @@ describe("HeroField (ambient background)", () => {
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
expect(section().style.getPropertyValue("--hero-p")).toBe("");
|
||||
});
|
||||
|
||||
it("full: covers the stage and hands the scene the measured poster box (where marca rests) and the useful dive area", async () => {
|
||||
const { container } = render(<Hero />);
|
||||
await idle();
|
||||
|
||||
const scene = mockScenes.at(-1);
|
||||
expect(scene?.tier).toBe("full");
|
||||
expect(scene?.placementRef?.current).toEqual(PLACEMENT);
|
||||
const area = scene?.stageRef?.current as typeof STAGE_AREA;
|
||||
for (const key of ["x", "y", "width", "height"] as const)
|
||||
expect(area[key]).toBeCloseTo(STAGE_AREA[key], 6);
|
||||
const field = container.querySelector(".hero-field") as HTMLElement;
|
||||
expect(field).toHaveAttribute("data-tier", "full");
|
||||
expect(field.getAttribute("style")).toBeNull();
|
||||
});
|
||||
|
||||
it("lite (a capable phone): no dive, the canvas box is the poster box, and no placement goes to the scene", async () => {
|
||||
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
|
||||
const { container } = render(<Hero />);
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
|
||||
await idle();
|
||||
|
||||
const scene = mockScenes.at(-1);
|
||||
expect(scene?.tier).toBe("lite");
|
||||
expect(scene?.placementRef).toBeUndefined();
|
||||
expect(scene?.stageRef).toBeUndefined();
|
||||
const field = container.querySelector(".hero-field") as HTMLElement;
|
||||
expect(field).toHaveAttribute("data-tier", "lite");
|
||||
expect(field.style).toMatchObject({
|
||||
left: "760px",
|
||||
top: "150px",
|
||||
width: "600px",
|
||||
height: "620px",
|
||||
});
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
expect(scrollWritesProgress()).toBe(false);
|
||||
});
|
||||
|
||||
it("lite: falls back to the poster when the scene gives up", async () => {
|
||||
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
|
||||
const { container } = render(<Hero />);
|
||||
await idle();
|
||||
act(() => mockScenes.at(-1)?.onReady());
|
||||
|
||||
act(() => mockScenes.at(-1)?.onError());
|
||||
|
||||
const field = container.querySelector(".hero-field") as HTMLElement;
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(field).not.toHaveAttribute("data-ready");
|
||||
expect(field).not.toHaveAttribute("data-tier");
|
||||
// de volta a cobrir o palco (inset: 0 do CSS), sem a caixa em linha
|
||||
expect([field.style.left, field.style.width]).toEqual(["", ""]);
|
||||
});
|
||||
|
||||
/** A legenda da peça, se existir. */
|
||||
const caption = (container: HTMLElement) =>
|
||||
container.querySelector<HTMLElement>(".hero-legenda");
|
||||
const tell = (estado: number | null) =>
|
||||
act(() => mockScenes.at(-1)?.onEstado?.(estado));
|
||||
|
||||
it("captions what the piece is building, under the box of the states and aligned to its left, and nothing on marca", async () => {
|
||||
const { container } = render(<Hero />);
|
||||
await idle();
|
||||
act(() => mockScenes.at(-1)?.onReady());
|
||||
tell(0);
|
||||
expect(caption(container)).toBeNull();
|
||||
|
||||
for (const [estado, texto] of [
|
||||
[1, "Sites e sistemas"],
|
||||
[2, "Integrações"],
|
||||
[3, "Painéis e dados"],
|
||||
] as const) {
|
||||
tell(estado);
|
||||
const legenda = caption(container) as HTMLElement;
|
||||
expect(legenda).toHaveTextContent(texto);
|
||||
expect(legenda).toHaveAttribute("data-shown");
|
||||
}
|
||||
const legenda = caption(container) as HTMLElement;
|
||||
// 12 px abaixo da caixa em que a cena põe os estados no topo, alinhada a eles: a caixa da peça sem os 22%
|
||||
// da esquerda (760 + 132 px), na proporção 17 x 12 (468 x 330,4 px), centrada na altura da peça (150 + 620 / 2)
|
||||
// (os estados têm 14,5 das 17 unidades dela, centrados: a legenda começa onde eles começam)
|
||||
expect(Number.parseFloat(legenda.style.left)).toBeCloseTo(
|
||||
892 + (468 * (1 - 14.5 / 17)) / 2,
|
||||
3,
|
||||
);
|
||||
expect(Number.parseFloat(legenda.style.top)).toBeCloseTo(
|
||||
460 + (468 * 12) / 17 / 2 + 12,
|
||||
3,
|
||||
);
|
||||
// dentro do campo decorativo
|
||||
|
||||
expect(legenda.closest('[aria-hidden="true"]')).not.toBeNull();
|
||||
// cada texto novo é digitado (.ds-type, um passo por caractere)
|
||||
const typed = legenda.querySelector<HTMLElement>(".ds-type");
|
||||
expect(typed?.style.getPropertyValue("--n")).toBe("15");
|
||||
|
||||
// de volta à marca, ou fora da caixa (o mergulho): some, com o último texto ainda no lugar enquanto apaga
|
||||
tell(0);
|
||||
expect(caption(container)).not.toHaveAttribute("data-shown");
|
||||
tell(2);
|
||||
tell(null);
|
||||
expect(caption(container)).not.toHaveAttribute("data-shown");
|
||||
});
|
||||
|
||||
it("shows no caption before the scene is ready, nor after it gives up", async () => {
|
||||
const { container } = render(<Hero />);
|
||||
await idle();
|
||||
tell(1);
|
||||
expect(caption(container)).not.toHaveAttribute("data-shown");
|
||||
|
||||
act(() => mockScenes.at(-1)?.onReady());
|
||||
expect(caption(container)).toHaveAttribute("data-shown");
|
||||
act(() => mockScenes.at(-1)?.onError());
|
||||
expect(caption(container)).toBeNull();
|
||||
});
|
||||
|
||||
it("lite: puts the caption right under the canvas box, from 1024 px up only (below, the piece shares the title's corner)", async () => {
|
||||
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
|
||||
const { container } = render(<Hero />);
|
||||
await idle();
|
||||
act(() => mockScenes.at(-1)?.onReady());
|
||||
tell(1);
|
||||
|
||||
expect(caption(container)?.style).toMatchObject({
|
||||
left: "0px",
|
||||
top: "632px",
|
||||
});
|
||||
expect(caption(container)).toHaveClass("max-lg:hidden");
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,21 @@
|
||||
|
||||
import {
|
||||
type ComponentType,
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isKnownSoftwareWebGL, isLowPowerDevice } from "@/lib/device";
|
||||
import type { HeroFieldSceneProps } from "./HeroFieldScene";
|
||||
import { caixaDosEstados, legendaDe, type Rect } from "@/lib/blocos";
|
||||
import {
|
||||
isKnownSlowScene,
|
||||
isKnownSoftwareWebGL,
|
||||
type SceneTier,
|
||||
sceneTier,
|
||||
} from "@/lib/device";
|
||||
import type { BlocosSceneProps } from "./BlocosScene";
|
||||
import "./hero-legenda.css";
|
||||
|
||||
/** A mesma condição do CSS do mergulho (globals.css): tela com altura para o conteúdo e sem movimento reduzido. */
|
||||
export const DIVE_QUERY =
|
||||
@@ -28,6 +36,13 @@ function heroTailScreens(section: HTMLElement) {
|
||||
const IDLE_SLACK_MS = 20;
|
||||
/** Sem intervalo ocioso folgado até aqui (ms), a cena entra mesmo assim. */
|
||||
const IDLE_PATIENCE_MS = 4000;
|
||||
/** Distância (px) entre a caixa da peça e a legenda abaixo dela. */
|
||||
const LEGENDA_GAP = 12;
|
||||
/**
|
||||
* Recuo da legenda no full, em fração da largura da caixa dos estados: tela, integrações e dados têm até 14,5 das 17
|
||||
* unidades dela, centrados (3d/blocos.py), e a legenda começa onde eles começam.
|
||||
*/
|
||||
const LEGENDA_RECUO = (1 - 14.5 / 17) / 2;
|
||||
/** Gestos que contam como a primeira interação: a cena entra logo depois, sem esperar a folga. */
|
||||
const FIRST_ACTIVITY = [
|
||||
"pointerdown",
|
||||
@@ -38,15 +53,10 @@ const FIRST_ACTIVITY = [
|
||||
"scroll",
|
||||
] as const;
|
||||
|
||||
const smooth = (a: number, b: number, x: number) => {
|
||||
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
|
||||
/**
|
||||
* O navegador tem a API do WebGL2? É só uma leitura: abrir um contexto de teste aqui, na hidratação, custava uma tarefa
|
||||
* de 150 ms (500 ms por software). Se o WebGL2 abre de verdade, e se ele desenha na GPU, quem diz é a própria cena,
|
||||
* no contexto que ela abre para desenhar (HeroFieldScene.createRenderer); o veredito "por software" fica anotado na
|
||||
* no contexto que ela abre para desenhar (createRenderer em BlocosScene); o veredito "por software" fica anotado na
|
||||
* sessão (isKnownSoftwareWebGL), e as páginas seguintes já nascem estáticas.
|
||||
*/
|
||||
const hasWebGL2 = () => typeof window.WebGL2RenderingContext !== "undefined";
|
||||
@@ -91,21 +101,86 @@ function whenIdle(run: () => void) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrega a cena em passos separados, cada um na própria tarefa: o pacote do Three.js, a geometria (pré-calculada
|
||||
* num intervalo ocioso) e só então o Canvas. Devolve `null` se a tela foi desmontada no meio do caminho.
|
||||
* Carrega a cena em passos separados, cada um na própria tarefa: o pacote do Three.js, os ativos (bloco, estados e
|
||||
* matcaps, baixados num intervalo ocioso) e só então o canvas. Devolve `null` se a tela foi desmontada no meio do
|
||||
* caminho.
|
||||
*/
|
||||
async function loadScene(isAlive: () => boolean) {
|
||||
const scene = await import("./HeroFieldScene");
|
||||
const scene = await import("./BlocosScene");
|
||||
await nextIdle();
|
||||
if (!isAlive()) return null;
|
||||
scene.prepareField();
|
||||
await scene.prepareBlocos();
|
||||
await nextIdle();
|
||||
return isAlive() ? scene.default : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liga a rolagem do hero às variáveis CSS --hero-p (0 no topo, 1 no fim do mergulho) e --hero-open (0 em
|
||||
* repouso, 1 quando a máscara do globals.css já se abriu para o mergulho), e marca o hero com data-dive, que
|
||||
* Mede, em relação ao palco que a cena cobre (o pai do elemento raiz, onde ele fica com `inset: 0`), a peça do hero
|
||||
* (o pôster, `.hero-piece`) e a área útil do mergulho: 6% a 94% da largura, do cabeçalho (`--site-header-height`) mais
|
||||
* 24 px até 70% da altura, onde começam as legendas e a faixa escura delas. Mede na montagem, quando a entrada da peça
|
||||
* acaba (`.hero-in` anima o deslocamento) e a cada mudança de tamanho do hero, um quadro depois. Sem a peça, `null`.
|
||||
* Diz também o tamanho do palco. Devolve a função que desfaz tudo.
|
||||
*/
|
||||
function trackPiece(
|
||||
root: HTMLElement,
|
||||
section: HTMLElement,
|
||||
onChange: (
|
||||
piece: Rect | null,
|
||||
stage: Rect | null,
|
||||
size: { width: number; height: number },
|
||||
) => void,
|
||||
) {
|
||||
let frame = 0;
|
||||
const measure = () => {
|
||||
frame = 0;
|
||||
const piece = section.querySelector<HTMLElement>(".hero-piece");
|
||||
const stage = root.parentElement;
|
||||
if (!stage) return onChange(null, null, { width: 0, height: 0 });
|
||||
const origin = stage.getBoundingClientRect();
|
||||
const header =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(stage).getPropertyValue("--site-header-height"),
|
||||
) || 0;
|
||||
const top = header + 24;
|
||||
const area = {
|
||||
x: origin.width * 0.06,
|
||||
y: top,
|
||||
width: origin.width * 0.88,
|
||||
height: Math.max(0, origin.height * 0.7 - top),
|
||||
};
|
||||
const size = { width: origin.width, height: origin.height };
|
||||
if (!piece) return onChange(null, area, size);
|
||||
const box = piece.getBoundingClientRect();
|
||||
onChange(
|
||||
{
|
||||
x: box.left - origin.left,
|
||||
y: box.top - origin.top,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
},
|
||||
area,
|
||||
size,
|
||||
);
|
||||
};
|
||||
const queue = () => {
|
||||
if (!frame) frame = requestAnimationFrame(measure);
|
||||
};
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(queue);
|
||||
observer?.observe(section);
|
||||
if (!observer) window.addEventListener("resize", queue);
|
||||
section.addEventListener("animationend", queue);
|
||||
measure();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", queue);
|
||||
section.removeEventListener("animationend", queue);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Liga a rolagem do hero à variável CSS --hero-p (0 no topo, 1 no fim do mergulho) e marca o hero com data-dive, que
|
||||
* liga o bloco de rolagem do mergulho no CSS. Devolve a função que desfaz tudo.
|
||||
*/
|
||||
function trackDive(
|
||||
@@ -129,8 +204,6 @@ function trackDive(
|
||||
if (p === progress.current) return;
|
||||
progress.current = p;
|
||||
section.style.setProperty("--hero-p", p.toFixed(4));
|
||||
// a máscara só se abre quando o texto e a coluna da direita já quase saíram (o conteúdo some até p ≈ 0,24)
|
||||
section.style.setProperty("--hero-open", smooth(0.16, 0.3, p).toFixed(4));
|
||||
onDone(p >= 0.999);
|
||||
};
|
||||
const queue = () => {
|
||||
@@ -145,7 +218,6 @@ function trackDive(
|
||||
window.removeEventListener("resize", queue);
|
||||
delete section.dataset.dive;
|
||||
section.style.removeProperty("--hero-p");
|
||||
section.style.removeProperty("--hero-open");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,43 +240,71 @@ function trackVisibility(
|
||||
};
|
||||
}
|
||||
|
||||
type Mounted = {
|
||||
Scene: ComponentType<BlocosSceneProps>;
|
||||
tier: Exclude<SceneTier, "static">;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fundo ambiente do hero e dono do mergulho. Só um aparelho que aguenta a cena (WebGL2 na GPU, não por software,
|
||||
* sem toque, com mais de 4 núcleos e mais de 4 GB, sem "economizar dados" e sem movimento reduzido) recebe qualquer coisa daqui: nos
|
||||
* outros o hero é só o palco de 100svh com o fundo estático leve do CSS (.hero-glow), sem ouvinte de rolagem
|
||||
* e sem baixar o Three.js. No aparelho capaz, a cena WebGL entra na primeira interação ou quando a página fica
|
||||
* ociosa com folga, em passos separados (pacote, geometria, Canvas); a rolagem do hero conduz o mergulho
|
||||
* (trackDive). O desenho por software só se descobre no contexto que a cena abre (nunca num contexto de teste na
|
||||
* hidratação): ela avisa por `onError` e o hero volta ao palco estático. Sem legendas sobre o 3D: o espaço da direita
|
||||
* é das molduras.
|
||||
* A cena 3D do hero (os blocos da marca) e dono do mergulho. O pôster (`.hero-piece`, renderizado no Blender) é o
|
||||
* primeiro quadro em todo aparelho; daqui só sai algo onde `sceneTier()` não é `static` e o WebGL2 existe, sem desenho
|
||||
* por software nem cena lenta já descobertos nesta sessão. Fora disso não há ouvinte de rolagem nem download do
|
||||
* Three.js. A cena entra na primeira interação ou quando a página fica ociosa com folga, em passos separados (pacote,
|
||||
* ativos, canvas), por cima do pôster, que some quando ela fica pronta (`data-ready`, no CSS):
|
||||
* - `full` (computador): o canvas cobre o palco; a marca descansa sobre o pôster (`placementRef`) e a rolagem do
|
||||
* hero conduz o mergulho (trackDive), em que a peça vai ao centro e passa por tela, integrações e dados junto das
|
||||
* legendas.
|
||||
* - `lite` (celular capaz): sem mergulho; o canvas ocupa só a caixa da peça e troca de estado sozinho.
|
||||
* Com a cena pronta, uma legenda abaixo da caixa da peça diz o que ela está montando (`legendaDe`, avisada pela cena
|
||||
* em `onEstado`): nenhuma na marca nem fora da caixa, no mergulho.
|
||||
* O desenho por software e a cena lenta só se descobrem no contexto que a cena abre (nunca num contexto de teste na
|
||||
* hidratação): ela avisa por `onError` e o hero volta ao pôster.
|
||||
*/
|
||||
export default function HeroField() {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const progressRef = useRef(0);
|
||||
const placementRef = useRef<Rect | null>(null);
|
||||
// a área útil do mergulho (full): entre o cabeçalho e as legendas
|
||||
const stageRef = useRef<Rect | null>(null);
|
||||
const giveUp = useRef(() => {});
|
||||
const [Scene, setScene] = useState<ComponentType<HeroFieldSceneProps> | null>(
|
||||
const [mounted, setMounted] = useState<Mounted | null>(null);
|
||||
// no lite, a caixa da peça e do canvas (em estilo em linha); no full, a caixa dos estados: a legenda vai abaixo dela
|
||||
const [box, setBox] = useState<Rect | null>(null);
|
||||
// a última legenda (o texto fica enquanto ela some) e quantas vezes entrou (cada entrada digita de novo)
|
||||
const [legenda, setLegenda] = useState<{ texto: string; vez: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [legendaOn, setLegendaOn] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
// fim do mergulho: a câmera parou diante dos dados e Projetos já vai cobrindo o palco; parar o desenho poupa a GPU
|
||||
// fim do mergulho: a cena parou nos dados e Projetos já vai cobrindo o palco; parar o desenho poupa a GPU
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLowPowerDevice()) return;
|
||||
const section = rootRef.current?.closest<HTMLElement>(".hero");
|
||||
if (!section) return;
|
||||
// sem WebGL2, ou com o desenho por software já descoberto nesta sessão: o hero estático de 100svh, já na hidratação,
|
||||
// sem o bloco de rolagem do mergulho. Num aparelho ainda não testado o mergulho liga já (o veredito sai do contexto
|
||||
// da cena, ocioso, sem uma tarefa longa na hidratação); sendo software, a cena avisa e o hero volta a 100svh
|
||||
if (!hasWebGL2() || isKnownSoftwareWebGL()) return;
|
||||
const tier = sceneTier();
|
||||
if (tier === "static") return;
|
||||
const root = rootRef.current;
|
||||
const section = root?.closest<HTMLElement>(".hero");
|
||||
if (!root || !section) return;
|
||||
// sem WebGL2, ou com o desenho por software ou a cena lenta já descobertos nesta sessão: só o pôster, já na
|
||||
// hidratação, sem o bloco de rolagem do mergulho. Num aparelho ainda não testado o mergulho liga já (o veredito sai
|
||||
// do contexto da cena, ocioso, sem uma tarefa longa na hidratação); se ela desistir, avisa e o hero volta ao pôster
|
||||
if (!hasWebGL2() || isKnownSoftwareWebGL() || isKnownSlowScene()) return;
|
||||
let alive = true;
|
||||
const stopDive = window.matchMedia?.(DIVE_QUERY).matches
|
||||
? trackDive(section, progressRef, setDone)
|
||||
: undefined;
|
||||
const stopDive =
|
||||
tier === "full" && window.matchMedia?.(DIVE_QUERY).matches
|
||||
? trackDive(section, progressRef, setDone)
|
||||
: undefined;
|
||||
const stopVisibility = trackVisibility(section, setVisible);
|
||||
// com o pacote que não carrega ou com o contexto perdido: de volta ao hero simples de 100svh
|
||||
const stopPiece = trackPiece(root, section, (piece, stage, size) => {
|
||||
// no full, a legenda vai sob a caixa dos estados (onde a cena põe tela, integrações e dados no topo)
|
||||
setBox(piece && tier === "full" ? caixaDosEstados(piece, size) : piece);
|
||||
if (tier === "lite") return;
|
||||
placementRef.current = piece;
|
||||
stageRef.current = stage;
|
||||
});
|
||||
// com o pacote que não carrega, sem WebGL ou com o contexto perdido: de volta ao pôster
|
||||
giveUp.current = () => {
|
||||
stopDive?.();
|
||||
if (alive) setFailed(true);
|
||||
@@ -213,7 +313,7 @@ export default function HeroField() {
|
||||
loadScene(() => alive)
|
||||
.then((component) => {
|
||||
if (!alive) return;
|
||||
if (component) setScene(() => component);
|
||||
if (component) setMounted({ Scene: component, tier });
|
||||
else giveUp.current();
|
||||
})
|
||||
.catch(() => giveUp.current());
|
||||
@@ -222,28 +322,76 @@ export default function HeroField() {
|
||||
alive = false;
|
||||
stopWaiting();
|
||||
stopVisibility();
|
||||
stopPiece();
|
||||
stopDive?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleReady = useCallback(() => setReady(true), []);
|
||||
const handleError = useCallback(() => giveUp.current(), []);
|
||||
const handleEstado = useCallback((estado: number | null) => {
|
||||
const texto = legendaDe(estado);
|
||||
setLegendaOn(texto !== null);
|
||||
if (texto)
|
||||
setLegenda((anterior) => ({ texto, vez: (anterior?.vez ?? 0) + 1 }));
|
||||
}, []);
|
||||
const scene = mounted && !failed ? mounted : null;
|
||||
const lite = scene?.tier === "lite";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
aria-hidden="true"
|
||||
className="hero-field"
|
||||
data-ready={ready && !failed ? "" : undefined}
|
||||
data-tier={scene?.tier}
|
||||
data-ready={ready && scene ? "" : undefined}
|
||||
style={
|
||||
lite && box
|
||||
? {
|
||||
left: box.x,
|
||||
top: box.y,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{Scene && !failed && (
|
||||
<Scene
|
||||
{scene && (
|
||||
<scene.Scene
|
||||
progressRef={progressRef}
|
||||
placementRef={lite ? undefined : placementRef}
|
||||
stageRef={lite ? undefined : stageRef}
|
||||
tier={scene.tier}
|
||||
visible={visible && !done}
|
||||
onReady={handleReady}
|
||||
onError={handleError}
|
||||
onEstado={handleEstado}
|
||||
/>
|
||||
)}
|
||||
{/* abaixo de 1024 px a peça fica no canto, na linha do rótulo, e a legenda cairia sobre o título: só de 1024 em diante */}
|
||||
{scene && legenda && box && (
|
||||
<p
|
||||
className="hero-legenda absolute flex max-lg:hidden items-center gap-sp-3 whitespace-nowrap font-display text-[13px] leading-[1.5] tracking-[0.02em] text-[rgba(244,242,237,.72)]"
|
||||
data-shown={ready && legendaOn ? "" : undefined}
|
||||
style={
|
||||
lite
|
||||
? { left: 0, top: box.height + LEGENDA_GAP }
|
||||
: {
|
||||
left: box.x + box.width * LEGENDA_RECUO,
|
||||
top: box.y + box.height + LEGENDA_GAP,
|
||||
}
|
||||
}
|
||||
>
|
||||
<span className="h-2 w-2 shrink-0 bg-[var(--slash)]" />
|
||||
<span
|
||||
key={legenda.vez}
|
||||
className="ds-type"
|
||||
style={{ "--n": legenda.texto.length } as CSSProperties}
|
||||
>
|
||||
{legenda.texto}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import HeroFieldScene from "./HeroFieldScene";
|
||||
|
||||
/**
|
||||
* Caminho-base de mountField que HeroFieldScene.test.tsx não exerce: o erro que vem ANTES de as peças existirem
|
||||
* (a nuvem de pontos ou a câmera não se criam). O renderizador já abriu o contexto WebGL, então precisa ser solto
|
||||
* (`disposeRenderer`, o `else` do `catch`), o canvas sai da página e a página é avisada. Os outros caminhos de
|
||||
* mountField (desmontada antes da 1ª tarefa, entre o renderizador e as peças, durante a compilação, shader que não
|
||||
* compila, sucesso) estão em HeroFieldScene.test.tsx.
|
||||
*/
|
||||
|
||||
const mockRenderers: MockRenderer[] = [];
|
||||
let mockBuildThrows = false;
|
||||
|
||||
class MockRenderer {
|
||||
domElement = document.createElement("canvas");
|
||||
render = jest.fn();
|
||||
compileAsync = jest.fn(() => Promise.resolve());
|
||||
dispose = jest.fn();
|
||||
forceContextLoss = jest.fn();
|
||||
setSize = jest.fn();
|
||||
setPixelRatio = jest.fn();
|
||||
// um WebGL que não conta o nome do renderizador (sem WEBGL_debug_renderer_info): não é por software
|
||||
getContext = () => ({ getExtension: () => null });
|
||||
getPixelRatio = () => 1;
|
||||
constructor() {
|
||||
mockRenderers.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
jest.mock("three", () => {
|
||||
const actual = jest.requireActual("three");
|
||||
class FailingCamera extends actual.PerspectiveCamera {
|
||||
constructor(...args: unknown[]) {
|
||||
if (mockBuildThrows) throw new Error("sem memória para a cena");
|
||||
super(...args);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
PerspectiveCamera: FailingCamera,
|
||||
WebGLRenderer: jest.fn(() => new MockRenderer()),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockRenderers.length = 0;
|
||||
mockBuildThrows = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const props = () => ({
|
||||
progressRef: { current: 0 },
|
||||
visible: false,
|
||||
onReady: jest.fn(),
|
||||
onError: jest.fn(),
|
||||
});
|
||||
|
||||
const buildScene = () =>
|
||||
act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
|
||||
describe("mountField: erro antes de as peças existirem", () => {
|
||||
it("path: the scene parts fail to build -> the renderer opened a moment ago is released, the canvas leaves and the page is told once", async () => {
|
||||
mockBuildThrows = true;
|
||||
const scene = props();
|
||||
|
||||
const { container } = render(<HeroFieldScene {...scene} />);
|
||||
await buildScene();
|
||||
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].domElement.isConnected).toBe(false);
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
expect(scene.onReady).not.toHaveBeenCalled();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("path: nothing fails -> the same setup mounts, draws and is released only on unmount (the control for the case above)", async () => {
|
||||
const scene = props();
|
||||
|
||||
const { container, unmount } = render(<HeroFieldScene {...scene} />);
|
||||
await buildScene();
|
||||
|
||||
expect(mockRenderers[0].compileAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].dispose).not.toHaveBeenCalled();
|
||||
expect(container.querySelector("canvas")).not.toBeNull();
|
||||
expect(scene.onError).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,957 +0,0 @@
|
||||
import {
|
||||
type BufferGeometry,
|
||||
type Group,
|
||||
type PerspectiveCamera,
|
||||
type Plane,
|
||||
Ray,
|
||||
type ShaderMaterial,
|
||||
type Vector3,
|
||||
} from "three";
|
||||
import {
|
||||
createField,
|
||||
type FieldEngine,
|
||||
isSettling,
|
||||
prepareField,
|
||||
type Rig,
|
||||
type Tick,
|
||||
updatePointer,
|
||||
updateRig,
|
||||
} from "./HeroFieldScene";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base do quadro da cena 3D (createField, sem React) e das contas que o compõem:
|
||||
* - onReady no 2º quadro, amortecimento da rolagem (limite de dt, progresso ausente), entrada, escurecimento e abertura;
|
||||
* - retrato x paisagem (posição do grupo e distância da câmera) e o foco da lente;
|
||||
* - mouse ativo x parado x toque, raio que acerta ou não o plano, e a quantidade de pontos (denso x leve);
|
||||
* - quando pedir o próximo quadro (entrada e rolagem assentando) e quando deixar para a batida de 30 por segundo (cursor);
|
||||
* - perda do contexto WebGL, falha ao desenhar e limpeza.
|
||||
* O WebGLRenderer é um dublê (o jsdom não tem WebGL); o `three` é o de verdade, com ShaderMaterial, BufferGeometry,
|
||||
* PerspectiveCamera e Group registrando as instâncias criadas. O quadro é chamado direto por `renderAt`, com o relógio
|
||||
* que o teste escolhe.
|
||||
*/
|
||||
|
||||
const mockMaterials: ShaderMaterial[] = [];
|
||||
const mockGeometries: BufferGeometry[] = [];
|
||||
const mockCameras: PerspectiveCamera[] = [];
|
||||
const mockGroups: Group[] = [];
|
||||
const mockCounts = { vectors: 0 };
|
||||
const mockRenderers: MockRenderer[] = [];
|
||||
let mockRenderThrows = false;
|
||||
|
||||
class MockRenderer {
|
||||
domElement = document.createElement("canvas");
|
||||
pixelRatio = 2;
|
||||
render = jest.fn(() => {
|
||||
if (mockRenderThrows) throw new Error("falha ao desenhar");
|
||||
});
|
||||
dispose = jest.fn();
|
||||
forceContextLoss = jest.fn();
|
||||
setSize = jest.fn();
|
||||
setPixelRatio = jest.fn();
|
||||
// um WebGL que não conta o nome do renderizador (sem WEBGL_debug_renderer_info): não é por software
|
||||
getContext = () => ({ getExtension: () => null });
|
||||
getPixelRatio = () => this.pixelRatio;
|
||||
constructor() {
|
||||
mockRenderers.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
jest.mock("three", () => {
|
||||
const actual = jest.requireActual("three");
|
||||
class CapturingMaterial extends actual.ShaderMaterial {
|
||||
constructor(parameters: unknown) {
|
||||
super(parameters);
|
||||
mockMaterials.push(this as unknown as ShaderMaterial);
|
||||
}
|
||||
}
|
||||
class CapturingGeometry extends actual.BufferGeometry {
|
||||
constructor() {
|
||||
super();
|
||||
mockGeometries.push(this as unknown as BufferGeometry);
|
||||
}
|
||||
}
|
||||
class CapturingCamera extends actual.PerspectiveCamera {
|
||||
constructor(...args: unknown[]) {
|
||||
super(...args);
|
||||
mockCameras.push(this as unknown as PerspectiveCamera);
|
||||
}
|
||||
}
|
||||
class CapturingGroup extends actual.Group {
|
||||
constructor() {
|
||||
super();
|
||||
mockGroups.push(this as unknown as Group);
|
||||
}
|
||||
}
|
||||
class CountingVector3 extends actual.Vector3 {
|
||||
constructor(x?: number, y?: number, z?: number) {
|
||||
super(x, y, z);
|
||||
mockCounts.vectors += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
WebGLRenderer: jest.fn(() => new MockRenderer()),
|
||||
ShaderMaterial: CapturingMaterial,
|
||||
BufferGeometry: CapturingGeometry,
|
||||
PerspectiveCamera: CapturingCamera,
|
||||
Group: CapturingGroup,
|
||||
Vector3: CountingVector3,
|
||||
};
|
||||
});
|
||||
|
||||
type Setup = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** `null` simula uma ref sem valor (o código trata como 0). */
|
||||
progress?: number | null;
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
let engines: FieldEngine[] = [];
|
||||
let frameRequests: jest.SpyInstance;
|
||||
|
||||
function setup({
|
||||
width = 1200,
|
||||
height = 800,
|
||||
progress = 0,
|
||||
visible = true,
|
||||
}: Setup = {}) {
|
||||
const host = document.createElement("div");
|
||||
Object.defineProperty(host, "clientWidth", { value: width });
|
||||
Object.defineProperty(host, "clientHeight", { value: height });
|
||||
document.body.append(host);
|
||||
const progressRef = {
|
||||
current: progress === null ? undefined : progress,
|
||||
} as { current: number };
|
||||
const onReady = jest.fn();
|
||||
const onError = jest.fn();
|
||||
const engine = createField(host, { progressRef, onReady, onError });
|
||||
engines.push(engine);
|
||||
engine.setVisible(visible);
|
||||
|
||||
const renderer = mockRenderers[mockRenderers.length - 1];
|
||||
const camera = mockCameras[mockCameras.length - 1];
|
||||
const group = mockGroups[mockGroups.length - 1];
|
||||
const uniforms = () => mockMaterials[mockMaterials.length - 1].uniforms;
|
||||
let clock = 1000;
|
||||
|
||||
/**
|
||||
* Um quadro `deltaMs` depois do anterior; devolve quantos quadros ele pediu em seguida. O primeiro quadro de uma
|
||||
* cena não tem anterior: o passo dele é 1/60 de segundo, seja qual for `deltaMs`.
|
||||
*/
|
||||
function frame(deltaMs = 1000 / 60) {
|
||||
frameRequests.mockClear();
|
||||
clock += deltaMs;
|
||||
engine.renderAt(clock);
|
||||
return frameRequests.mock.calls.length;
|
||||
}
|
||||
|
||||
/** Roda `count` quadros e devolve quantas vezes o próximo quadro foi pedido no último. */
|
||||
function settle(count: number, deltaMs = 50) {
|
||||
let asked = 0;
|
||||
for (let i = 0; i < count; i++) asked = frame(deltaMs);
|
||||
return asked;
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
engine,
|
||||
renderer,
|
||||
camera,
|
||||
group,
|
||||
progressRef,
|
||||
onReady,
|
||||
onError,
|
||||
uniforms,
|
||||
frame,
|
||||
settle,
|
||||
/** O relógio do quadro (ms), para os testes que pedem o tempo em segundos. */
|
||||
now: () => clock,
|
||||
};
|
||||
}
|
||||
|
||||
const originalHardware = Object.getOwnPropertyDescriptor(
|
||||
navigator,
|
||||
"hardwareConcurrency",
|
||||
);
|
||||
|
||||
function setCores(value: number | undefined) {
|
||||
Object.defineProperty(navigator, "hardwareConcurrency", {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
function setWindowSize(width: number, height = 800) {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
value: width,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: height,
|
||||
});
|
||||
}
|
||||
|
||||
function pointerMove(type: string, x = 600, y = 400) {
|
||||
const event = new MouseEvent("pointermove", { clientX: x, clientY: y });
|
||||
Object.defineProperty(event, "pointerType", { value: type });
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCounts.vectors = 0;
|
||||
mockMaterials.length = 0;
|
||||
mockGeometries.length = 0;
|
||||
mockCameras.length = 0;
|
||||
mockGroups.length = 0;
|
||||
mockRenderers.length = 0;
|
||||
mockRenderThrows = false;
|
||||
engines = [];
|
||||
setCores(8);
|
||||
setWindowSize(1200);
|
||||
// o relógio do cursor: performance.now() em segundos é o que `lastMove` guarda
|
||||
jest.spyOn(performance, "now").mockReturnValue(1000);
|
||||
frameRequests = jest
|
||||
.spyOn(window, "requestAnimationFrame")
|
||||
.mockImplementation(() => 1 as unknown as number);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const engine of engines) engine.dispose();
|
||||
jest.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
if (originalHardware)
|
||||
Object.defineProperty(navigator, "hardwareConcurrency", originalHardware);
|
||||
});
|
||||
|
||||
describe("createField: quadro — começo e rolagem", () => {
|
||||
it("path: onReady fires on the second frame, once", () => {
|
||||
const scene = setup();
|
||||
|
||||
scene.frame();
|
||||
expect(scene.onReady).not.toHaveBeenCalled();
|
||||
scene.frame();
|
||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||
scene.frame();
|
||||
scene.frame();
|
||||
|
||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: the scroll progress is damped toward the target (and a long frame cannot overshoot)", () => {
|
||||
const scene = setup({ progress: 1 });
|
||||
|
||||
scene.frame(); // o primeiro quadro: passo de 1/60 de segundo
|
||||
const afterFirst = 1 - Math.exp(-(1 / 60) * 5);
|
||||
scene.frame(5000); // delta enorme: o passo é limitado a 1/20 de segundo
|
||||
|
||||
const expected = 1 - (1 - afterFirst) * Math.exp(-(1 / 20) * 5);
|
||||
expect(scene.uniforms().uScroll.value).toBeCloseTo(expected, 9);
|
||||
expect(scene.uniforms().uScroll.value).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("path: no progress available (undefined) -> treated as the top of the page", () => {
|
||||
const scene = setup({ progress: null });
|
||||
|
||||
scene.settle(5);
|
||||
|
||||
expect(scene.uniforms().uScroll.value).toBe(0);
|
||||
});
|
||||
|
||||
it("path: the intro advances every frame and stops at 1", () => {
|
||||
const scene = setup();
|
||||
|
||||
scene.frame(50); // o primeiro quadro: 1/60 de segundo
|
||||
expect(scene.uniforms().uIntro.value).toBeCloseTo(1 / 60 / 2.6, 9);
|
||||
scene.frame(50);
|
||||
expect(scene.uniforms().uIntro.value).toBeCloseTo((1 / 60 + 0.05) / 2.6, 9);
|
||||
|
||||
scene.settle(80);
|
||||
|
||||
expect(scene.uniforms().uIntro.value).toBe(1);
|
||||
});
|
||||
|
||||
it("path: the scene only dims, and only at the very end of the dive (it never ends in the dark)", () => {
|
||||
const scene = setup({ progress: 0 });
|
||||
scene.settle(10);
|
||||
expect(scene.uniforms().uFade.value).toBe(1);
|
||||
|
||||
scene.progressRef.current = 0.9;
|
||||
scene.settle(120);
|
||||
expect(scene.uniforms().uFade.value).toBe(1);
|
||||
|
||||
scene.progressRef.current = 1;
|
||||
scene.settle(120);
|
||||
|
||||
expect(scene.uniforms().uFade.value).toBeCloseTo(0.6, 6);
|
||||
});
|
||||
|
||||
it("path: layers spread apart only while they face the viewer", () => {
|
||||
const scene = setup({ progress: 0 });
|
||||
scene.settle(5);
|
||||
expect(scene.uniforms().uSpread.value).toBeCloseTo(1.05, 6);
|
||||
|
||||
scene.progressRef.current = 0.5;
|
||||
scene.settle(150);
|
||||
|
||||
expect(scene.uniforms().uSpread.value).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it("path: the clock reaches the shader in seconds since the scene started", () => {
|
||||
const scene = setup();
|
||||
scene.frame(0);
|
||||
scene.frame(2000);
|
||||
expect(scene.uniforms().uTime.value).toBeCloseTo(2, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createField: enquadramento e foco", () => {
|
||||
it("path: landscape window -> the group sits just below center and the camera rests at 9", () => {
|
||||
const scene = setup({ width: 1200, height: 800 });
|
||||
|
||||
scene.frame();
|
||||
|
||||
expect(scene.group.position.y).toBeCloseTo(-0.5, 9);
|
||||
expect(scene.camera.position.z).toBeCloseTo(9, 9);
|
||||
});
|
||||
|
||||
it("path: portrait window -> the group sits lower and the camera backs off to keep the layer in frame", () => {
|
||||
const scene = setup({ width: 400, height: 800 });
|
||||
|
||||
scene.frame();
|
||||
|
||||
expect(scene.group.position.y).toBeLessThan(-0.5);
|
||||
expect(scene.camera.position.z).toBeGreaterThan(9);
|
||||
});
|
||||
|
||||
it("path: the renderer and the camera follow the size of the host", () => {
|
||||
const scene = setup({ width: 900, height: 300 });
|
||||
|
||||
expect(scene.renderer.setSize).toHaveBeenCalledWith(900, 300, false);
|
||||
expect(scene.camera.aspect).toBeCloseTo(3, 9);
|
||||
});
|
||||
|
||||
it("path: a host without size yet (0 x 0) -> never a zero-size canvas or a NaN aspect", () => {
|
||||
const scene = setup({ width: 0, height: 0 });
|
||||
|
||||
expect(scene.renderer.setSize).toHaveBeenCalledWith(1, 1, false);
|
||||
expect(Number.isFinite(scene.camera.aspect)).toBe(true);
|
||||
});
|
||||
|
||||
it("path: end of the dive -> the group has settled back to the center and the camera stopped in front of the data layer", () => {
|
||||
const scene = setup({ progress: 1 });
|
||||
|
||||
scene.settle(200);
|
||||
|
||||
const spread = scene.uniforms().uSpread.value;
|
||||
expect(scene.group.position.y).toBeCloseTo(0, 6);
|
||||
// passou pelas duas primeiras camadas e parou diante da última (a dos dados), a menos de 6 unidades dela
|
||||
expect(scene.camera.position.z).toBeLessThan(0);
|
||||
expect(scene.camera.position.z).toBeGreaterThan(-spread);
|
||||
expect(scene.camera.position.z + spread).toBeLessThan(6);
|
||||
});
|
||||
|
||||
it("path: camera in front of the layers -> the lens focuses on the next layer ahead", () => {
|
||||
const scene = setup({ progress: 0 });
|
||||
|
||||
scene.frame();
|
||||
|
||||
// câmera em 9 e primeira camada em 1,05: foco a 9 - 1,05
|
||||
expect(scene.uniforms().uFocus.value).toBeCloseTo(9 - 1.05, 6);
|
||||
});
|
||||
|
||||
it("path: camera parked in front of the data layer -> the lens focuses on it, never closer than 1.5", () => {
|
||||
const scene = setup({ progress: 1 });
|
||||
|
||||
scene.settle(200);
|
||||
|
||||
const spread = scene.uniforms().uSpread.value;
|
||||
expect(scene.uniforms().uFocus.value).toBeCloseTo(
|
||||
scene.camera.position.z + spread,
|
||||
6,
|
||||
);
|
||||
expect(scene.uniforms().uFocus.value).toBeGreaterThanOrEqual(1.5);
|
||||
});
|
||||
|
||||
it("path: camera between the first layer and the middle one -> the lens focuses on the middle layer, at the origin", () => {
|
||||
const scene = setup({ progress: 0.55 });
|
||||
|
||||
scene.settle(200);
|
||||
|
||||
const spread = scene.uniforms().uSpread.value;
|
||||
const z = scene.camera.position.z;
|
||||
// já passou da primeira camada (z abaixo de spread) e ainda não chegou à do meio (z acima de 0,2)
|
||||
expect(z).toBeGreaterThan(0.2);
|
||||
expect(z).toBeLessThan(spread);
|
||||
expect(scene.uniforms().uFocus.value).toBeCloseTo(Math.max(1.5, z), 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createField: mouse", () => {
|
||||
it("path: pointer type other than mouse (touch, pen) -> ignored, the cursor stays on its own path", () => {
|
||||
const scene = setup();
|
||||
pointerMove("touch");
|
||||
pointerMove("pen");
|
||||
|
||||
// sem mouse, o cursor fantasma passa a valer 0,4 (não 0,8)
|
||||
scene.settle(120);
|
||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
||||
});
|
||||
|
||||
it("path: a mouse moved a moment ago -> the pointer drives the cursor strength to 0.8", () => {
|
||||
const scene = setup();
|
||||
scene.frame(); // o relógio da cena começa
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
for (let i = 0; i < 40; i++) scene.frame(50); // 2 s: o mouse ainda conta
|
||||
|
||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.8, 2);
|
||||
});
|
||||
|
||||
it("path: the mouse stopped long ago (over 2.5 s) -> back to the ghost cursor", () => {
|
||||
const scene = setup();
|
||||
scene.frame(0);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
for (let i = 0; i < 120; i++) scene.frame(50); // 6 s depois
|
||||
|
||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
||||
});
|
||||
|
||||
it("path: a mouse event at timestamp 0 is not counted as activity", () => {
|
||||
const scene = setup();
|
||||
(performance.now as jest.Mock).mockReturnValue(0);
|
||||
pointerMove("mouse");
|
||||
|
||||
for (let i = 0; i < 30; i++) scene.frame(50);
|
||||
|
||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
||||
});
|
||||
|
||||
it("path: cursor strength fades to 0 once the dive is under way", () => {
|
||||
const scene = setup({ progress: 1 });
|
||||
scene.frame();
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
for (let i = 0; i < 200; i++) scene.frame(5);
|
||||
|
||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0, 2);
|
||||
});
|
||||
|
||||
it("path: active mouse and the ray hits the layer plane -> the shader cursor follows the hit point", () => {
|
||||
const scene = setup();
|
||||
jest
|
||||
.spyOn(Ray.prototype, "intersectPlane")
|
||||
.mockImplementation(((_plane: Plane, target: Vector3) =>
|
||||
target.set(1, 0, 2)) as never);
|
||||
// o grupo real está girado e abaixado: aqui o ponto do raio já vale como ponto do grupo
|
||||
scene.group.worldToLocal = ((point: Vector3) => point) as never;
|
||||
scene.frame(16); // um mouse no instante 0 da cena não conta como atividade
|
||||
const before = scene.uniforms().uMouse.value.clone();
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
scene.frame(50);
|
||||
|
||||
const factor = 1 - Math.exp(-(1 / 20) * 10);
|
||||
expect(scene.uniforms().uMouse.value.x).toBeCloseTo(
|
||||
before.x + (1 - before.x) * factor,
|
||||
6,
|
||||
);
|
||||
expect(scene.uniforms().uMouse.value.y).toBeCloseTo(
|
||||
before.y + (2 - before.y) * factor,
|
||||
6,
|
||||
);
|
||||
});
|
||||
|
||||
it("path: active mouse but the ray misses the plane -> the shader cursor stays where it was", () => {
|
||||
const scene = setup();
|
||||
jest.spyOn(Ray.prototype, "intersectPlane").mockReturnValue(null as never);
|
||||
scene.frame(16); // um mouse no instante 0 da cena não conta como atividade
|
||||
const before = scene.uniforms().uMouse.value.clone();
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
scene.frame(50);
|
||||
|
||||
expect(scene.uniforms().uMouse.value.x).toBe(before.x);
|
||||
expect(scene.uniforms().uMouse.value.y).toBe(before.y);
|
||||
});
|
||||
|
||||
it("path: idle mouse -> the ghost cursor wanders on its own", () => {
|
||||
const scene = setup();
|
||||
|
||||
scene.frame(0);
|
||||
scene.frame(9000);
|
||||
|
||||
const { x, y } = scene.uniforms().uMouse.value;
|
||||
expect(Math.hypot(x, y)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createField: quando pedir o próximo quadro", () => {
|
||||
it("path: the intro is still running -> a frame is requested even when the hero is not visible", () => {
|
||||
const scene = setup({ visible: false });
|
||||
|
||||
expect(scene.frame()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: everything settled and the hero is visible but nothing interacts -> no extra frame (the pulse does it)", () => {
|
||||
const scene = setup({ visible: true });
|
||||
scene.settle(200);
|
||||
|
||||
expect(scene.frame(50)).toBe(0);
|
||||
});
|
||||
|
||||
it("path: everything settled and the hero is not visible -> no frame", () => {
|
||||
const scene = setup({ visible: false });
|
||||
scene.settle(200);
|
||||
|
||||
expect(scene.frame(50)).toBe(0);
|
||||
});
|
||||
|
||||
it("path: settled, visible, and the mouse is moving -> the frame draws but asks for no other (the pulse brings the next at 30 Hz)", () => {
|
||||
const scene = setup({ visible: true });
|
||||
scene.settle(200);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
const drawn = scene.renderer.render.mock.calls.length;
|
||||
|
||||
expect(scene.frame(50)).toBe(0);
|
||||
|
||||
expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1);
|
||||
});
|
||||
|
||||
it("path: settled, mouse active, but the hero is not visible -> no frame (cursor only counts on screen)", () => {
|
||||
const scene = setup({ visible: false });
|
||||
scene.settle(200);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
expect(scene.frame(50)).toBe(0);
|
||||
});
|
||||
|
||||
it("path: the scroll target moved after the hero left the screen -> still finishes settling", () => {
|
||||
const scene = setup({ visible: false });
|
||||
scene.settle(200);
|
||||
scene.progressRef.current = 0.5;
|
||||
|
||||
expect(scene.frame()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: becoming visible later -> the pulse starts and asks for a frame right away", () => {
|
||||
const scene = setup({ visible: false });
|
||||
scene.settle(200);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
expect(scene.frame(50)).toBe(0);
|
||||
frameRequests.mockClear();
|
||||
|
||||
scene.engine.setVisible(true);
|
||||
|
||||
expect(frameRequests).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: several requests before a frame become ONE animation frame", () => {
|
||||
const scene = setup({ visible: true });
|
||||
scene.settle(200);
|
||||
frameRequests.mockClear();
|
||||
// ligar a batida pede um quadro; religá-la sem que ele tenha vindo não pede outro
|
||||
scene.engine.setVisible(false);
|
||||
scene.engine.setVisible(true);
|
||||
scene.engine.setVisible(false);
|
||||
scene.engine.setVisible(true);
|
||||
expect(frameRequests.mock.calls.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSettling / updatePointer / updateRig (contas puras)", () => {
|
||||
function rig(): Rig {
|
||||
const scene = setup();
|
||||
scene.frame();
|
||||
const material = mockMaterials[mockMaterials.length - 1];
|
||||
return {
|
||||
group: scene.group,
|
||||
camera: scene.camera,
|
||||
uniforms: material.uniforms,
|
||||
layout: {
|
||||
wide: true,
|
||||
tanHalf: Math.tan((35 / 2) * (Math.PI / 180)),
|
||||
spreadMax: 5.5,
|
||||
rest: 9,
|
||||
scale: 1000,
|
||||
camera: [
|
||||
[0, 9],
|
||||
[1, -1],
|
||||
],
|
||||
} as unknown as Rig["layout"],
|
||||
state: {
|
||||
progress: 0,
|
||||
pointer: { set() {}, x: 0, y: 0 } as never,
|
||||
lastMove: -10,
|
||||
mouse: { lerp() {}, x: 0, y: 0, copy() {} } as never,
|
||||
tilt: { lerp() {}, x: 0, y: 0 } as never,
|
||||
frames: 10,
|
||||
},
|
||||
helpers: {} as Rig["helpers"],
|
||||
};
|
||||
}
|
||||
|
||||
const tick = (overrides: Partial<Tick> = {}): Tick => ({
|
||||
p: 0,
|
||||
face: 0,
|
||||
time: 1,
|
||||
dt: 1 / 60,
|
||||
cursorActive: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("isSettling: the intro still running keeps asking, the settled scene does not", () => {
|
||||
const r = rig();
|
||||
r.uniforms.uIntro.value = 1;
|
||||
expect(isSettling(r.uniforms, r.state, 0)).toBe(false);
|
||||
r.uniforms.uIntro.value = 0.5;
|
||||
expect(isSettling(r.uniforms, r.state, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("isSettling: the scroll target far from the shown progress keeps asking, the cursor does not (the pulse does that)", () => {
|
||||
const r = rig();
|
||||
r.uniforms.uIntro.value = 1;
|
||||
r.uniforms.uMouseAmt.value = 0;
|
||||
r.state.frames = 1;
|
||||
r.state.lastMove = 1;
|
||||
expect(isSettling(r.uniforms, r.state, 0)).toBe(false);
|
||||
expect(isSettling(r.uniforms, r.state, 0.5)).toBe(true);
|
||||
});
|
||||
|
||||
it("updatePointer: returns the strength it is heading for (0.8 with the mouse, 0.4 without, none in the dive)", () => {
|
||||
const r = rig();
|
||||
r.helpers = {
|
||||
target: { set: () => ({}), x: 0, y: 0 },
|
||||
ray: { setFromCamera() {}, ray: { intersectPlane: () => null } },
|
||||
plane: { set: () => ({ applyMatrix4() {} }) },
|
||||
up: {},
|
||||
hit: {},
|
||||
} as never;
|
||||
expect(updatePointer(r, tick({ cursorActive: true }))).toBeCloseTo(0.8, 9);
|
||||
expect(updatePointer(r, tick({ cursorActive: false }))).toBeCloseTo(0.4, 9);
|
||||
expect(updatePointer(r, tick({ p: 1 }))).toBe(0);
|
||||
});
|
||||
|
||||
it("updateRig: the rotation goes from three quarters (resting) to facing the viewer (diving)", () => {
|
||||
const r = rig();
|
||||
updateRig(r, tick({ face: 0 }));
|
||||
const resting = r.group.rotation.x;
|
||||
updateRig(r, tick({ face: 1 }));
|
||||
expect(resting).toBeCloseTo(0.42, 9);
|
||||
expect(r.group.rotation.x).toBeCloseTo(Math.PI / 2, 9);
|
||||
// de frente, o balanço lateral some
|
||||
expect(r.group.rotation.y).toBeCloseTo(0, 9);
|
||||
});
|
||||
|
||||
it("isSettling: the scroll damping counts as settling only beyond 0.0004 of the target", () => {
|
||||
const r = rig();
|
||||
r.uniforms.uIntro.value = 1;
|
||||
r.state.progress = 0.5;
|
||||
expect(isSettling(r.uniforms, r.state, 0.50039)).toBe(false);
|
||||
expect(isSettling(r.uniforms, r.state, 0.5005)).toBe(true);
|
||||
});
|
||||
|
||||
it("updateRig: the camera follows the choreography -> the first frame before it starts, a smoothed midpoint, the last frame after it ends", () => {
|
||||
const r = rig();
|
||||
|
||||
updateRig(r, tick({ p: -0.5 }));
|
||||
expect(r.camera.position.z).toBe(9);
|
||||
|
||||
updateRig(r, tick({ p: 0.5 }));
|
||||
expect(r.camera.position.z).toBeCloseTo(4, 9);
|
||||
|
||||
updateRig(r, tick({ p: 2 }));
|
||||
expect(r.camera.position.z).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createField: geometria pré-calculada e quadros sem alocação", () => {
|
||||
it("path: prepareField builds the points before the scene mounts, and mounting reuses them", () => {
|
||||
prepareField();
|
||||
expect(mockGeometries).toHaveLength(1);
|
||||
const prepared = mockGeometries[0];
|
||||
|
||||
prepareField(); // de novo, sem montar: nada é refeito
|
||||
expect(mockGeometries).toHaveLength(1);
|
||||
|
||||
setup();
|
||||
expect(mockGeometries).toHaveLength(1);
|
||||
expect(mockGeometries[0]).toBe(prepared);
|
||||
expect(prepared.getAttribute("position").count).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it("path: the preparation is used once; a second scene builds its own points", () => {
|
||||
prepareField();
|
||||
setup();
|
||||
expect(mockGeometries).toHaveLength(1);
|
||||
|
||||
setup();
|
||||
expect(mockGeometries).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("path: no vector is created per frame beyond the few the frame itself needs, not even with the mouse over the layer", () => {
|
||||
const scene = setup();
|
||||
scene.frame(0);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
scene.frame();
|
||||
const before = mockCounts.vectors;
|
||||
|
||||
for (let i = 0; i < 60; i++) scene.frame();
|
||||
|
||||
// nenhum Vector3 novo: os auxiliares são criados uma vez só
|
||||
expect(mockCounts.vectors).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createField: densidade, contexto WebGL, erros e limpeza", () => {
|
||||
const pointCount = () => {
|
||||
const geometry = mockGeometries[mockGeometries.length - 1];
|
||||
return geometry.getAttribute("position").count;
|
||||
};
|
||||
|
||||
it("path: wide window and 4+ cores -> the dense field", () => {
|
||||
setWindowSize(1200);
|
||||
setCores(8);
|
||||
setup();
|
||||
const dense = pointCount();
|
||||
|
||||
mockGeometries.length = 0;
|
||||
setWindowSize(600);
|
||||
setup();
|
||||
|
||||
expect(dense).toBeGreaterThan(pointCount());
|
||||
});
|
||||
|
||||
it("path: wide window but 2 cores -> the light field", () => {
|
||||
setWindowSize(1200);
|
||||
setCores(2);
|
||||
setup();
|
||||
const light = pointCount();
|
||||
|
||||
mockGeometries.length = 0;
|
||||
setCores(8);
|
||||
setup();
|
||||
|
||||
expect(pointCount()).toBeGreaterThan(light);
|
||||
});
|
||||
|
||||
it("path: the browser does not report cores -> counts as capable (4): dense, not light", () => {
|
||||
setWindowSize(1200);
|
||||
const counts: Record<string, number> = {};
|
||||
for (const [label, cores] of [
|
||||
["dense", 8],
|
||||
["light", 2],
|
||||
["unknown", undefined],
|
||||
] as const) {
|
||||
mockGeometries.length = 0;
|
||||
setCores(cores);
|
||||
setup();
|
||||
counts[label] = pointCount();
|
||||
}
|
||||
|
||||
expect(Math.abs(counts.unknown - counts.dense)).toBeLessThan(
|
||||
Math.abs(counts.unknown - counts.light),
|
||||
);
|
||||
});
|
||||
|
||||
it("path: the WebGL context is lost -> the default recovery is cancelled and the page is told", () => {
|
||||
const scene = setup();
|
||||
const event = new Event("webglcontextlost", { cancelable: true });
|
||||
|
||||
scene.renderer.domElement.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: drawing throws -> the page is told and the scene does not crash the frame", () => {
|
||||
const scene = setup();
|
||||
mockRenderThrows = true;
|
||||
|
||||
expect(() => scene.frame()).not.toThrow();
|
||||
|
||||
expect(scene.onError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: dispose -> geometry, material and renderer are released, the canvas leaves, and the listeners go away", () => {
|
||||
const scene = setup();
|
||||
const geometry = mockGeometries[mockGeometries.length - 1];
|
||||
const material = mockMaterials[mockMaterials.length - 1];
|
||||
const disposeGeometry = jest.spyOn(geometry, "dispose");
|
||||
const disposeMaterial = jest.spyOn(material, "dispose");
|
||||
const event = new Event("webglcontextlost", { cancelable: true });
|
||||
|
||||
scene.engine.dispose();
|
||||
scene.renderer.domElement.dispatchEvent(event);
|
||||
pointerMove("mouse");
|
||||
|
||||
expect(disposeGeometry).toHaveBeenCalledTimes(1);
|
||||
expect(disposeMaterial).toHaveBeenCalledTimes(1);
|
||||
expect(scene.renderer.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(scene.renderer.forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(scene.renderer.domElement.isConnected).toBe(false);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(scene.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: a frame after dispose does nothing", () => {
|
||||
const scene = setup();
|
||||
scene.engine.dispose();
|
||||
scene.renderer.render.mockClear();
|
||||
|
||||
scene.frame();
|
||||
|
||||
expect(scene.renderer.render).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: no ResizeObserver (old browser) -> the window resize event resizes the scene", () => {
|
||||
const original = globalThis.ResizeObserver;
|
||||
// @ts-expect-error o jsdom não tem ResizeObserver; o teste garante a ausência
|
||||
globalThis.ResizeObserver = undefined;
|
||||
try {
|
||||
const scene = setup({ width: 600, height: 400 });
|
||||
scene.renderer.setSize.mockClear();
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
expect(scene.renderer.setSize).toHaveBeenCalledWith(600, 400, false);
|
||||
} finally {
|
||||
globalThis.ResizeObserver = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createField: o cursor sozinho não passa de ~30 quadros por segundo", () => {
|
||||
/** Quantos quadros saem (renderer.render) em `seconds` de uma tela que entrega um quadro a cada `refreshMs`. */
|
||||
function drawnOn(
|
||||
scene: ReturnType<typeof setup>,
|
||||
refreshMs: number,
|
||||
seconds = 1,
|
||||
) {
|
||||
const before = scene.renderer.render.mock.calls.length;
|
||||
const frames = Math.round((seconds * 1000) / refreshMs);
|
||||
for (let i = 0; i < frames; i++) scene.frame(refreshMs);
|
||||
return scene.renderer.render.mock.calls.length - before;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uma tela de `refreshMs` entre quadros, com tudo assentado e o cursor mexendo: o único relógio é a batida (o
|
||||
* setInterval de 33 ms do FramePulse, aqui tocado à mão), e um quadro da tela só roda se alguém o pediu.
|
||||
*/
|
||||
function pulsedScreen(refreshMs: number) {
|
||||
let beat = () => {};
|
||||
jest.spyOn(window, "setInterval").mockImplementation(((
|
||||
callback: () => void,
|
||||
) => {
|
||||
beat = callback;
|
||||
return 7;
|
||||
}) as never);
|
||||
jest.spyOn(window, "clearInterval").mockImplementation(() => {});
|
||||
let pending: FrameRequestCallback | undefined;
|
||||
frameRequests.mockImplementation(((callback: FrameRequestCallback) => {
|
||||
pending = callback;
|
||||
return 1;
|
||||
}) as never);
|
||||
const scene = setup({ visible: true });
|
||||
let clock = 0;
|
||||
let nextBeat = 33;
|
||||
const run = (seconds: number) => {
|
||||
const drawnBefore = scene.renderer.render.mock.calls.length;
|
||||
frameRequests.mockClear();
|
||||
const end = clock + seconds * 1000;
|
||||
for (clock += refreshMs; clock <= end; clock += refreshMs) {
|
||||
for (; nextBeat <= clock; nextBeat += 33) beat();
|
||||
const frame = pending;
|
||||
pending = undefined;
|
||||
frame?.(1000 + clock);
|
||||
}
|
||||
return {
|
||||
drawn: scene.renderer.render.mock.calls.length - drawnBefore,
|
||||
requested: frameRequests.mock.calls.length,
|
||||
};
|
||||
};
|
||||
run(6); // a entrada corre a cada quadro da tela e assenta
|
||||
pointerMove("mouse");
|
||||
return run;
|
||||
}
|
||||
|
||||
it.each([
|
||||
[144, 1000 / 144],
|
||||
[120, 1000 / 120],
|
||||
[60, 1000 / 60],
|
||||
])(
|
||||
"path: a %i Hz screen with the cursor moving -> about 30 frames a second drawn and asked for, never a wake-up per screen frame",
|
||||
(_hz, refreshMs) => {
|
||||
const run = pulsedScreen(refreshMs);
|
||||
|
||||
const { drawn, requested } = run(2);
|
||||
|
||||
expect(drawn).toBeGreaterThanOrEqual(2 * 25);
|
||||
expect(drawn).toBeLessThanOrEqual(2 * 31);
|
||||
// quem pede é a batida: nenhum requestAnimationFrame para pular o desenho e pedir outro
|
||||
expect(requested).toBeLessThanOrEqual(drawn + 1);
|
||||
},
|
||||
);
|
||||
|
||||
it("path: a frame the pulse asks for soon after another is drawn, not skipped (nothing throttles it any more)", () => {
|
||||
const run = pulsedScreen(1000 / 144);
|
||||
const drawn = run(0.5).drawn;
|
||||
expect(drawn).toBeGreaterThanOrEqual(14);
|
||||
// 27,8 ms (4 quadros de 144 Hz) entre duas batidas que se alinharam mal: o desenho sai do mesmo jeito
|
||||
expect(run(0.1).drawn).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("path: the intro and the scroll are not throttled (one frame per screen frame while they settle)", () => {
|
||||
const scene = setup({ visible: true });
|
||||
scene.frame(); // a entrada ainda corre
|
||||
const intro = drawnOn(scene, 1000 / 144, 0.5);
|
||||
expect(intro).toBe(Math.round(500 / (1000 / 144)));
|
||||
|
||||
scene.settle(200); // tudo assentado
|
||||
scene.progressRef.current = 1; // a rolagem se move: o mergulho segue a cada quadro
|
||||
const scrolling = drawnOn(scene, 1000 / 60, 0.25);
|
||||
expect(scrolling).toBe(15);
|
||||
});
|
||||
|
||||
it("path: the frame the pulse asks for 33 ms after the last one is drawn, not skipped", () => {
|
||||
const scene = setup({ visible: true });
|
||||
scene.settle(200);
|
||||
|
||||
scene.frame(33);
|
||||
|
||||
// o quadro de uma batida que sai com folga de temporizador (33 ms) entra
|
||||
expect(scene.renderer.render.mock.calls.length).toBeGreaterThan(0);
|
||||
const drawn = scene.renderer.render.mock.calls.length;
|
||||
scene.frame(33);
|
||||
expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createField: a batida de 30 quadros por segundo", () => {
|
||||
it("path: setVisible(true) twice in a row -> a single pulse; leaving and coming back starts a new one", () => {
|
||||
const interval = jest
|
||||
.spyOn(window, "setInterval")
|
||||
.mockReturnValue(7 as unknown as ReturnType<typeof setInterval>);
|
||||
jest.spyOn(window, "clearInterval").mockImplementation(() => {});
|
||||
const { engine } = setup({ visible: false });
|
||||
expect(interval).not.toHaveBeenCalled();
|
||||
|
||||
engine.setVisible(true);
|
||||
engine.setVisible(true);
|
||||
expect(interval).toHaveBeenCalledTimes(1);
|
||||
|
||||
engine.setVisible(false);
|
||||
engine.setVisible(false); // sair de novo sem estar à vista não desliga nada duas vezes
|
||||
engine.setVisible(true);
|
||||
expect(interval).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,471 +0,0 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { isKnownSoftwareWebGL } from "@/lib/device";
|
||||
import HeroFieldScene from "./HeroFieldScene";
|
||||
|
||||
/*
|
||||
* O componente que monta a cena: cria o renderizador do Three.js (sem React Three Fiber), põe o canvas na página,
|
||||
* liga a batida só com o hero à vista e solta tudo ao sair. A cena nasce em tarefas separadas (renderizador,
|
||||
* compilação do shader, primeiro quadro): os testes deixam essas tarefas correrem com `mount`. O WebGL do jsdom não
|
||||
* existe: o renderizador é um dublê.
|
||||
*/
|
||||
|
||||
type RendererOptions = {
|
||||
alpha?: boolean;
|
||||
antialias?: boolean;
|
||||
powerPreference?: string;
|
||||
};
|
||||
const mockRenderers: MockRenderer[] = [];
|
||||
let mockFailToCreate = false;
|
||||
/** O nome do renderizador que o contexto de teste conta (WEBGL_debug_renderer_info); `null` = a extensão não existe. */
|
||||
let mockRendererName: string | null = null;
|
||||
/** Quando o teste quer segurar a compilação do shader: a promessa que `compileAsync` devolve. */
|
||||
let mockCompile: () => Promise<unknown> = () => Promise.resolve();
|
||||
|
||||
class MockRenderer {
|
||||
domElement = document.createElement("canvas");
|
||||
pixelRatio = 1;
|
||||
options: RendererOptions;
|
||||
render = jest.fn();
|
||||
compileAsync = jest.fn(() => mockCompile());
|
||||
dispose = jest.fn();
|
||||
forceContextLoss = jest.fn();
|
||||
setSize = jest.fn();
|
||||
setPixelRatio = (value: number) => {
|
||||
this.pixelRatio = value;
|
||||
};
|
||||
getPixelRatio = () => this.pixelRatio;
|
||||
getContext = () => ({
|
||||
getExtension: (name: string) =>
|
||||
name === "WEBGL_debug_renderer_info" && mockRendererName !== null
|
||||
? { UNMASKED_RENDERER_WEBGL: 37446 }
|
||||
: null,
|
||||
getParameter: (parameter: number) =>
|
||||
parameter === 37446 ? mockRendererName : null,
|
||||
});
|
||||
constructor(options: RendererOptions) {
|
||||
if (mockFailToCreate) throw new Error("sem WebGL");
|
||||
this.options = options;
|
||||
mockRenderers.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
// a classe é criada só quando a cena pede o renderizador (o mock é içado antes da declaração dela)
|
||||
jest.mock("three", () => ({
|
||||
...jest.requireActual("three"),
|
||||
WebGLRenderer: jest.fn(
|
||||
(options: RendererOptions) => new MockRenderer(options),
|
||||
),
|
||||
}));
|
||||
|
||||
const props = () => ({
|
||||
progressRef: { current: 0 },
|
||||
visible: false,
|
||||
onReady: jest.fn(),
|
||||
onError: jest.fn(),
|
||||
});
|
||||
|
||||
function setDevicePixelRatio(value: number) {
|
||||
Object.defineProperty(window, "devicePixelRatio", {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockRenderers.length = 0;
|
||||
mockFailToCreate = false;
|
||||
mockRendererName = null;
|
||||
mockCompile = () => Promise.resolve();
|
||||
setDevicePixelRatio(1);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
/** Deixa as tarefas da montagem correrem (renderizador, compilação do shader, primeiro quadro pedido). */
|
||||
const buildScene = (ms = 10) =>
|
||||
act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
|
||||
/** Só a tarefa que já estava na fila (as que ela mesma agenda esperam a próxima chamada): um passo da montagem. */
|
||||
const nextTask = () =>
|
||||
act(async () => {
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
});
|
||||
|
||||
async function mount(ui: React.ReactElement) {
|
||||
const result = render(ui);
|
||||
await buildScene();
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("HeroFieldScene (ambient 3D)", () => {
|
||||
it("creates a transparent renderer without antialiasing, on the high-performance GPU, and puts its canvas in the page", async () => {
|
||||
const { container } = await mount(<HeroFieldScene {...props()} />);
|
||||
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
expect(mockRenderers[0].options).toMatchObject({
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
const canvas = container.querySelector("canvas");
|
||||
expect(canvas).toBe(mockRenderers[0].domElement);
|
||||
expect(canvas).toHaveAttribute("aria-hidden", "true");
|
||||
expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0.5, 1],
|
||||
[1, 1],
|
||||
[1.25, 1.25],
|
||||
[2, 1.5],
|
||||
[3, 1.5],
|
||||
])(
|
||||
"caps the pixel ratio at 1.5 (device %s -> %s)",
|
||||
async (device, expected) => {
|
||||
setDevicePixelRatio(device);
|
||||
await mount(<HeroFieldScene {...props()} />);
|
||||
expect(mockRenderers[0].pixelRatio).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("draws on demand: a frame is asked for, never a fixed 60 fps loop, and nothing keeps running once settled", async () => {
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
await mount(<HeroFieldScene {...props()} />);
|
||||
expect(frames).toHaveBeenCalledTimes(1);
|
||||
|
||||
// a entrada ainda corre: cada quadro pede o seguinte, até assentar (2,6 s de entrada)
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
const settled = frames.mock.calls.length;
|
||||
expect(settled).toBeGreaterThan(2);
|
||||
expect(settled).toBeLessThan(5000 / 16);
|
||||
|
||||
// hero fora de cena e tudo assentado: nenhum quadro a mais
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(frames.mock.calls.length).toBe(settled);
|
||||
expect(mockRenderers[0].render.mock.calls.length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it("tells the page it is ready after the second frame", async () => {
|
||||
const scene = props();
|
||||
await mount(<HeroFieldScene {...scene} />);
|
||||
expect(scene.onReady).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts the 30 fps pulse only while the hero is visible, and stops it when it leaves", async () => {
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
const scene = props();
|
||||
const { rerender } = await mount(
|
||||
<HeroFieldScene {...scene} visible={false} />,
|
||||
);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(6000);
|
||||
});
|
||||
const hidden = frames.mock.calls.length;
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
|
||||
rerender(<HeroFieldScene {...scene} visible />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(20); // o quadro que a batida pediu sai
|
||||
});
|
||||
expect(jest.getTimerCount()).toBe(1); // um setInterval, nenhum segundo laço
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(980);
|
||||
});
|
||||
const shown = frames.mock.calls.length - hidden;
|
||||
expect(shown).toBeGreaterThan(20);
|
||||
expect(shown).toBeLessThanOrEqual(34);
|
||||
|
||||
rerender(<HeroFieldScene {...scene} visible={false} />);
|
||||
const stopped = frames.mock.calls.length;
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(frames.mock.calls.length).toBe(stopped);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("releases the context, the canvas and the timers when it unmounts", async () => {
|
||||
const scene = props();
|
||||
const { container, unmount } = await mount(
|
||||
<HeroFieldScene {...scene} visible />,
|
||||
);
|
||||
expect(container.querySelector("canvas")).not.toBeNull();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].domElement.isConnected).toBe(false);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
const drawn = mockRenderers[0].render.mock.calls.length;
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
|
||||
});
|
||||
|
||||
it("does not rebuild the scene when the parent passes new callbacks, and calls the newest ones", async () => {
|
||||
const first = props();
|
||||
const { rerender } = await mount(<HeroFieldScene {...first} />);
|
||||
const second = props();
|
||||
rerender(
|
||||
<HeroFieldScene
|
||||
progressRef={first.progressRef}
|
||||
visible={false}
|
||||
onReady={second.onReady}
|
||||
onError={second.onError}
|
||||
/>,
|
||||
);
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(first.onReady).not.toHaveBeenCalled();
|
||||
expect(second.onReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("tells the page and renders nothing but the empty host when WebGL cannot be created", async () => {
|
||||
mockFailToCreate = true;
|
||||
const scene = props();
|
||||
|
||||
const { container } = await mount(<HeroFieldScene {...scene} />);
|
||||
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, 1], // aparelho que não informa a proporção: 1
|
||||
[0.5, 1], // nunca abaixo de 1
|
||||
[1.25, 1.25],
|
||||
[3, 1.5], // nunca acima de 1,5: cada ponto custa fragmentos e o fundo é ambiente
|
||||
])(
|
||||
"draws at a pixel ratio of %p -> the renderer gets %p",
|
||||
async (device, expected) => {
|
||||
setDevicePixelRatio(device);
|
||||
|
||||
await mount(<HeroFieldScene {...props()} />);
|
||||
|
||||
expect(mockRenderers[0].pixelRatio).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("tells the page when the WebGL context is lost after mounting, through the newest callback", async () => {
|
||||
const scene = props();
|
||||
await mount(<HeroFieldScene {...scene} />);
|
||||
|
||||
act(() => {
|
||||
mockRenderers[0].domElement.dispatchEvent(
|
||||
new Event("webglcontextlost", { cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("built in separate tasks (no single long task)", () => {
|
||||
it("takes the steps one task at a time: the renderer, the point cloud and the shader compile, then the first frame", async () => {
|
||||
let finishCompile = () => {};
|
||||
mockCompile = () =>
|
||||
new Promise((resolve) => {
|
||||
finishCompile = () => resolve(undefined);
|
||||
});
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
const scene = props();
|
||||
|
||||
render(<HeroFieldScene {...scene} />);
|
||||
// nada acontece dentro do commit do React: nem o contexto WebGL
|
||||
expect(mockRenderers).toHaveLength(0);
|
||||
|
||||
await nextTask();
|
||||
// tarefa 1: só o renderizador (o contexto WebGL); a nuvem de pontos e o shader esperam a vez
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
|
||||
await nextTask();
|
||||
// tarefa 2: a nuvem de pontos e o pedido de compilação; o shader compila fora da thread principal e nada foi desenhado
|
||||
expect(mockRenderers[0].compileAsync).toHaveBeenCalledTimes(1);
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
expect(mockRenderers[0].setSize).not.toHaveBeenCalled();
|
||||
|
||||
finishCompile();
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
// a compilação acabou, mas o tamanho e o primeiro quadro esperam a própria tarefa
|
||||
expect(mockRenderers[0].setSize).not.toHaveBeenCalled();
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
|
||||
await nextTask();
|
||||
// tarefa 3: tamanho e o pedido do primeiro quadro, que sai noutra tarefa (requestAnimationFrame)
|
||||
expect(mockRenderers[0].setSize).toHaveBeenCalled();
|
||||
expect(frames).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].render).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
expect(mockRenderers[0].render).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("compiles the very scene it is going to draw", async () => {
|
||||
await mount(<HeroFieldScene {...props()} />);
|
||||
|
||||
const [scene, camera] = mockRenderers[0].compileAsync.mock
|
||||
.calls[0] as unknown as [{ isScene: boolean }, { isCamera: boolean }];
|
||||
expect(scene.isScene).toBe(true);
|
||||
expect(camera.isCamera).toBe(true);
|
||||
await act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
expect(mockRenderers[0].render.mock.calls[0][0]).toBe(scene);
|
||||
});
|
||||
|
||||
it("creates nothing when it is unmounted before the first task (StrictMode, a quick scroll away)", async () => {
|
||||
const scene = props();
|
||||
const { unmount } = render(<HeroFieldScene {...scene} />);
|
||||
|
||||
unmount();
|
||||
await buildScene();
|
||||
|
||||
expect(mockRenderers).toHaveLength(0);
|
||||
expect(scene.onError).not.toHaveBeenCalled();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("frees the context if it is unmounted between the renderer and the point cloud", async () => {
|
||||
const { container, unmount } = render(<HeroFieldScene {...props()} />);
|
||||
await nextTask();
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
|
||||
unmount();
|
||||
await buildScene();
|
||||
|
||||
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].domElement.isConnected).toBe(false);
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
});
|
||||
|
||||
it("releases the context if it is unmounted while the shader compiles", async () => {
|
||||
let finishCompile = () => {};
|
||||
mockCompile = () =>
|
||||
new Promise((resolve) => {
|
||||
finishCompile = () => resolve(undefined);
|
||||
});
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
const { container, unmount } = await mount(
|
||||
<HeroFieldScene {...props()} />,
|
||||
);
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
|
||||
unmount();
|
||||
finishCompile();
|
||||
await buildScene();
|
||||
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].domElement.isConnected).toBe(false);
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tells the page, and frees the context, when the shader does not compile", async () => {
|
||||
mockCompile = () => Promise.reject(new Error("shader inválido"));
|
||||
const scene = props();
|
||||
|
||||
const { container } = await mount(<HeroFieldScene {...scene} />);
|
||||
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
});
|
||||
|
||||
it("applies the visibility the page asked for while it was still being built", async () => {
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
const scene = props();
|
||||
const { rerender } = render(
|
||||
<HeroFieldScene {...scene} visible={false} />,
|
||||
);
|
||||
rerender(<HeroFieldScene {...scene} visible />);
|
||||
|
||||
await buildScene();
|
||||
await act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(980);
|
||||
});
|
||||
|
||||
// a batida de 30 quadros por segundo ligou assim que a cena nasceu
|
||||
expect(frames.mock.calls.length).toBeGreaterThan(20);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("HeroFieldScene: WebGL por software (o teste é o contexto da própria cena)", () => {
|
||||
beforeEach(() => sessionStorage.clear());
|
||||
afterEach(() => sessionStorage.clear());
|
||||
|
||||
it.each([
|
||||
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
|
||||
"llvmpipe (LLVM 15.0.7, 256 bits)",
|
||||
"ANGLE (Microsoft, Microsoft Basic Render Driver (0x0000008C) Direct3D11 vs_5_0 ps_5_0, D3D11)",
|
||||
])(
|
||||
"draws on the CPU (%s): no canvas, the page is told once, the context is freed and the session remembers it",
|
||||
async (name) => {
|
||||
mockRendererName = name;
|
||||
const scene = props();
|
||||
|
||||
const { container } = await mount(<HeroFieldScene {...scene} />);
|
||||
|
||||
// um só contexto: o do teste é o que a cena abriria para desenhar, sem um segundo
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
expect(scene.onReady).not.toHaveBeenCalled();
|
||||
expect(isKnownSoftwareWebGL()).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("draws on the GPU: the canvas stays and the session is not marked", async () => {
|
||||
mockRendererName =
|
||||
"ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11 vs_5_0 ps_5_0, D3D11)";
|
||||
const scene = props();
|
||||
|
||||
const { container } = await mount(<HeroFieldScene {...scene} />);
|
||||
|
||||
expect(container.querySelector("canvas")).toBe(mockRenderers[0].domElement);
|
||||
expect(scene.onError).not.toHaveBeenCalled();
|
||||
expect(isKnownSoftwareWebGL()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not report an unknown renderer (no debug extension) as software", async () => {
|
||||
mockRendererName = null;
|
||||
const scene = props();
|
||||
|
||||
await mount(<HeroFieldScene {...scene} />);
|
||||
|
||||
expect(scene.onError).not.toHaveBeenCalled();
|
||||
expect(isKnownSoftwareWebGL()).toBe(false);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,10 +9,10 @@ import HeroField, { DIVE_QUERY } from "./HeroField";
|
||||
* segue a variável), não o texto do CSS.
|
||||
*/
|
||||
|
||||
jest.mock("./HeroFieldScene", () => ({
|
||||
jest.mock("./BlocosScene", () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
prepareField: () => {},
|
||||
prepareBlocos: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
const win = window as unknown as Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/* Legenda da peça do hero (HeroField): o que a peça está montando. Cada texto novo entra digitado, com o passo e o
|
||||
cursor do rótulo das seções (.ds-type e as animações ds-type, ds-type-caret e ds-type-blink, em globals.css); ela
|
||||
some só pela opacidade. Sem movimento reduzido: com ele a cena nem monta, e a legenda não existe. */
|
||||
.hero-legenda {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s var(--ease-out);
|
||||
}
|
||||
|
||||
.hero-legenda[data-shown] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) and (scripting: enabled) {
|
||||
.hero-legenda .ds-type {
|
||||
animation: ds-type calc(var(--n) * 32ms) steps(var(--n)) forwards;
|
||||
}
|
||||
|
||||
.hero-legenda .ds-type::after {
|
||||
animation:
|
||||
ds-type-caret calc(var(--n) * 32ms) steps(var(--n)) forwards,
|
||||
ds-type-blink 0.9s steps(1) calc(var(--n) * 32ms) forwards;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user