1 Grafo de cena e laço de renderização — Projeto do Professor
Este é o projeto de referência resolvido pelo professor: a implementação que serve de modelo do que cada grupo deve produzir no Projeto Integrador. Ele existe para ser estudado, discutido e criticado — não para ser copiado. O que está aqui é uma solução defensável, com as decisões explicadas uma a uma; a solução de cada grupo será outra, e terá de ser defendida do mesmo jeito.
1.1 Visão geral
O módulo em que a Bancada finalmente aparece, e em que quase nada do que importa é visível.
Dois módulos declarando e sondando, e nenhum triângulo desenhado. Agora há oficina: a bancada, o suporte, as cinco peças e um painel preso ao tampo. A imagem é modesta de propósito. O que este módulo entrega não é a aparência.
O que ele entrega é a estrutura sobre a qual todo o resto se apoia. A cena passa a ser uma árvore, o laço passa a andar contra o relógio, e o custo do quadro passa a ter teto declarado antes de existir conteúdo pesado para culpar.
Nenhuma dessas três coisas se vê olhando a tela. Uma cena montada como lista de coordenadas absolutas produz exatamente a mesma imagem que esta. É por isso que o módulo cobra as três agora, enquanto a oficina tem cinco peças e dá para conferir tudo a olho.
Este é também o módulo em que o code-along carrega o maior peso do percurso. A árvore e o laço são construídos ao vivo, decisão a decisão, com a turma digitando junto. O arquivo que fica é este.
O código nasce em uma pasta nova de núcleo, ao lado das que já existiam. A declaração de domínio do primeiro módulo não é reescrita: ela vira a fonte de quais peças a cena monta, e o compilador passa a cobrar a correspondência que antes dependia de alguém reler a ata.
1.2 A ordem das operações, e o erro que não acusa nada
Duas linhas trocadas de lugar, meio metro de diferença, e nenhuma mensagem de erro.
Cada nó da cena guarda três coisas próprias: onde está, como está girado, em que tamanho. A biblioteca compõe as três sempre na mesma ordem — escala, depois rotação, depois translação — e multiplica o resultado pela matriz do pai.
Inverter duas dessas operações produz uma cena que continua desenhando. Sem erro, sem aviso, sem nada. Há uma peça a meio metro de onde deveria estar e trinta linhas plausíveis para inspecionar.
Por isso a demonstração é código executável, e não parágrafo.
src/bancada/core/transformacao.ts
// ---------------------------------------------------------------------------
// Composição de transformações, e a ordem como conteúdo.
//
// Cada nó da cena guarda translação, rotação e escala próprias, e a biblioteca
// as compõe sempre na mesma ordem: escala primeiro, rotação depois, translação
// por último. A matriz do nó no mundo é a matriz do pai multiplicada por essa.
//
// Inverter duas dessas operações produz uma cena que continua desenhando, sem
// erro algum, com os objetos nos lugares errados. É o defeito mais caro de
// diagnosticar olhando o resultado, porque não há mensagem: há uma peça a meio
// metro de onde deveria estar, e trinta linhas plausíveis para inspecionar.
//
// Por isso a demonstração da ordem é código executável, e não parágrafo. O
// número que ela devolve é a distância entre os dois resultados, e ele encerra a
// discussão sem apelo a intuição.
// ---------------------------------------------------------------------------
import { Matrix4, Quaternion, Vector3, MathUtils } from 'three';
export interface ComparacaoDeOrdem {
/** Onde o ponto vai parar quando se gira e depois se translada. */
readonly girandoPrimeiro: Vector3;
/** Onde o ponto vai parar quando se translada e depois se gira. */
readonly transladandoPrimeiro: Vector3;
/** Distância entre os dois destinos, em metros. */
readonly distancia: number;
}
/**
* Aplica as mesmas duas operações a um mesmo ponto, nas duas ordens possíveis, e
* mede a discrepância.
*
* O caso escolhido não é arbitrário: é o do encaixe. Uma peça precisa ir para a
* posição do socket e assumir a orientação dele. Girar em torno da origem do pai
* e depois deslocar leva a peça a um lugar; deslocar e depois girar em torno da
* origem do pai leva a peça a descrever um arco em torno de um ponto onde ela
* não está. O segundo caso é o erro que se vê em sala.
*/
export function compararOrdem(
ponto: Vector3,
deslocamento: Vector3,
eixo: Vector3,
anguloEmGraus: number,
): ComparacaoDeOrdem {
const rotacao: Matrix4 = new Matrix4().makeRotationFromQuaternion(
new Quaternion().setFromAxisAngle(eixo.clone().normalize(), MathUtils.degToRad(anguloEmGraus)),
);
const translacao: Matrix4 = new Matrix4().makeTranslation(
deslocamento.x,
deslocamento.y,
deslocamento.z,
);
// Multiplicação de matrizes se lê da direita para a esquerda: em `T · R`, a
// rotação toca o ponto primeiro. É a convenção da biblioteca e da álgebra, e é
// o ponto em que a leitura ingênua da linha de código engana.
const girandoPrimeiro: Vector3 = ponto
.clone()
.applyMatrix4(new Matrix4().multiplyMatrices(translacao, rotacao));
const transladandoPrimeiro: Vector3 = ponto
.clone()
.applyMatrix4(new Matrix4().multiplyMatrices(rotacao, translacao));
return {
girandoPrimeiro,
transladandoPrimeiro,
distancia: girandoPrimeiro.distanceTo(transladandoPrimeiro),
};
}
/** Formata um vetor em metros, com a precisão que a conferência a olho exige. */
export function emMetros(v: Vector3): string {
return `(${v.x.toFixed(3)}, ${v.y.toFixed(3)}, ${v.z.toFixed(3)})`;
}
O caso escolhido é o do encaixe, porque é o que a Bancada vai fazer o percurso inteiro. Levar uma peça a trinta centímetros dali e girá-la um quarto de volta. Girando primeiro, ela chega a um lugar; transladando primeiro, ela descreve um arco em torno de um ponto onde não está.
A página imprime os dois destinos e a distância entre eles. O número encerra a discussão sem apelo a intuição, e é ele que a turma vê antes de escrever a primeira transformação própria.
Há uma armadilha de leitura embutida na multiplicação, e ela merece ser dita em voz alta. A conta se lê da direita para a esquerda: em T \cdot R, quem toca o ponto primeiro é a rotação. Quem lê a linha da esquerda para a direita, como se lê texto, entende exatamente o contrário do que o código faz.
1.3 Tarefa 1: Montar a cena como árvore de nós
A imagem não muda; a estrutura por trás dela decide os próximos onze módulos.
Enunciado da tarefa
A cena passa a ser uma hierarquia de objetos, cada um com transformação própria em relação ao pai, em lugar de uma lista de coisas com coordenadas absolutas. A diferença parece contábil neste ponto do percurso e é o que sustenta pegar, encaixar e soltar adiante.
O que fica pronto é a cena mínima do domínio escolhido, montada com pelo menos um nível de aninhamento que exista por razão de projeto: algo que se move junto com outra coisa porque está preso a ela.
1.3.1 O critério que decide cada parentesco
Por que o suporte é filho do tampo, e não da sala? A pergunta parece retórica e não é: as duas montagens desenham a mesma imagem.
O critério é uma frase só. Arrastar a bancada tem de levar junto tudo o que está sobre ela, sem que ninguém escreva uma linha para isso acontecer. O suporte está apoiado no tampo, logo é filho dele. As peças também. O painel também.
Hierarquia montada por acaso — o objeto que virou filho de outro porque foi criado ali — passa despercebida até o dia em que a bancada se move. Aí metade da oficina fica para trás.
src/bancada/core/cena.ts
// ---------------------------------------------------------------------------
// A cena mínima da Bancada, montada como árvore.
//
// A hierarquia daqui não veio da ordem em que os objetos foram criados: veio das
// relações do domínio declarado no primeiro módulo. A sala contém a bancada; a
// bancada carrega o tampo, o suporte, as peças ainda soltas e o painel. Mover a
// bancada move tudo o que está sobre ela, porque na oficina real é isso o que
// acontece — e essa frase é o critério que decide cada parentesco abaixo.
//
// A geometria é paramétrica e propositalmente crua: caixas e cilindros. Modelar
// de verdade, com malha importada, materiais e níveis de detalhe, é assunto dos
// módulos de modelagem e de ativos. Antecipar isso aqui esconderia o conteúdo do
// módulo — a estrutura — atrás de peças bonitas.
//
// A escala é a única coisa que já precisa estar certa: uma unidade é um metro.
// O tampo está a noventa centímetros do chão, altura de bancada de trabalho, e
// essa medida vai ser cobrada de verdade no regime imersivo, quando quem usa
// estiver de pé diante dela em escala real.
// ---------------------------------------------------------------------------
import {
BoxGeometry,
Color,
CylinderGeometry,
DirectionalLight,
Group,
HemisphereLight,
Mesh,
MeshStandardMaterial,
Object3D,
Scene,
} from 'three';
import { BANCADA, type PecaId } from '../dominio/dominio';
/** Altura do tampo, em metros. Bancada de trabalho de pé. */
export const ALTURA_DO_TAMPO: number = 0.9;
export interface CenaDaBancada {
readonly sala: Scene;
readonly bancada: Group;
readonly suporte: Group;
/** O eixo do mecanismo: é ele que gira, e o que estiver preso a ele gira junto. */
readonly eixo: Object3D;
/** Onde o painel diegético se prende. Preso à bancada, e não à câmera. */
readonly suporteDoPainel: Object3D;
/** Cada peça declarada no domínio, com o nó que a representa na cena. */
readonly pecas: ReadonlyMap<PecaId, Object3D>;
}
function materialFosco(cor: number): MeshStandardMaterial {
return new MeshStandardMaterial({ color: cor, roughness: 0.7, metalness: 0.1 });
}
/** A forma provisória de cada peça, escolhida só para que ela seja reconhecível. */
function formaDaPeca(id: PecaId): Mesh {
switch (id) {
case 'corpo':
return new Mesh(new BoxGeometry(0.12, 0.06, 0.12), materialFosco(0x8d99ae));
case 'eixo':
return new Mesh(new CylinderGeometry(0.012, 0.012, 0.22, 16), materialFosco(0xc9cbd6));
case 'engrenagem-grande':
return new Mesh(new CylinderGeometry(0.075, 0.075, 0.016, 24), materialFosco(0xb07d3b));
case 'engrenagem-pequena':
return new Mesh(new CylinderGeometry(0.045, 0.045, 0.016, 20), materialFosco(0xd0a05a));
case 'tampa':
return new Mesh(new BoxGeometry(0.13, 0.012, 0.13), materialFosco(0x6d7280));
}
}
/** Onde cada peça repousa sobre o tampo, antes de qualquer montagem. */
function repousoDaPeca(id: PecaId): [number, number, number] {
switch (id) {
case 'corpo':
return [0, 0.055, 0];
case 'eixo':
return [-0.32, 0.135, 0.12];
case 'engrenagem-grande':
return [0.3, 0.033, 0.1];
case 'engrenagem-pequena':
return [0.45, 0.033, -0.05];
case 'tampa':
return [-0.15, 0.031, -0.18];
}
}
export function montarCena(): CenaDaBancada {
const sala: Scene = new Scene();
sala.name = 'sala';
sala.background = new Color(0x1b1d24);
// Duas luzes, e nenhuma a mais. Cada luz custa em todo material que a recebe,
// e o orçamento deste módulo não é uma formalidade: é o teto que a máquina
// mais modesta da turma impõe.
const ambiente: HemisphereLight = new HemisphereLight(0xdfe6f5, 0x2a2c33, 1.1);
ambiente.name = 'luz-ambiente';
sala.add(ambiente);
const direcional: DirectionalLight = new DirectionalLight(0xffffff, 1.4);
direcional.name = 'luz-direcional';
direcional.position.set(1.2, 2.4, 1.0);
sala.add(direcional);
const bancada: Group = new Group();
bancada.name = 'bancada';
sala.add(bancada);
const tampo: Mesh = new Mesh(new BoxGeometry(1.4, 0.05, 0.8), materialFosco(0x5c4a35));
tampo.name = 'tampo';
tampo.position.y = ALTURA_DO_TAMPO;
bancada.add(tampo);
const pe: Mesh = new Mesh(
new BoxGeometry(1.2, ALTURA_DO_TAMPO - 0.05, 0.6),
materialFosco(0x3f3428),
);
pe.name = 'cavalete';
pe.position.y = (ALTURA_DO_TAMPO - 0.05) / 2;
bancada.add(pe);
// O suporte é filho do tampo: ele está apoiado nele, e arrastar a bancada tem
// de levá-lo junto sem que ninguém escreva uma linha para isso acontecer.
const suporte: Group = new Group();
suporte.name = 'suporte';
suporte.position.set(0, 0.025, 0);
tampo.add(suporte);
const base: Mesh = new Mesh(new BoxGeometry(0.34, 0.02, 0.34), materialFosco(0x2f333d));
base.name = 'base-do-suporte';
base.position.y = 0.01;
suporte.add(base);
const pecas: Map<PecaId, Object3D> = new Map<PecaId, Object3D>();
for (const peca of BANCADA.pecas) {
const no: Mesh = formaDaPeca(peca.id);
no.name = peca.id;
const [x, y, z] = repousoDaPeca(peca.id);
no.position.set(x, y, z);
// As peças nascem sobre o tampo, e não dentro do suporte. A montagem é o que
// vai movê-las de um pai para o outro, e nascer no destino apagaria
// justamente a operação que o módulo existe para ensinar.
tampo.add(no);
pecas.set(peca.id, no);
}
const corpo: Object3D | undefined = pecas.get('corpo');
const eixo: Object3D | undefined = pecas.get('eixo');
if (corpo === undefined || eixo === undefined) {
throw new Error('O domínio não declara o corpo ou o eixo, e a cena não se monta sem eles.');
}
const suporteDoPainel: Object3D = new Object3D();
suporteDoPainel.name = 'suporte-do-painel';
// Preso ao tampo, na borda de trás, virado para quem trabalha. Painel preso à
// câmera acompanharia a cabeça e deixaria de ser objeto do mundo — no visor,
// texto grudado no rosto é a receita conhecida de desconforto.
suporteDoPainel.position.set(0.0, 0.025, -0.36);
tampo.add(suporteDoPainel);
return { sala, bancada, suporte, eixo, suporteDoPainel, pecas };
}
Três decisões deste arquivo merecem justificativa.
A primeira é a escala. Uma unidade é um metro, e o tampo está a 0,9 m do chão, que é altura de bancada de trabalho de pé. Nenhum número desses importa hoje. Todos importam no regime imersivo, quando quem usa estiver de pé diante da bancada em tamanho real, e corrigir escala depois significa refazer cada medida da cena.
A segunda é a geometria crua. Caixas e cilindros, nada importado, nada texturizado. Modelagem de verdade é assunto dos módulos seguintes, e antecipá-la aqui esconderia o conteúdo deste — a estrutura — atrás de peças bonitas.
A terceira é onde as peças nascem. Elas nascem soltas sobre o tampo, e não já encaixadas no suporte. Nascer no destino apagaria justamente a operação que o módulo existe para ensinar.
1.3.2 O palco, e o ajuste que se faz a cada quadro
src/bancada/core/palco.ts
// ---------------------------------------------------------------------------
// O palco: superfície de desenho, câmera e o ajuste ao tamanho disponível.
//
// Duas decisões deste arquivo têm consequência direta no orçamento do módulo.
//
// A primeira é o limite da densidade de pixels. Aparelhos de tela densa pedem
// mais de dois pixels físicos por pixel de layout, e o custo de desenhar cresce
// com a área — dobrar a densidade quadruplica o trabalho. Como o teto de
// hardware desta disciplina é uma máquina de vídeo integrado, a densidade é o
// primeiro lugar onde o orçamento estoura, e o corte tem de estar declarado
// antes de haver cena pesada para culpar.
//
// A segunda é conferir o tamanho a cada quadro, em vez de escutar o evento de
// redimensionamento da janela. O evento cobre um caso; o ambiente tem quatro —
// janela redimensionada, celular girado, barra do navegador que aparece e some,
// e a saída de uma sessão imersiva, que devolve o desenho ao canvas com outra
// medida e sem disparar evento de janela nenhum.
// ---------------------------------------------------------------------------
import { PerspectiveCamera, Scene, WebGLRenderer } from 'three';
/** Teto da densidade de pixels. Acima disto o ganho visual não paga o custo. */
const DENSIDADE_MAXIMA: number = 2;
export interface Palco {
readonly renderer: WebGLRenderer;
readonly camera: PerspectiveCamera;
/** Ajusta o alvo de desenho ao canvas. Devolve `true` quando algo mudou. */
ajustar(): boolean;
desenhar(cena: Scene): void;
}
export function montarPalco(canvas: HTMLCanvasElement): Palco {
// O canal de transparência entra aqui por causa do registro por marcador. Sem
// sessão XR não há compositor do aparelho para pôr a imagem da câmera atrás da
// cena: quem compõe é a página, empilhando o vídeo sob a superfície de desenho,
// e uma superfície opaca esconde o vídeo por completo. O sintoma é cruel — a
// câmera liga, a luz do aparelho acende, a detecção funciona, e a tela mostra
// apenas a cena sobre o fundo cinza de sempre.
const renderer: WebGLRenderer = new WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, DENSIDADE_MAXIMA));
// A câmera olha a bancada de pé, de quem chega para trabalhar nela: altura de
// olho, um metro e meio à frente. O regime não imersivo ganha câmera orbital
// mais adiante; aqui ela é fixa de propósito, porque o que este módulo tem de
// demonstrar é a hierarquia se movendo, e câmera que se move junto confunde os
// dois movimentos.
const camera: PerspectiveCamera = new PerspectiveCamera(55, 1, 0.05, 50);
camera.position.set(0, 1.55, 1.5);
camera.lookAt(0, 0.95, 0);
function ajustar(): boolean {
const densidade: number = Math.min(window.devicePixelRatio, DENSIDADE_MAXIMA);
const largura: number = Math.max(1, Math.floor(canvas.clientWidth * densidade));
const altura: number = Math.max(1, Math.floor(canvas.clientHeight * densidade));
if (canvas.width === largura && canvas.height === altura) {
return false;
}
// O terceiro argumento em falso impede a biblioteca de escrever largura e
// altura no estilo do elemento. Quem manda no tamanho em tela é a folha de
// estilo; aqui só se acerta o tamanho do buffer de desenho.
renderer.setSize(largura, altura, false);
camera.aspect = largura / altura;
camera.updateProjectionMatrix();
return true;
}
function desenhar(cena: Scene): void {
renderer.render(cena, camera);
}
return { renderer, camera, ajustar, desenhar };
}
Duas escolhas aqui têm consequência direta no orçamento, e a primeira é a densidade de pixels. Telas densas pedem mais de dois pixels físicos por pixel de layout, e o custo de desenhar cresce com a área. Dobrar a densidade quadruplica o trabalho.
Como o teto de hardware desta disciplina é uma máquina de vídeo integrado, a densidade é o primeiro lugar onde o orçamento estoura. O corte fica declarado antes de haver cena pesada a que atribuir a culpa.
A segunda é conferir o tamanho a cada quadro, em vez de escutar o evento de redimensionamento da janela. O evento cobre um caso e o ambiente tem quatro: janela redimensionada, celular girado, barra do navegador que aparece e some, e a saída de uma sessão imersiva — que devolve o desenho ao canvas com outra medida e sem disparar evento de janela nenhum.
1.3.3 Onde é fácil errar, e como conferir
O erro previsível desta tarefa é montar a árvore pela ordem de criação e não perceber. Ele não produz sintoma, e a tutoria não o encontra olhando a tela.
Encontra-se perguntando. Por que este objeto é filho daquele? A pergunta é barata e revela hierarquia acidental na primeira resposta hesitante.
A conferência mecânica é a árvore impressa. O ambiente escreve a estrutura na página, um nível por recuo, e ler seis linhas de indentação custa segundos. O que se procura ali é qualquer peça pendurada na raiz, que é onde o acaso costuma deixá-la.
1.4 Tarefa 2: Reparentar sem recalcular à mão
Uma identidade de três símbolos, e o módulo inteiro de manipulação apoiado nela.
Enunciado da tarefa
Prender um objeto a outro pode ser feito recalculando a posição dele a cada quadro ou mudando de quem ele é filho. A segunda forma é uma operação só, e ela nunca sai de sincronia.
A tarefa fecha quando um objeto da cena troca de pai preservando a posição que ocupava no mundo, sem que nenhuma coordenada seja ajustada manualmente. Essa operação é a que a montagem vai consumir, e vale escrevê-la enquanto a cena ainda é pequena e dá para conferir a olho.
1.4.1 Três linhas de álgebra, e a razão de elas estarem aqui
A solução ingênua funciona. Copiar a cada quadro a posição do objeto a que a peça deveria estar presa produz, hoje, exatamente a mesma imagem. É por isso que ela sobrevive: nada no resultado a denuncia.
Ela cobra depois. Cobra quando o objeto intermediário também se move, quando a peça precisa ser solta, e quando alguém inverte a ordem de atualização e a cópia passa a chegar um quadro atrasada.
A operação correta cabe em uma identidade. A transformação local que preserva o mundo é a do mundo vista de dentro do novo pai:
M_{\text{local}} = M_{\text{pai}}^{-1} \cdot M_{\text{mundo}}
src/bancada/core/hierarquia.ts
// ---------------------------------------------------------------------------
// Reparentagem: trocar de pai preservando o lugar no mundo.
//
// Prender uma peça a outra tem duas soluções. A primeira é recalcular a posição
// da peça a cada quadro, copiando a do objeto a que ela deveria estar presa. A
// segunda é mudar de quem ela é filha, uma vez, e nunca mais pensar no assunto.
//
// As duas produzem a mesma imagem hoje, e é por isso que a primeira sobrevive:
// nada no resultado denuncia a escolha. Ela cobra depois — quando o objeto
// intermediário também se move, quando a peça precisa ser solta, quando alguém
// inverte a ordem de atualização e a cópia passa a chegar um quadro atrasada.
//
// A operação está aqui em três linhas de álgebra porque essas três linhas SÃO o
// conteúdo do módulo: a matriz local que preserva o mundo é a matriz do mundo
// vista de dentro do novo pai, `M_local = M_pai⁻¹ · M_mundo`. A biblioteca
// oferece a mesma operação pronta; o que a versão daqui acrescenta é a medida do
// desvio, e é ela que transforma "confio que preservou" em número conferível.
// ---------------------------------------------------------------------------
import { Matrix4, Object3D, Vector3 } from 'three';
/** Reaproveitados entre chamadas: alocar vetor por quadro é lixo que o coletor cobra. */
const matrizLocal: Matrix4 = new Matrix4();
const posicaoAntes: Vector3 = new Vector3();
const posicaoDepois: Vector3 = new Vector3();
/**
* Torna `filho` filho de `novoPai` sem que ele saia do lugar em que estava no
* mundo. Devolve o desvio residual em metros, que é o erro de arredondamento da
* conta e deve ficar na casa dos micrômetros.
*
* Desvio grande não é falha desta função: é sinal de que alguma matriz do
* caminho até a raiz estava desatualizada quando a conta foi feita.
*/
export function reparentar(filho: Object3D, novoPai: Object3D): number {
// Sem esta atualização a conta usa a matriz do quadro anterior, e o objeto
// pula para onde ele estava, não para onde está.
filho.updateWorldMatrix(true, false);
novoPai.updateWorldMatrix(true, false);
posicaoAntes.setFromMatrixPosition(filho.matrixWorld);
matrizLocal.copy(novoPai.matrixWorld).invert().multiply(filho.matrixWorld);
novoPai.add(filho);
matrizLocal.decompose(filho.position, filho.quaternion, filho.scale);
filho.updateWorldMatrix(true, false);
posicaoDepois.setFromMatrixPosition(filho.matrixWorld);
return posicaoAntes.distanceTo(posicaoDepois);
}
/** Posição do nó no mundo, com as matrizes do caminho até a raiz atualizadas. */
export function posicaoDeMundo(no: Object3D, destino: Vector3): Vector3 {
no.updateWorldMatrix(true, false);
return destino.setFromMatrixPosition(no.matrixWorld);
}
/**
* Desenha a árvore em texto, um nível por recuo. Serve à conferência a olho e ao
* painel: hierarquia montada por acaso — objeto que virou filho de outro porque
* foi criado ali — aparece na indentação antes de aparecer no comportamento.
*/
export function descreverArvore(raiz: Object3D, profundidadeMaxima: number = 4): string[] {
const linhas: string[] = [];
function percorrer(no: Object3D, nivel: number): void {
if (nivel > profundidadeMaxima) {
return;
}
const nome: string = no.name === '' ? `<sem nome: ${no.type}>` : no.name;
linhas.push(`${' '.repeat(nivel)}${nome}`);
for (const filho of no.children) {
percorrer(filho, nivel + 1);
}
}
percorrer(raiz, 0);
return linhas;
}
A biblioteca de renderização oferece essa mesma operação pronta, e usá-la seria legítimo. O que a versão daqui acrescenta é o desvio medido: ela devolve, em metros, a distância entre onde o objeto estava e onde ficou. Isso transforma a confiança em um número que aparece na página.
O desvio esperado é da ordem de um bilionésimo de metro — é o arredondamento da conta, e nada mais. Desvio grande não é falha desta função. É sinal de que alguma matriz do caminho até a raiz estava desatualizada quando a conta foi feita, e por isso as duas primeiras linhas do corpo atualizam esse caminho antes de qualquer coisa.
1.4.2 A demonstração que parece errada, e é a prova
A oficina tem um botão que prende a engrenagem grande ao eixo. Ao tocá-lo, a engrenagem não se move. Ela continua deitada no tampo, a trinta centímetros do eixo, exatamente onde estava.
E então começa a girar em torno dele, a distância.
O efeito é estranho e é exatamente o que se quer mostrar. A reparentagem trocou a quem a peça pertence, e não onde ela está. O encaixe — trazer a peça até o socket e assentá-la lá — é outra operação, de outro módulo, e confundir as duas é o que faz um grupo escrever a montagem inteira dentro da função errada.
O botão também desfaz. Soltar devolve a engrenagem ao tampo, de novo sem que ela saia do lugar, e o diário registra as duas posições e o desvio. É a mesma função, no sentido oposto, e ver isso reduz a operação ao que ela é.
1.4.3 Onde é fácil errar, e como conferir
Este é o ponto de atenção do módulo, e ele cobra tarde. O grupo que recalcula posição a cada quadro consegue um resultado idêntico agora, e por isso não vê motivo para mudar.
O preço aparece no módulo de manipulação, quando pegar e encaixar exigem exatamente essa operação. A correção ali custa reescrever o que já funcionava, e o grupo a essa altura tem razão em resistir.
A conferência é o desvio impresso. Prender, ler o número, soltar, ler de novo. Qualquer valor acima de um milímetro significa que a peça pulou, e peça que pula na reparentagem vai pular no encaixe diante da turma.
1.5 Tarefa 3: Fazer o laço andar contra o relógio
O teto entra antes do conteúdo, e essa ordem é a decisão do módulo.
Enunciado da tarefa
O laço de renderização passa a avançar a cena pelo tempo transcorrido, e não pelo número de quadros desenhados. Sem isso o ambiente roda em velocidades diferentes em máquinas diferentes, e o problema aparece primeiro no aparelho de outra pessoa.
O que fica pronto é o laço com relógio próprio e um indicador do custo do quadro exibido na própria cena. O orçamento entra aqui, no começo, para que o teto exista antes de haver conteúdo pesado a caber nele.
1.5.1 O relógio, e o salto que ele se recusa a entregar
src/bancada/core/relogio.ts
// ---------------------------------------------------------------------------
// O relógio do ambiente.
//
// A partir deste módulo a cena avança pelo tempo transcorrido, e não pelo número
// de quadros desenhados. A diferença não aparece na máquina de quem escreve o
// código — aparece no aparelho de outra pessoa, que desenha em outra cadência e
// roda o mesmo mundo em outra velocidade.
//
// O relógio faz uma coisa a mais que o da biblioteca de renderização, e é por
// isso que ele existe aqui: ele limita o salto. Quando a aba perde o foco, ou
// quem usa abre o menu do sistema do visor, o laço para de ser chamado. Ao
// voltar, o intervalo real desde o último quadro pode ser de vários segundos, e
// entregar esse número à cena faz o mecanismo girar meia volta de uma vez.
// ---------------------------------------------------------------------------
/** O que o relógio entrega a cada quadro. Tudo em segundos. */
export interface Amostra {
/** Tempo desde o quadro anterior, já limitado pelo teto de salto. */
readonly delta: number;
/** Tempo acumulado desde o primeiro quadro, somando os deltas limitados. */
readonly decorrido: number;
/** Intervalo real medido, antes do limite. Serve à medição, não à simulação. */
readonly intervaloReal: number;
/** Verdadeiro quando o intervalo real excedeu o teto e foi cortado. */
readonly saltoDescartado: boolean;
}
/** Teto de salto, em segundos. Acima disto a suspensão é tratada como pausa. */
const TETO_DE_SALTO_PADRAO: number = 0.1;
export class Relogio {
private readonly tetoDeSalto: number;
private ultimoInstanteMs: number | undefined = undefined;
private decorrido: number = 0;
constructor(tetoDeSalto: number = TETO_DE_SALTO_PADRAO) {
this.tetoDeSalto = tetoDeSalto;
}
/**
* Recebe o instante que o laço de animação informa, em milissegundos, e
* devolve a amostra do quadro.
*
* O primeiro quadro tem delta zero de propósito: não existe intervalo anterior
* a medir, e inventar um valor plausível aqui é a origem de um solavanco na
* primeira imagem que o ambiente mostra.
*/
avancar(instanteMs: number): Amostra {
const anterior: number | undefined = this.ultimoInstanteMs;
this.ultimoInstanteMs = instanteMs;
if (anterior === undefined) {
return { delta: 0, decorrido: 0, intervaloReal: 0, saltoDescartado: false };
}
const intervaloReal: number = (instanteMs - anterior) / 1000;
const saltoDescartado: boolean = intervaloReal > this.tetoDeSalto;
const delta: number = saltoDescartado ? this.tetoDeSalto : intervaloReal;
this.decorrido += delta;
return { delta, decorrido: this.decorrido, intervaloReal, saltoDescartado };
}
/** Zera a contagem sem destruir o relógio. Usado ao trocar de regime. */
reiniciar(): void {
this.ultimoInstanteMs = undefined;
this.decorrido = 0;
}
}
A biblioteca já traz um relógio, e ele não faz uma coisa que este faz: limitar o salto. Quando a aba perde o foco, ou quando quem usa abre o menu do sistema do visor, o laço deixa de ser chamado. Ao voltar, o intervalo real desde o último quadro pode ser de vários segundos.
Entregar esse número à cena faz o mecanismo girar meia volta de uma vez. Em uma engrenagem, o efeito é feio. Em um objeto que quem usa está segurando, vira um solavanco no campo de visão. Aí o preço deixa de ser estético e o corpo cobra a conta.
O primeiro quadro tem intervalo zero de propósito. Não existe intervalo anterior a medir, e inventar um valor plausível ali é a origem de um solavanco na primeira imagem que o ambiente mostra.
1.5.2 O laço, e uma decisão tomada três módulos antes do motivo
Qual mecanismo pede o próximo quadro? A pergunta parece administrativa e é a de maior consequência deste arquivo.
O laço de animação da janela serve à página comum e não funciona dentro de uma sessão imersiva. O visor tem cadência própria, e quem entrega o quadro dele é a sessão, não a janela. A biblioteca oferece um laço que troca de fonte sozinho quando a sessão começa, e é esse que adotamos agora.
src/bancada/core/laco.ts
// ---------------------------------------------------------------------------
// O laço de renderização.
//
// A decisão que mais pesa aqui é qual mecanismo pede o próximo quadro, e ela é
// tomada três módulos antes de o motivo aparecer. O laço de animação da janela
// serve à página comum e NÃO funciona dentro de uma sessão imersiva: o visor
// tem cadência própria, e quem entrega o quadro dele é a sessão XR, não a
// janela. A biblioteca de renderização oferece um laço que troca de fonte
// sozinho quando a sessão começa, e é esse que adotamos agora.
//
// Adotá-lo desde já custa nada e evita o retrabalho que se vê em sala: o
// ambiente inteiro escrito em torno do laço da janela, funcionando no desktop, e
// congelado no visor — com a página respondendo normalmente, o que faz o
// diagnóstico apontar para todo lado menos para o laço.
//
// A ordem dentro do quadro também não é livre. Ajusta-se o alvo de desenho,
// mede-se o tempo, avança-se a cena e só então se desenha. Desenhar antes de
// avançar entrega ao olho o estado do quadro anterior — um atraso de um quadro
// que ninguém percebe no desktop e que, no visor, é latência somada à que o
// aparelho já tem.
// ---------------------------------------------------------------------------
import type { Scene } from 'three';
import type { Palco } from './palco';
import { Relogio, type Amostra } from './relogio';
import { Orcamento } from './orcamento';
/**
* O que o ambiente faz a cada quadro, antes de a imagem ser desenhada.
*
* O segundo parâmetro chegou no módulo aumentado, e é o quadro da sessão XR.
* Ele carrega o que só pode ser perguntado DENTRO do quadro — a pose da cabeça,
* a resposta da consulta de superfície, a posição atual de uma âncora —, e a
* plataforma o invalida assim que o quadro termina. Guardá-lo para consultar
* depois é o erro clássico daqui: a consulta lança exceção, e o rastro aponta
* para o módulo que consultou, nunca para quem guardou.
*
* Fora de sessão ele simplesmente não existe, e é por isso que o tipo admite a
* ausência: a biblioteca chama o laço da janela com um argumento a menos.
*/
export type PassoDoQuadro = (amostra: Amostra, quadroXR: XRFrame | undefined) => void;
export interface Laco {
aoPasso(passo: PassoDoQuadro): void;
iniciar(): void;
parar(): void;
}
export function montarLaco(palco: Palco, cena: Scene, relogio: Relogio, orcamento: Orcamento): Laco {
const passos: PassoDoQuadro[] = [];
// O tipo do segundo parâmetro que a biblioteca declara NÃO admite ausência, e
// a execução dela admite: fora de sessão a chamada vem com um argumento a
// menos. Declarar aqui o que de fato chega é o que permite verificar a
// ausência adiante sem que o verificador considere a verificação supérflua e
// deixe passar o acesso que estoura no desktop.
function quadro(instanteMs: number, quadroXR: XRFrame | undefined): void {
const inicio: number = performance.now();
palco.ajustar();
const amostra: Amostra = relogio.avancar(instanteMs);
for (const passo of passos) {
passo(amostra, quadroXR);
}
palco.desenhar(cena);
// As contagens de desenho são zeradas pela própria biblioteca no início de
// cada renderização, então ler aqui é ler o quadro que acabou de sair — e
// não a soma do percurso inteiro.
const custoMs: number = performance.now() - inicio;
orcamento.registrar(
custoMs,
amostra.intervaloReal * 1000,
palco.renderer.info.render.calls,
palco.renderer.info.render.triangles,
);
}
return {
aoPasso(passo: PassoDoQuadro): void {
passos.push(passo);
},
iniciar(): void {
palco.renderer.setAnimationLoop(quadro);
},
parar(): void {
palco.renderer.setAnimationLoop(null);
relogio.reiniciar();
},
};
}
Adotá-lo desde já não custa nada e evita o retrabalho mais comum de sala: o ambiente inteiro escrito em torno do laço da janela, funcionando no desktop, e congelado no visor. A página responde normalmente, o que faz o diagnóstico apontar para todo lado menos para o laço.
A ordem dentro do quadro também não é livre. Ajusta-se o alvo de desenho, mede-se o tempo, avança-se a cena, desenha-se. Desenhar antes de avançar entrega ao olho o estado do quadro anterior — um atraso que ninguém percebe no desktop e que, no visor, soma à latência que o aparelho já tem.
1.5.3 O envelope, e de que aparelho vem o teto
Cada quadro é um envelope de onze milissegundos, e o que não couber nele não fica para depois: some.
De onde saiu esse número? De 90 imagens por segundo, que é a cadência do visor. E aqui há uma sutileza que o plano de aulas resolve em uma frase e que vale abrir.
O teto tem duas metades e elas vêm de aparelhos diferentes. O tempo disponível vem do aparelho de maior cadência, que é o visor: 11,1 ms. O trabalho que cabe nesse tempo vem da máquina mais fraca, que é o desktop de vídeo integrado do laboratório. Orçar pelo desktop sozinho daria 16,7 ms de folga aparente e estouro garantido no visor — que é justamente onde o estouro produz mal-estar físico, e não uma animação menos lisa.
src/bancada/core/orcamento.ts
// ---------------------------------------------------------------------------
// O orçamento por quadro.
//
// Ele entra agora, antes de a cena ter conteúdo pesado, e essa ordem é a decisão
// de projeto deste arquivo. Teto declarado depois que o ambiente já está montado
// não é orçamento: é laudo do que já foi gasto, e a essa altura cortar custa
// remodelar.
//
// O que se mede aqui são duas grandezas diferentes, e confundi-las produz
// diagnóstico errado. O CUSTO é o tempo que o nosso trabalho de quadro consome
// na CPU — é o que escrevemos e o que podemos cortar. O INTERVALO é o tempo real
// entre duas imagens entregues — é o que o corpo de quem usa percebe, e nele
// entram a GPU, o compositor do sistema e tudo o mais que dividimos o aparelho
// com. Custo baixo com intervalo alto é sintoma de gargalo fora do nosso código.
// ---------------------------------------------------------------------------
/** Teto do desktop do laboratório, em milissegundos: sessenta imagens por segundo. */
export const TETO_DESKTOP_MS: number = 16.7;
/** Teto do visor, em milissegundos: noventa imagens por segundo. */
export const TETO_VISOR_MS: number = 11.1;
export interface LeituraDoOrcamento {
readonly tetoMs: number;
readonly quadrosMedidos: number;
readonly custoMedioMs: number;
readonly intervaloMedioMs: number;
/** O pior intervalo da janela observada. É ele que produz o engasgo sentido. */
readonly piorIntervaloMs: number;
readonly quadrosAcimaDoTeto: number;
readonly chamadasDeDesenho: number;
readonly triangulos: number;
}
/** Quantos quadros a janela de observação guarda. */
const JANELA_PADRAO: number = 120;
export class Orcamento {
private readonly tetoMs: number;
private readonly custos: Float64Array;
private readonly intervalos: Float64Array;
private proximo: number = 0;
private preenchidos: number = 0;
private chamadasDeDesenho: number = 0;
private triangulos: number = 0;
constructor(tetoMs: number, janela: number = JANELA_PADRAO) {
this.tetoMs = tetoMs;
this.custos = new Float64Array(janela);
this.intervalos = new Float64Array(janela);
}
/**
* Registra o quadro recém-terminado. O buffer é circular e de tamanho fixo
* porque a alternativa — acumular tudo e tirar a média do percurso inteiro —
* esconde exatamente o que interessa: a média do ambiente carregado dilui o
* engasgo de um segundo atrás até ele desaparecer do número.
*/
registrar(custoMs: number, intervaloMs: number, chamadas: number, triangulos: number): void {
this.custos[this.proximo] = custoMs;
this.intervalos[this.proximo] = intervaloMs;
this.proximo = (this.proximo + 1) % this.custos.length;
if (this.preenchidos < this.custos.length) {
this.preenchidos += 1;
}
this.chamadasDeDesenho = chamadas;
this.triangulos = triangulos;
}
ler(): LeituraDoOrcamento {
if (this.preenchidos === 0) {
return {
tetoMs: this.tetoMs,
quadrosMedidos: 0,
custoMedioMs: 0,
intervaloMedioMs: 0,
piorIntervaloMs: 0,
quadrosAcimaDoTeto: 0,
chamadasDeDesenho: 0,
triangulos: 0,
};
}
let somaDeCustos: number = 0;
let somaDeIntervalos: number = 0;
let pior: number = 0;
let acima: number = 0;
for (let i: number = 0; i < this.preenchidos; i += 1) {
const custo: number = this.custos[i] ?? 0;
const intervalo: number = this.intervalos[i] ?? 0;
somaDeCustos += custo;
somaDeIntervalos += intervalo;
if (intervalo > pior) {
pior = intervalo;
}
if (intervalo > this.tetoMs) {
acima += 1;
}
}
return {
tetoMs: this.tetoMs,
quadrosMedidos: this.preenchidos,
custoMedioMs: somaDeCustos / this.preenchidos,
intervaloMedioMs: somaDeIntervalos / this.preenchidos,
piorIntervaloMs: pior,
quadrosAcimaDoTeto: acima,
chamadasDeDesenho: this.chamadasDeDesenho,
triangulos: this.triangulos,
};
}
}
/**
* As linhas que o painel exibe dentro da cena. Ficam aqui, e não no painel,
* porque quem sabe o que cada número significa é quem o mediu — e porque o
* mesmo texto vai precisar sair também no relatório em página comum.
*/
export function linhasDoOrcamento(leitura: LeituraDoOrcamento): string[] {
if (leitura.quadrosMedidos === 0) {
return ['Orçamento: ainda sem quadros medidos.'];
}
const proporcaoAcima: number = (leitura.quadrosAcimaDoTeto / leitura.quadrosMedidos) * 100;
return [
`Teto do quadro: ${leitura.tetoMs.toFixed(1)} ms`,
`Intervalo medio: ${leitura.intervaloMedioMs.toFixed(1)} ms · pior: ${leitura.piorIntervaloMs.toFixed(1)} ms`,
`Custo do nosso trabalho: ${leitura.custoMedioMs.toFixed(2)} ms`,
`Acima do teto: ${proporcaoAcima.toFixed(0)}% dos ${leitura.quadrosMedidos} quadros observados`,
`Chamadas de desenho: ${leitura.chamadasDeDesenho} · triangulos: ${leitura.triangulos}`,
];
}
O arquivo mede duas grandezas, e confundi-las produz diagnóstico errado. O custo é o tempo que o nosso trabalho consome; é o que escrevemos e o que podemos cortar. O intervalo é o tempo real entre duas imagens; é o que o corpo percebe, e nele entram a placa de vídeo, o compositor do sistema e tudo o mais com que dividimos o aparelho.
Custo baixo com intervalo alto é sintoma de gargalo fora do nosso código. Sem as duas colunas lado a lado, o grupo passa a tarde otimizando a metade que já estava barata.
A janela de observação é curta e circular de propósito. A média do percurso inteiro dilui o engasgo de um segundo atrás até ele sumir do número, e o engasgo isolado é exatamente o que o corpo registra.
1.5.4 O painel diegético, na sua primeira versão
No módulo anterior o relatório saía em página comum, e a razão declarada era que ainda não havia mundo onde pendurá-lo. Agora há.
src/bancada/ui/painel.ts
// ---------------------------------------------------------------------------
// A primeira pedra do painel diegético.
//
// No módulo anterior o relatório saía em página comum, e a razão declarada era
// que ainda não havia mundo onde pendurá-lo. Agora há. O painel passa a ser um
// objeto da cena, preso à bancada, lido de dentro do ambiente — que é a única
// forma de um número chegar a quem está com o visor no rosto.
//
// A implementação é a mais simples que sustenta o requisito: uma superfície
// plana com uma textura desenhada por nós, redesenhada algumas vezes por
// segundo. Duas razões para não redesenhar a cada quadro. A primeira é custo:
// enviar textura para a placa é das operações mais caras do quadro, e um painel
// que estoura o orçamento enquanto informa o orçamento seria uma piada de mau
// gosto. A segunda é legibilidade: número que muda sessenta vezes por segundo
// não se lê, borra.
//
// ---------------------------------------------------------------------------
// O que o módulo final acrescentou, e por quê.
//
// O painel nasceu no regime em janela, onde a câmera orbita a bancada e sempre
// olha para ela. Nos outros dois regimes isso deixa de valer, e três defeitos
// apareceram no primeiro ensaio com o visor:
//
// 1. O cartaz plano visto de lado é uma linha. Quem contorna a bancada perde o
// painel inteiro. A correção é girá-lo em torno do próprio eixo vertical para
// encarar quem lê — e SÓ em torno dele: inclinar o cartaz para acompanhar a
// cabeça de quem se abaixa faz um objeto preso à mesa parecer solto no ar, e
// a única coisa que o painel não pode perder é o pertencimento à bancada.
// 2. Texto de 24 pixels a três metros não se lê. O painel não pode crescer sem
// deixar de ser um cartaz de bancada, e não pode encolher a distância sem
// mentir sobre a escala — então ele MEDE a própria legibilidade e diz quando
// ela caiu, em vez de fingir que está sendo lido.
// 3. Linha comprida saía pela borda direita sem aviso. Agora ela quebra, e o que
// não couber é anunciado como corte em vez de sumir.
//
// A quarta correção é o `chamar()`: no celular, em sessão aumentada, a bancada
// pousa onde a mesa está e o painel pode ficar de costas, atrás dela. Trazê-lo
// para a borda mais próxima de quem lê é gesto explícito, e não automático —
// painel que se move sozinho é a coisa que mais rápido deixa de parecer objeto.
// ---------------------------------------------------------------------------
import {
CanvasTexture,
LinearFilter,
Mesh,
MeshBasicMaterial,
Object3D,
PlaneGeometry,
Vector3,
} from 'three';
/** Largura do painel em metros. Um cartaz de bancada, não um outdoor. */
const LARGURA_M: number = 0.62;
const ALTURA_M: number = 0.34;
/** Resolução da textura, em pixels. Múltiplo da proporção física acima. */
const LARGURA_PX: number = 620;
const ALTURA_PX: number = 340;
/** Intervalo mínimo entre redesenhos, em segundos. */
const INTERVALO_DE_REDESENHO: number = 0.25;
/** Corpo do texto das linhas, em pixels da textura. */
const CORPO_PX: number = 24;
/** Altura de linha, em pixels da textura. */
const ENTRELINHA_PX: number = 34;
/** Margem lateral, em pixels da textura. Vale dos dois lados. */
const MARGEM_PX: number = 24;
/**
* Abaixo de quantos minutos de arco o texto deixa de ser lido.
*
* É DECISÃO DE PROJETO, não norma citada. O valor saiu do único ensaio que se
* pôde fazer sem depender do rodízio dos três visores: no desktop, afastando o
* ponto de vista até o texto do painel parar de ser lido por três pessoas
* diferentes com a mesma fonte e o mesmo corpo. Deu por volta de vinte minutos
* de arco, e vinte é o que está aqui.
*
* O ensaio dentro do visor CONTINUA A FAZER. Ele pode mover este número, porque
* a densidade de pixels do visor e a distorção da lente não entram na conta do
* ângulo — e é por isso que o painel informa o valor medido ao lado do limiar,
* em vez de devolver apenas "legível" ou "ilegível".
*/
export const LIMIAR_DE_LEITURA_MINUTOS: number = 20;
/** O que o painel responde sobre a própria leitura, no instante da consulta. */
export interface Legibilidade {
readonly distanciaM: number;
readonly alturaAparenteMinutos: number;
readonly legivel: boolean;
/** Quantas linhas o último desenho não conseguiu mostrar. */
readonly linhasCortadas: number;
}
export interface Painel {
readonly no: Mesh;
/** Entrega o texto do painel. O redesenho só acontece quando vale a pena. */
atualizar(linhas: readonly string[], decorrido: number): void;
/**
* Gira o painel em torno do eixo vertical para encarar quem lê. Chamada a
* cada quadro; a conta é de três linhas e não entra no orçamento de forma
* mensurável.
*/
encarar(observador: Object3D): void;
/** Mede a própria leitura a partir de onde o observador está agora. */
legibilidade(observador: Object3D): Legibilidade;
/** Traz o painel para a borda da bancada voltada a quem lê. Gesto explícito. */
chamar(observador: Object3D): void;
/** Devolve o painel ao lugar de origem sobre o tampo. */
devolver(): void;
chamado(): boolean;
}
export function montarPainel(titulo: string): Painel {
const tela: HTMLCanvasElement = document.createElement('canvas');
tela.width = LARGURA_PX;
tela.height = ALTURA_PX;
const contexto: CanvasRenderingContext2D | null = tela.getContext('2d');
if (contexto === null) {
throw new Error('Este navegador não fornece contexto de desenho para o painel.');
}
// A cópia em constante não é adorno: a checagem acima estreita o tipo aqui, e
// esse estreitamento não sobrevive à entrada na função de desenho.
const pincel: CanvasRenderingContext2D = contexto;
const textura: CanvasTexture = new CanvasTexture(tela);
// Sem os níveis intermediários de detalhe, texto lido de longe cintila; com o
// filtro linear ele apenas borra, que é o defeito menos ruim dos dois.
textura.minFilter = LinearFilter;
textura.generateMipmaps = false;
const no: Mesh = new Mesh(
new PlaneGeometry(LARGURA_M, ALTURA_M),
new MeshBasicMaterial({ map: textura }),
);
no.name = 'painel';
// Material sem iluminação de propósito: painel que escurece quando a luz muda
// de lugar deixa de ser instrumento de leitura.
no.position.y = ALTURA_M / 2;
const posicaoDeOrigem: Vector3 = no.position.clone();
let ultimoDesenho: number = Number.NEGATIVE_INFINITY;
let ultimoTexto: string = '';
let cortadas: number = 0;
let chamado: boolean = false;
const doPainel: Vector3 = new Vector3();
const doObservador: Vector3 = new Vector3();
/**
* Quebra a linha que não cabe na largura útil.
*
* A medida é feita com a régua do próprio contexto de desenho, e não por
* contagem de caracteres: a fonte é proporcional, e "iiii" e "MMMM" têm o
* mesmo número de letras e larguras que diferem em três vezes. Contagem de
* caracteres é o atalho que faz o painel cortar cedo demais em umas linhas e
* transbordar em outras.
*/
function quebrar(linha: string, larguraUtil: number): string[] {
if (pincel.measureText(linha).width <= larguraUtil) {
return [linha];
}
const partes: string[] = [];
let atual: string = '';
for (const palavra of linha.split(' ')) {
const tentativa: string = atual === '' ? palavra : `${atual} ${palavra}`;
if (pincel.measureText(tentativa).width <= larguraUtil) {
atual = tentativa;
continue;
}
if (atual !== '') {
partes.push(atual);
}
atual = palavra;
}
if (atual !== '') {
partes.push(atual);
}
return partes;
}
function desenhar(linhas: readonly string[]): void {
pincel.fillStyle = '#11131a';
pincel.fillRect(0, 0, LARGURA_PX, ALTURA_PX);
pincel.fillStyle = '#f2f4fa';
pincel.font = `bold ${CORPO_PX + 6}px system-ui, sans-serif`;
pincel.fillText(titulo, MARGEM_PX, 52);
pincel.font = `${CORPO_PX}px system-ui, sans-serif`;
const larguraUtil: number = LARGURA_PX - MARGEM_PX * 2;
const quebradas: string[] = [];
for (const linha of linhas) {
quebradas.push(...quebrar(linha, larguraUtil));
}
let linhaY: number = 100;
let escritas: number = 0;
for (const linha of quebradas) {
if (linhaY > ALTURA_PX - MARGEM_PX) {
break;
}
pincel.fillText(linha, MARGEM_PX, linhaY);
linhaY += ENTRELINHA_PX;
escritas += 1;
}
cortadas = quebradas.length - escritas;
textura.needsUpdate = true;
}
desenhar([]);
function atualizar(linhas: readonly string[], decorrido: number): void {
const texto: string = linhas.join('\n');
if (decorrido - ultimoDesenho < INTERVALO_DE_REDESENHO || texto === ultimoTexto) {
return;
}
ultimoDesenho = decorrido;
ultimoTexto = texto;
desenhar(linhas);
}
function distanciaAte(observador: Object3D): number {
no.getWorldPosition(doPainel);
observador.getWorldPosition(doObservador);
return doPainel.distanceTo(doObservador);
}
function encarar(observador: Object3D): void {
no.getWorldPosition(doPainel);
observador.getWorldPosition(doObservador);
// Só o componente horizontal entra na conta. Zerar a altura ANTES de mirar é
// o que mantém o cartaz de pé: mirando o ponto inteiro, ele se inclinaria
// para acompanhar quem se abaixa, e um objeto preso à mesa não faz isso.
doObservador.y = doPainel.y;
no.lookAt(doObservador);
}
function legibilidade(observador: Object3D): Legibilidade {
const distancia: number = distanciaAte(observador);
// Altura física de uma letra: a proporção que ela ocupa na textura, aplicada
// à altura real da superfície.
const alturaDaLetraM: number = (CORPO_PX / ALTURA_PX) * ALTURA_M;
// Ângulo subtendido, em minutos de arco. A distância mínima evita a divisão
// por zero de quem encosta o rosto no painel dentro do visor — que acontece.
const radianos: number = 2 * Math.atan(alturaDaLetraM / (2 * Math.max(distancia, 0.05)));
const minutos: number = (radianos * 180 * 60) / Math.PI;
return {
distanciaM: distancia,
alturaAparenteMinutos: minutos,
legivel: minutos >= LIMIAR_DE_LEITURA_MINUTOS,
linhasCortadas: cortadas,
};
}
function chamar(observador: Object3D): void {
// O painel anda no espaço do próprio pai — o suporte preso ao tampo —, e é
// por isso que a bancada continua sendo o dono dele depois de chamado. Um
// painel que se soltasse do suporte para ir até quem lê deixaria de ser
// diegético no instante em que fosse mais útil.
const pai: Object3D | null = no.parent;
if (pai === null) {
return;
}
observador.getWorldPosition(doObservador);
pai.worldToLocal(doObservador);
doObservador.y = posicaoDeOrigem.y;
// Meio metro à frente de quem lê, na direção em que ele está, e não em cima
// dele: colado no rosto o cartaz fica perto demais para ser lido e atrapalha
// a mão que trabalha.
const direcao: Vector3 = doObservador.clone().setY(0);
if (direcao.lengthSq() < 1e-6) {
return;
}
direcao.normalize().multiplyScalar(0.45);
no.position.set(direcao.x, posicaoDeOrigem.y, direcao.z);
chamado = true;
}
function devolver(): void {
no.position.copy(posicaoDeOrigem);
chamado = false;
}
return {
no,
atualizar,
encarar,
legibilidade,
chamar,
devolver,
chamado(): boolean {
return chamado;
},
};
}
/** A medida da leitura em texto, para a folha e para o diário. */
export function linhasDaLegibilidade(medida: Legibilidade): string[] {
const linhas: string[] = [
`Distância de quem lê até o painel: ${medida.distanciaM.toFixed(2)} m.`,
`Altura aparente da letra: ${medida.alturaAparenteMinutos.toFixed(1)} minutos de arco ` +
`(limiar adotado: ${LIMIAR_DE_LEITURA_MINUTOS}).`,
medida.legivel
? 'Acima do limiar: o texto está sendo lido desta distância.'
: 'ABAIXO do limiar: daqui o painel é um borrão. Aproxime-se, ou chame o painel para a ' +
'borda da bancada — ele não cresce, porque crescer mentiria sobre a escala do mundo.',
];
if (medida.linhasCortadas > 0) {
linhas.push(
`${medida.linhasCortadas} linha(s) não couberam no último desenho e foram anunciadas como ` +
'corte. O texto inteiro está na folha de diagnóstico.',
);
}
linhas.push(
'',
'O limiar é decisão de projeto aferida no desktop, e não norma citada. O ensaio dentro do ' +
'visor continua a fazer: densidade de pixels e distorção da lente não entram na conta do ' +
'ângulo, e podem mover o número.',
);
return linhas;
}
O painel é um objeto da cena, preso ao tampo, virado para quem trabalha. Preso à câmera ele acompanharia a cabeça e deixaria de ser objeto do mundo — e texto grudado no rosto, dentro de um visor, é receita conhecida de desconforto.
Ele redesenha algumas vezes por segundo, não a cada quadro. Enviar textura para a placa é das operações mais caras do quadro, e um painel que estoura o orçamento enquanto informa o orçamento seria uma piada de mau gosto. O segundo motivo é mais simples: número que muda 60 vezes por segundo não se lê, borra.
1.5.5 A composição, e por que ela mora em um arquivo só
src/bancada/app/oficina.ts
// ---------------------------------------------------------------------------
// Composição do ambiente: o ponto em que as peças deste módulo viram oficina.
//
// Este arquivo não contém conceito novo. Ele existe para que cada um dos outros
// contenha um só: o relógio não sabe o que é uma bancada, a cena não sabe medir
// tempo, e o painel não sabe de onde vêm os números que exibe. Quem os apresenta
// uns aos outros é este arquivo, e é o único que muda quando a composição muda.
// ---------------------------------------------------------------------------
import { Box3, BufferGeometry, InstancedMesh, Mesh, Object3D, Vector3 } from 'three';
import { montarCena, type CenaDaBancada } from '../core/cena';
import { montarLaco, type Laco } from '../core/laco';
import { montarPalco, type Palco } from '../core/palco';
import { Relogio } from '../core/relogio';
import { Orcamento, TETO_VISOR_MS } from '../core/orcamento';
import { descreverArvore, posicaoDeMundo, reparentar } from '../core/hierarquia';
import { compararOrdem, emMetros, type ComparacaoDeOrdem } from '../core/transformacao';
import { linhasDaLegibilidade, montarPainel, type Legibilidade, type Painel } from '../ui/painel';
import { compor, frasePasso } from '../ui/informe';
import { Composicao } from './composicao';
import {
abrirDescendo,
linhasDoPercurso,
type Abertura,
type Percurso,
} from './roteamento';
import {
RegistroDeAparelhos,
type AparelhoTestado,
} from '../relatorio/decisoes';
import { RegistroDeUso, type Observacao } from '../relatorio/uso';
import { compararFolgas, vestirCena, type ComparacaoDeFolga, type Conteudo } from '../content/pecas';
import { linhasDoCatalogo } from '../content/materiais';
import { extrudarPerfil, perfilRegular } from '../content/malha';
import { importarAtivo, linhasDoAtivo, type AtivoImportado } from '../content/ativos';
import {
ESPACAMENTO_DOS_PARAFUSOS,
custoDaRepeticao,
disporNaBorda,
linhasDaRepeticao,
montarLoteInstanciado,
montarPrateleira,
type CustoDaRepeticao,
type Prateleira,
} from '../content/repeticao';
import { censoDaCena, identificarPlaca, linhasDaMedicao, type Censo } from '../content/medicao';
import { montarOrbita, type Orbita } from '../core/orbita';
import {
aferirLimites,
linhasDaJanela,
medirEntrega,
type EntregaDaJanela,
} from '../modes/janela';
import { BANCADA } from '../dominio/dominio';
import { montarEntrada, linhasDoPonteiro, type Entrada, type Ponteiro } from '../input/ponteiro';
import { montarFonteDeCursor } from '../input/cursor';
import { montarFonteDeControles } from '../input/controle';
import { montarFonteDeToque } from '../input/toque';
import { linhasDaFronteira } from '../input/fronteira';
import {
montarCicloDeSessao,
type CicloDeSessao,
type ModoDeSessao,
type SessaoAberta,
} from '../modes/sessao';
import { contagemDaFronteira, linhasDaPlataforma } from '../modes/plataforma';
import { montarRegimeAumentado, type RegimeAumentado } from '../modes/aumentado';
import type { QuadroDaSessao } from '../anchoring/espaco';
import {
montarConsultaDeSuperficie,
type AcertoDeSuperficie,
type ConsultaDeSuperficie,
} from '../anchoring/superficie';
import { montarAncoragem, type Ancoragem } from '../anchoring/ancora';
import { montarRastreamento, type Rastreamento } from '../anchoring/rastreamento';
import {
haCameraDisponivel,
montarCameraDeFundo,
type CameraDeFundo,
} from '../anchoring/camera';
import { montarRegimeMarcado, type RegimeMarcado } from '../modes/marcado';
import {
ORDEM_DE_PREFERENCIA,
escolherRegime,
linhasDaOrdem,
mensagemHonesta,
type RegimeExecutavel,
type CapacidadesDoAparelho,
type Escolha,
} from './degradacao';
import { sondarSemSessao, type SondaSemSessao } from '../devices/sonda';
import { RECUO_DO_POSTO_M } from '../modes/sessao';
import { EscalaCorporal } from '../modes/corpo';
import {
auditarAlcance,
linhasDoAlcance,
trazerParaOAlcance,
type Aproximacao,
type Auditoria,
} from '../modes/alcance';
import {
ContadorDeEngasgos,
Demonstracao,
linhasDosEngasgos,
type Caso,
type CasoDesconfortavel,
} from '../modes/conforto';
import { montarManche, type Manche } from '../locomotion/manche';
import { montarArco, type Arco } from '../locomotion/arco';
import { montarVinheta, type Vinheta } from '../locomotion/vinheta';
import {
transporteDaOrbita,
transporteDaSessao,
type Transporte,
} from '../locomotion/transporte';
import { montarTeleporte, type Teleporte } from '../locomotion/teleporte';
import { montarMovimento, type Movimento } from '../locomotion/giro';
import { montarLimiteDaArea, type LimiteDaArea } from '../locomotion/limite';
import {
montarRegistroDaComparacao,
type Ensaio,
type RegistroDaComparacao,
} from '../locomotion/comparacao';
import { montarDestaque, type Destaque } from '../interaction/destaque';
import { montarSelecao, type MudancaDeSelecao, type Selecao } from '../interaction/selecao';
import { montarManipulacao, type EixoDeGiro, type Manipulacao } from '../interaction/agarre';
import {
alternarFolgasVisiveis,
linhasDaTolerancia,
montarSockets,
type Socket,
} from '../assembly/sockets';
import { montarEncaixes, type Encaixes } from '../assembly/encaixe';
import { montarMontagem, type Montagem } from '../assembly/montagem';
import type { SocketId } from '../dominio/dominio';
/** Voltas por segundo do eixo. Devagar: o que se demonstra é o parentesco. */
const VOLTAS_POR_SEGUNDO: number = 0.12;
/** Onde o ativo importado é aparafusado: canto esquerdo do tampo, fora da área de montagem. */
const POSICAO_DA_MORSA: Vector3 = new Vector3(-0.52, 0.025, 0.0);
/** Peças de reposição na prateleira do fundo. A mesma malha da engrenagem da bancada. */
const PECAS_DE_REPOSICAO: number = 24;
/** Proporção de vértices que a ferramenta remove no nível distante. */
const REMOCAO_NO_NIVEL_DISTANTE: number = 0.5;
/** Uma tecla de giro do regime em janela: em torno de que eixo, e para que lado. */
interface ComandoDeGiro {
readonly eixo: EixoDeGiro;
readonly sinal: number;
}
export interface Oficina {
/** A árvore da cena em texto, para conferência a olho. */
estrutura(): string[];
/** O inventário do conteúdo: origem, triângulos e vértices de cada peça. */
inventario(): string[];
/** As superfícies do catálogo, com o que cada uma cobre. */
materiais(): string[];
/** Os volumes de contato, com a folga adotada e a razão dela. */
volumes(): string[];
/** Liga e desliga o contorno dos volumes. Devolve o estado em que ficou. */
alternarVolumes(): boolean;
/** A comparação medida entre a folga adotada e uma folga generosa. */
folgas(distanciaDeTeste: number, folgaGenerosa: number): ComparacaoDeFolga;
/** Prende a engrenagem grande ao eixo. Devolve o desvio de mundo, em metros. */
prender(): number;
/** Devolve a engrenagem ao tampo. Mesmo desvio, mesma operação, sentido oposto. */
soltar(): number;
presa(): boolean;
/** Onde a engrenagem está no mundo, em texto. É a conferência da reparentagem. */
posicaoDaEngrenagem(): string;
/** O que a instanciação da borda parafusada comprou, em chamadas de desenho. */
repeticao(): string[];
/** Os dois níveis da prateleira de reposição, com o que a simplificação cortou. */
niveisDeDetalhe(): string[];
/** Censo estático, leitura do quadro e a máquina em que o número foi obtido. */
medicao(): string[];
/**
* Traz o ativo externo, ajusta-o à convenção da cena e o instala. Devolve o
* laudo. Assíncrona porque o arquivo vem pela rede: a cena já está de pé e em
* movimento quando ele chega, e é assim que o ambiente se comporta de verdade.
*/
trazerAtivoExterno(): Promise<string[]>;
/** Aproxima e afasta a câmera da prateleira, para a troca de nível ficar visível. */
alternarAproximacao(): boolean;
/** Enquadra a cena inteira: todos os objetos do domínio passam a caber na tela. */
enquadrarTudo(): void;
/** O estado da órbita em texto: azimute, elevação, distância e travas. */
orbita(): string[];
/** O que o regime em janela entrega e o que ele não entrega, aferido em execução. */
regimeEmJanela(): string[];
/** As fontes de apontamento registradas e o que cada campo do ponteiro carrega. */
apontamento(): string[];
/** O que vem pronto de biblioteca e o que se escreve no projeto, com a razão de cada um. */
fronteira(): string[];
/** Abre a sessão imersiva pedida e devolve o que a negociação deixou valendo. */
entrarEmSessao(modo: ModoDeSessao): Promise<SessaoAberta>;
/** Encerra a sessão e devolve o controle do laço ao regime em janela. */
sairDaSessao(): Promise<void>;
emSessao(): boolean;
/** O espaço obtido, os recusados e o estado de cada recurso opcional pedido. */
sessao(): string[];
/** O assentamento do mundo, a conferência de escala e os dois percursos. */
escalaCorporal(): string[];
/** A distância de cada peça ao ombro de quem trabalha, e o que ficou fora. */
alcance(): string[];
/** Traz para dentro do braço o que estiver longe, e relata cada movimento. */
aproximarPecas(): string[];
/** Os engasgos contados na sessão inteira e as três provocações declaradas. */
conforto(): string[];
/** Provoca deliberadamente um caso desconfortável. Corrige-se sozinho no prazo. */
provocar(caso: CasoDesconfortavel): string;
/** Interrompe a provocação em curso antes do prazo. */
corrigirDesconforto(): void;
/** O estado do salto, do giro, da máscara e do transporte deste regime. */
locomocao(): string[];
/** A borda da área física declarada pelo aparelho, e a cerca. */
areaFisica(): string[];
/** Troca o salto pelo deslize contínuo. Devolve verdadeiro com o deslize ligado. */
alternarDeslize(): boolean;
/** Os critérios da comparação, a decisão do projeto e os ensaios já feitos. */
comparacao(): string[];
/** Marca o início de um ensaio de conforto: a partir daqui, tudo é medido. */
iniciarEnsaio(): void;
/**
* Fecha o ensaio com o relato de quem experimentou.
*
* O chamador fornece só o que uma pessoa responde. Duração, metros percorridos
* e engasgos do período são medidos aqui, e não digitados: número relatado de
* memória é o primeiro a divergir do que aconteceu.
*/
registrarEnsaio(observador: string, relato: string): Ensaio;
/** A composição sobre o mundo, a consulta de superfície, o pouso e o rastreamento. */
ancoragem(): string[];
/** Desfaz o pouso: a bancada volta ao lugar de origem e espera outra escolha. */
repousar(): void;
/** Liga o registro por marcador impresso: pede a câmera e passa a procurar o papel. */
entrarPorMarcador(): Promise<void>;
/** Desliga o registro por marcador, apaga a câmera e devolve a janela. */
sairDoMarcador(): void;
registrandoPorMarcador(): boolean;
/** O estado do registro por marcador, a medida do tremor e a conferência do papel. */
marcador(): string[];
/** Consulta o aparelho e decide qual regime abrir. Uma vez por carregamento. */
decidirRegime(): Promise<Escolha>;
/** A ordem de preferência declarada e a mensagem honesta para este aparelho. */
degradacao(): string[];
/** Verdadeiro com uma sessão aumentada aberta e a cena composta sobre o ambiente. */
emRealidadeAumentada(): boolean;
/** O que a plataforma resolve, o que se negocia com ela e o que este projeto escreveu. */
plataforma(): string[];
/** Avisa quando a sessão abre e quando ela fecha — inclusive por decisão do aparelho. */
aoMudarSessao(abriu: (aberta: SessaoAberta) => void, fechou: () => void): void;
/** Mira, escolha e o caminho que a subida percorreu até a peça. */
interacao(): string[];
/** Avisa quando a mira ou a escolha mudam. É o gancho do diário da página. */
aoMudarSelecao(ouvinte: (mudanca: MudancaDeSelecao) => void): void;
/** O que está na mão, a que distância, e o que a fonte ativa entrega de pose. */
manipulacao(): string[];
/** As duas folgas adotadas e o que se experimentou antes de fixá-las. */
tolerancia(): string[];
/** O estado de cada peça na tarefa, com o que cada uma ainda espera. */
montagem(): string[];
/** Liga e desliga os contornos que desenham a folga de cada encaixe. */
alternarFolgas(): boolean;
/** Avisa a cada resposta dada a quem soltou uma peça: encaixe, recusa ou distância. */
aoResponder(ouvinte: (parecer: string) => void): void;
/** O inventário das camadas do percurso, com o caminho morto, se houver. */
composicao(): string[];
/**
* Abre o melhor regime que este aparelho de fato aceitar, descendo a ordem
* declarada. Exige gesto de quem usa: o navegador recusa sessão e câmera
* pedidas sem toque, e o carregamento da página não é toque de ninguém.
*/
abrirMelhorRegime(): Promise<Percurso>;
/** O percurso da última abertura, tentativa por tentativa, com os motivos. */
percurso(): string[];
/** Decisões, números com a máquina de origem, tolerâncias e aparelhos abertos. */
registro(): string[];
/** Acrescenta um aparelho à lista, que nasce vazia e só vale preenchida. */
registrarAparelho(aparelho: AparelhoTestado): void;
/** O registro em texto, pronto para ser colado no repositório do projeto. */
exportarRegistro(): string;
/** A análise da tarefa por demanda e as observações de uso desta sessão. */
uso(): string[];
/** Registra o que uma pessoa fez diante do ambiente. Só isto é avaliação de uso. */
registrarObservacao(observacao: Observacao): void;
/** O regime em uso agora, decidido pelo estado e não pela intenção. */
regimeEmUso(): RegimeExecutavel;
/** A medida da leitura do painel a partir de onde a câmera está. */
legibilidadeDoPainel(): string[];
/** Traz o painel para a borda da bancada voltada a quem lê, ou o devolve. */
alternarChamadaDoPainel(): boolean;
encerrar(): void;
}
export function iniciarOficina(canvas: HTMLCanvasElement): Oficina {
const palco: Palco = montarPalco(canvas);
const cena: CenaDaBancada = montarCena();
// A árvore sobe primeiro e é vestida depois. A ordem preserva o assunto de
// cada módulo: a estrutura é do anterior, a forma e a superfície são deste.
const conteudo: Conteudo = vestirCena(cena);
// O inventário da composição. Cada camada se registra na linha em que é
// ligada, e é essa proximidade que dá valor à auditoria: lista mantida no fim
// do arquivo diverge do que o código faz, e a auditoria passa a conferir a
// lista contra a lista.
const composicao: Composicao = new Composicao();
composicao.registrar('dominio');
composicao.registrar('regimes');
composicao.registrar('sonda');
composicao.registrar('cena');
composicao.registrar('conteudo');
// O painel deixa de ser o mostrador do orçamento e passa a ser o instrumento
// do ambiente. O título muda com ele: o custo do quadro continua lá, no fim
// da ordem de prioridade, e o que abre o cartaz agora é o passo da tarefa.
const painel: Painel = montarPainel('Bancada');
cena.suporteDoPainel.add(painel.no);
composicao.registrar('painel');
const registroDeAparelhos: RegistroDeAparelhos = new RegistroDeAparelhos();
const registroDeUso: RegistroDeUso = new RegistroDeUso();
const engrenagem: Object3D | undefined = cena.pecas.get('engrenagem-grande');
if (engrenagem === undefined) {
throw new Error('A cena não trouxe a engrenagem grande, e a demonstração depende dela.');
}
const paiOriginal: Object3D | null = engrenagem.parent;
if (paiOriginal === null) {
throw new Error('A engrenagem nasceu sem pai, o que a montagem da cena deveria impedir.');
}
const tampo: Object3D | undefined = cena.sala.getObjectByName('tampo');
if (tampo === undefined) {
throw new Error('A cena não trouxe o tampo, e o conteúdo deste módulo se prende a ele.');
}
// A borda parafusada. O parafuso é a cabeça sextavada e nada mais: o corpo
// dele está dentro da madeira, e modelar o que ninguém vê é orçamento gasto em
// nada. São 24 triângulos por cabeça, e a conta que importa não é essa.
const posicoesDosParafusos: Vector3[] = disporNaBorda(
1.4,
0.8,
ESPACAMENTO_DOS_PARAFUSOS,
0.02,
0.026,
);
const geometriaDoParafuso: BufferGeometry = extrudarPerfil(perfilRegular(6, 0.004), 0.003, 'parafuso');
const parafusos: InstancedMesh = montarLoteInstanciado(
geometriaDoParafuso,
conteudo.catalogo.acoEscovado.material,
posicoesDosParafusos,
'parafusos-da-borda',
);
tampo.add(parafusos);
const custoDosParafusos: CustoDaRepeticao = custoDaRepeticao(
geometriaDoParafuso,
posicoesDosParafusos.length,
);
// A prateleira do fundo, com as peças de reposição. É a MESMA malha da
// engrenagem que está sobre a bancada, e essa identidade é o argumento: o que
// muda de uma para a outra não é o objeto, é a distância até o olho.
const engrenagemComoMalha: Mesh = engrenagem as Mesh;
const prateleira: Prateleira = montarPrateleira(
engrenagemComoMalha.geometry,
conteudo.catalogo.latao.material,
PECAS_DE_REPOSICAO,
REMOCAO_NO_NIVEL_DISTANTE,
);
prateleira.no.position.set(0, 0.4, -2.8);
cena.sala.add(prateleira.no);
// A órbita assume a câmera. Até aqui ela era fixa, e a cena só se deixava ver
// do ângulo em que nasceu; a partir daqui o regime em janela está completo, e é
// ele o piso de todas as demonstrações do percurso.
const orbita: Orbita = montarOrbita(palco.camera, canvas);
orbita.olharPara(new Vector3(0, 0.95, 0), 1.8);
composicao.registrar('orbita');
// A camada de interação. O cursor é a primeira fonte a alimentar a abstração, e
// a seleção que a consome não sabe que é um cursor: quando o controle rastreado
// chegar, ele se registra ao lado desta linha e nada abaixo dela muda.
const entrada: Entrada = montarEntrada();
entrada.registrar(montarFonteDeCursor(palco.camera, canvas));
// O segundo plugue entra na tomada aqui, e esta é a única linha do projeto que
// ele acrescenta fora do próprio arquivo. Ele fica em silêncio enquanto o
// regime for o da janela, e passa a falar quando a sessão abrir.
entrada.registrar(montarFonteDeControles(palco.renderer, cena.sala));
// O terceiro e último plugue. Ele fecha a lista prevista no módulo da
// abstração, e a linha é a mesma das outras duas: uma chamada de registro, e
// nada abaixo dela sabe que existe um dedo.
entrada.registrar(montarFonteDeToque(palco.renderer, cena.sala));
composicao.registrar('apontamento');
const destaque: Destaque = montarDestaque();
const selecao: Selecao = montarSelecao(cena.sala, cena.pecas, destaque);
composicao.registrar('selecao');
// A camada de montagem. Os encaixes nascem sob o suporte, a interpolação leva a
// peça até eles, e a máquina de estados decide quem pode entrar e quando. A
// manipulação é a única que enxerga as três coisas ao mesmo tempo.
const sockets: ReadonlyMap<SocketId, Socket> = montarSockets(cena.suporte);
const encaixes: Encaixes = montarEncaixes();
const montagem: Montagem = montarMontagem(sockets, encaixes);
const manipulacao: Manipulacao = montarManipulacao(cena.sala, cena.pecas, selecao, montagem);
composicao.registrar('manipulacao');
composicao.registrar('montagem');
// O teclado só aparece aqui, e é de propósito. Girar por tecla é remendo do
// regime em janela, onde não há pose de mão; a composição é a única camada com
// licença para conhecer um aparelho pelo nome, e nada abaixo dela sabe que
// existe um teclado. No visor, esta linha simplesmente não faz falta.
const GIRO_POR_TECLA_GRAUS: number = 15;
const GIROS: ReadonlyMap<string, ComandoDeGiro> = new Map<string, ComandoDeGiro>([
['q', { eixo: 'vertical', sinal: 1 }],
['e', { eixo: 'vertical', sinal: -1 }],
['r', { eixo: 'lateral', sinal: 1 }],
['f', { eixo: 'lateral', sinal: -1 }],
]);
function aoTeclar(evento: KeyboardEvent): void {
const giro: ComandoDeGiro | undefined = GIROS.get(evento.key.toLowerCase());
if (giro === undefined) {
return;
}
if (manipulacao.girar(giro.eixo, giro.sinal * GIRO_POR_TECLA_GRAUS)) {
evento.preventDefault();
}
}
window.addEventListener('keydown', aoTeclar);
// As setas fazem, no regime em janela, o papel do manche do controle. O
// teclado continua conhecido só aqui: a camada de locomoção recebe um par de
// números e não tem como perguntar de onde ele veio. É a mesma licença que o
// giro da peça já usava, e pela mesma razão.
const SETAS: ReadonlyMap<string, readonly [number, number]> = new Map<
string,
readonly [number, number]
>([
['arrowup', [0, 1]],
['arrowdown', [0, -1]],
['arrowleft', [-1, 0]],
['arrowright', [1, 0]],
]);
const setasApertadas: Set<string> = new Set<string>();
function recalcularManche(): void {
let x: number = 0;
let y: number = 0;
for (const tecla of setasApertadas) {
const par: readonly [number, number] | undefined = SETAS.get(tecla);
if (par === undefined) {
continue;
}
x += par[0];
y += par[1];
}
manche.injetar(Math.sign(x), Math.sign(y));
}
function aoApertarSeta(evento: KeyboardEvent): void {
const tecla: string = evento.key.toLowerCase();
if (!SETAS.has(tecla)) {
return;
}
// Sem isto, a seta rola a página por baixo do ambiente enquanto a mira está
// aberta, e o defeito parece do arco.
evento.preventDefault();
setasApertadas.add(tecla);
recalcularManche();
}
function aoSoltarSeta(evento: KeyboardEvent): void {
const tecla: string = evento.key.toLowerCase();
if (!setasApertadas.delete(tecla)) {
return;
}
recalcularManche();
}
window.addEventListener('keydown', aoApertarSeta);
window.addEventListener('keyup', aoSoltarSeta);
// O ciclo de sessão. Ele não conhece a cena, não conhece peça alguma e não
// decide nada sobre a montagem: pede a sessão, negocia o que dá, e avisa. Quem
// liga o aviso ao resto é esta composição, e são duas linhas.
const ciclo: CicloDeSessao = montarCicloDeSessao(palco.renderer);
composicao.registrar('sessao');
// A escala corporal e a demonstração de desconforto moram aqui, na composição,
// e não dentro de camada alguma. É o mesmo lugar em que o módulo anterior pôs a
// troca de regime, e pela mesma razão: são as únicas duas coisas que precisam
// conhecer ao mesmo tempo o mundo, o ciclo de sessão e o laço.
const corpo: EscalaCorporal = new EscalaCorporal(cena.sala);
const demonstracao: Demonstracao = new Demonstracao(cena.sala);
composicao.registrar('corpo');
composicao.registrar('conforto');
// A camada de locomoção. Ela entra inteira aqui, e a lista de arquivos
// alterados no módulo é o que prova a economia: nenhuma linha de seleção, de
// agarre ou de montagem mudou para que o salto existisse.
const manche: Manche = montarManche(palco.renderer);
const arco: Arco = montarArco(cena.sala);
const vinheta: Vinheta = montarVinheta(cena.sala);
const limite: LimiteDaArea = montarLimiteDaArea(palco.renderer, cena.sala);
const registroDaComparacao: RegistroDaComparacao = montarRegistroDaComparacao();
const naSessao: Transporte = transporteDaSessao(palco.renderer, cena.sala, palco.camera);
const naJanela: Transporte = transporteDaOrbita(orbita, cena.sala);
// O roteamento entre os dois transportes mora na composição, e não dentro do
// salto, pela mesma razão que a troca de fonte de apontamento mora aqui: quem
// sabe em que regime o ambiente está é esta camada, e é a única que pode saber
// sem que o conhecimento vaze para baixo.
const transporte: Transporte = {
nome: 'roteado pelo regime em uso',
saltarPara: (destinoLocal): boolean =>
ciclo.ativa() ? naSessao.saltarPara(destinoLocal) : naJanela.saltarPara(destinoLocal),
deslizar: (deltaMundo): boolean =>
ciclo.ativa() ? naSessao.deslizar(deltaMundo) : naJanela.deslizar(deltaMundo),
girar: (graus): boolean => (ciclo.ativa() ? naSessao.girar(graus) : naJanela.girar(graus)),
reiniciar: (): void => {
naSessao.reiniciar();
naJanela.reiniciar();
},
linhas: (): string[] => [...naSessao.linhas(), '', ...naJanela.linhas()],
};
const teleporte: Teleporte = montarTeleporte(manche, arco, transporte, vinheta);
const movimento: Movimento = montarMovimento(manche, transporte);
composicao.registrar('locomocao');
// A camada de ancoragem. Ela entra inteira aqui, como a locomoção entrou no
// módulo anterior, e a lista de arquivos alterados volta a ser o recibo: nada
// em interação, agarre ou montagem mudou para a bancada passar a pousar sobre
// uma mesa de verdade.
//
// Quem pousa é a bancada, e não a raiz da cena. Mover a raiz levaria junto o
// retículo, o aviso de rastreamento e a própria origem contra a qual as três
// coisas são medidas — a bancada fugiria da mira enquanto se tenta pousá-la.
const aumentado: RegimeAumentado = montarRegimeAumentado(cena.sala);
const superficie: ConsultaDeSuperficie = montarConsultaDeSuperficie(cena.sala);
const pouso: Ancoragem = montarAncoragem(cena.sala, cena.bancada);
const rastreamento: Rastreamento = montarRastreamento(cena.sala);
composicao.registrar('ancoragem');
// O quarto regime, e o único que não é uma sessão do aparelho. A câmera de
// fundo, o detector e a pose vivem em arquivos próprios; aqui eles só são
// apresentados uns aos outros, como todo o resto deste arquivo.
//
// Quem pousa continua sendo a bancada, e pela mesma razão do módulo anterior:
// mover a raiz levaria junto o retículo e o aviso de rastreamento. A diferença
// é que agora a bancada é reposicionada a cada quadro em que o papel aparece,
// e não uma vez por pouso.
const cameraDeFundo: CameraDeFundo = montarCameraDeFundo(canvas);
const marcado: RegimeMarcado = montarRegimeMarcado(
cena.sala,
cena.bancada,
palco.camera,
canvas,
cameraDeFundo,
);
// A decisão de qual regime abrir é tomada uma vez, com o que a sonda e a
// consulta de dispositivos responderem, e fica guardada para a página exibir.
// Ela é assíncrona porque as duas consultas são, e por isso nasce ausente: até
// a resposta chegar, o ambiente está no regime em janela, que é o caso base.
let escolhaDeRegime: Escolha | undefined = undefined;
composicao.registrar('marcador');
composicao.registrar('degradacao');
/**
* O percurso da última abertura pedida. Nasce ausente porque abrir exige
* gesto, e o carregamento da página não é gesto de ninguém.
*/
let percursoDaAbertura: Percurso | undefined = undefined;
/**
* Reúne, uma vez por quadro, o que os três módulos de ancoragem precisam.
*
* As três verificações de ausência ficam aqui, e não repetidas dentro de cada
* um deles. O quadro só existe dentro da sessão, o espaço só existe depois de a
* negociação terminar, e a sessão pode ter sido encerrada pelo aparelho entre
* um quadro e o seguinte — fora de sessão o trio inteiro é ausência, que é a
* resposta certa e a que cada módulo sabe tratar.
*/
function contextoDaSessao(quadroXR: XRFrame | undefined): QuadroDaSessao | undefined {
if (quadroXR === undefined) {
return undefined;
}
const espaco: XRReferenceSpace | null = palco.renderer.xr.getReferenceSpace();
const sessao: XRSession | null = palco.renderer.xr.getSession();
if (espaco === null || sessao === null) {
return undefined;
}
return { quadro: quadroXR, espaco, sessao };
}
ciclo.aoAbrir((aberta): void => {
if (aberta.modo === 'immersive-ar') {
// Duas famílias de apontamento ao mesmo tempo, e é o único regime em que
// isso acontece: o aparelho de mão traz o dedo, o visor com passagem de
// vídeo traz os controles, e a sessão é a mesma. Ativar só o regime
// aumentado calaria os controles no visor; ativar só o imersivo calaria o
// dedo no celular. Nenhum dos dois daria erro.
entrada.emRegimes(['aumentado', 'imersivo']);
aumentado.compor(aberta);
// A sessão em si não vem no resumo da negociação, e a consulta de
// superfície precisa dela. Ela é pedida ao renderizador, que a recebeu do
// ciclo — e este é o único ponto do projeto em que o objeto da sessão é
// usado fora do arquivo que a abriu.
const sessao: XRSession | null = palco.renderer.xr.getSession();
if (sessao !== null) {
superficie.preparar(sessao);
}
} else {
entrada.emRegime('imersivo');
}
// O mundo se assenta contra a origem que a sessão concedeu. Sem esta linha a
// bancada aparece na altura certa em um aparelho e no teto em outro, e nada
// dá erro nos dois casos.
corpo.assentar(aberta);
// A área física é fato da sessão, e a consulta só tem resposta depois de ela
// abrir. Perguntar antes devolveria sempre "não há área", que é a resposta
// errada pelo motivo errado.
void limite.consultar();
});
ciclo.aoFechar((): void => {
entrada.emRegime('janela');
// A ordem importa por um motivo só: devolver o fundo antes de a bancada
// voltar ao lugar deixaria, por um quadro, a oficina flutuando no meio da
// sala pintada de cinza. É feio e dura um trigésimo de segundo, mas é o tipo
// de coisa que a turma vê no projetor.
pouso.soltar();
superficie.esquecer();
rastreamento.reiniciar();
aumentado.desfazer();
// A mira aberta no instante em que a sessão termina ficaria desenhada na
// janela, sem comando algum que a apagasse.
arco.exibir(false);
limite.esquecer();
transporte.reiniciar();
// A câmera voltou de dentro do visor com a pose da cabeça de quem saiu. A
// órbita nunca soube disso, e é ela quem tem o estado correto guardado.
orbita.reaplicar();
corpo.desfazer();
// Sair da sessão com uma provocação em curso deixaria o mundo deslizando na
// janela, sem nada à vista que explicasse por quê.
demonstracao.corrigir();
});
const relogio: Relogio = new Relogio();
// O teto adotado é o do visor, e não o do desktop. A mesma cena vai subir nos
// dois, e orçar pelo aparelho mais folgado garante estouro no outro — que é
// justamente aquele em que o estouro produz mal-estar físico, e não só uma
// animação menos lisa.
const orcamento: Orcamento = new Orcamento(TETO_VISOR_MS);
// O contador de engasgos usa o mesmo teto do orçamento e nada mais tem em
// comum com ele: aquele guarda uma janela deslizante e informa a média, este
// não esquece nada desde o início da sessão. O corpo de quem usa também não.
const engasgos: ContadorDeEngasgos = new ContadorDeEngasgos(TETO_VISOR_MS);
const laco: Laco = montarLaco(palco, cena.sala, relogio, orcamento);
let ativo: AtivoImportado | undefined = undefined;
/**
* O regime em uso agora, decidido pelo ESTADO e não pela intenção.
*
* A distinção custou uma tarde. A escolha da degradação diz qual regime este
* aparelho alcança; a sessão pode ter sido encerrada pelo aparelho no instante
* seguinte, e o painel continuaria anunciando o regime pretendido. Quem
* responde aqui são os objetos que sabem se estão de pé.
*/
function regimeEmUso(): RegimeExecutavel {
if (marcado.ligado()) {
return 'marcador';
}
if (aumentado.composto()) {
return 'immersive-ar';
}
if (ciclo.ativa()) {
return 'immersive-vr';
}
return 'janela';
}
function nomeDoRegimeEmUso(): string {
const regime: RegimeExecutavel = regimeEmUso();
return ORDEM_DE_PREFERENCIA.find((item) => item.regime === regime)?.nome ?? regime;
}
/**
* A próxima peça liberada pela ordem da tarefa.
*
* A pergunta é feita à máquina de estados da montagem, e não a uma lista de
* ordem escrita aqui. Duplicar a ordem produziria dois donos da mesma regra, e
* o painel passaria a anunciar um passo que a montagem recusa.
*/
function proximaLiberada(): string | undefined {
for (const peca of BANCADA.pecas) {
if (montagem.estadoDe(peca.id) === 'livre' && montagem.podeAgarrar(peca.id) === undefined) {
return peca.nome.toLowerCase();
}
}
return undefined;
}
/**
* O custo do quadro em UMA linha.
*
* A folha de diagnóstico mostra cinco. O painel não tem cinco linhas para dar
* ao assunto que está em último lugar na ordem de prioridade, e resumir aqui é
* mais honesto que encolher a fonte: o número inteiro continua a um toque de
* distância, na folha.
*/
function custoEmUmaLinha(): readonly string[] {
const leitura = orcamento.ler();
if (leitura.quadrosMedidos === 0) {
return ['Quadro: ainda sem medida.'];
}
return [
`Quadro: ${leitura.intervaloMedioMs.toFixed(1)} ms (teto ${leitura.tetoMs.toFixed(1)}) · ` +
`${leitura.triangulos} triângulos`,
];
}
/** O aviso do instante, quando existe. Ele passa à frente de tudo no painel. */
function avisoDoInstante(): string | undefined {
if (rastreamento.perdido()) {
return 'Referência perdida: a cena está congelada até o aparelho reencontrar o lugar.';
}
if (percursoDaAbertura?.degradouNaAbertura === true) {
return `Degradou na abertura: o regime pedido foi recusado, e este é o ${nomeDoRegimeEmUso()}.`;
}
return undefined;
}
/**
* O informe do painel, composto a cada desenho.
*
* As quatro fontes são consultadas aqui e em nenhum outro lugar. O painel
* continua sem saber de onde vêm os números, e a ordem de descarte continua
* sendo decisão de quem compõe o informe — não de quem o desenha.
*/
function informeDoPainel(): readonly string[] {
const escolha: Escolha | undefined = escolhaDeRegime;
const capacidades: string[] = [];
const primeiroPreterido = escolha?.preteridos[0];
if (primeiroPreterido !== undefined) {
capacidades.push(`Falta neste aparelho: ${primeiroPreterido.oQueFalta}.`);
}
const alcancados: number =
escolha === undefined ? 0 : ORDEM_DE_PREFERENCIA.length - escolha.preteridos.length;
const aparelho: string =
escolha === undefined
? 'Aparelho: consulta em andamento.'
: `Aparelho: alcança ${alcancados} de ${ORDEM_DE_PREFERENCIA.length} regimes · em uso: ${nomeDoRegimeEmUso()}.`;
return compor({
passo: frasePasso(montagem.encaixadas().length, BANCADA.pecas.length, proximaLiberada()),
aparelho,
capacidades,
custo: custoEmUmaLinha(),
aviso: avisoDoInstante(),
});
}
// O relógio do laço, guardado para o ensaio de conforto. Um ensaio cronometrado
// por fora divergiria do que o ambiente mediu no mesmo período, e a comparação
// perderia justamente a parte que não depende de ninguém opinar.
let decorridoNoLacoS: number = 0;
let inicioDoEnsaioS: number = 0;
let metrosNoInicioDoEnsaio: number = 0;
let engasgosNoInicioDoEnsaio: number = 0;
laco.aoPasso((amostra, quadroXR) => {
decorridoNoLacoS = amostra.decorrido;
const contexto: QuadroDaSessao | undefined = contextoDaSessao(quadroXR);
// A ancoragem é atualizada ANTES de tudo o que lê posição, e a ordem é
// consequência direta do que ela faz: a âncora move a bancada, e a mira, o
// agarre e o encaixe medem distâncias contra peças que pendem dela. Atualizar
// depois faria a camada de interação trabalhar um quadro atrasada em relação
// ao mundo real, e o sintoma seria a peça escapando da mão de quem caminha.
rastreamento.atualizar(contexto, palco.camera, amostra.decorrido);
// Sem registro confiável, a âncora para de ser aplicada. Ela continuaria
// respondendo com a última pose conhecida, e a bancada nadaria pela sala.
pouso.congelar(rastreamento.perdido());
superficie.atualizar(rastreamento.perdido() ? undefined : contexto);
pouso.atualizar(contexto);
// O registro por marcador entra ao lado da ancoragem, e não depois dela,
// porque faz a mesma coisa por outro caminho: move a bancada. Os dois nunca
// estão ligados ao mesmo tempo — um exige sessão aumentada, o outro exige que
// não haja sessão alguma —, e a chamada sai barata quando o regime está
// desligado.
marcado.atualizar(amostra.decorrido);
// A carga da provocação de engasgo é queimada ANTES do trabalho do quadro, e
// não depois: o que se quer é atrasar a imagem deste quadro, e queimar tempo
// depois de desenhar atrasaria a do seguinte, deslocando o efeito em relação
// ao que a pessoa vê acontecer.
demonstracao.consumirCarga();
demonstracao.avancar(amostra.delta);
// O intervalo real, antes do limite de salto: é o tempo que a pessoa esperou
// pela imagem, e é ele que o corpo registra.
engasgos.registrar(amostra.decorrido, amostra.intervaloReal);
// A cena avança pelo tempo, e não pelo quadro. Em uma máquina que desenha na
// metade da cadência, a volta continua levando o mesmo tanto.
cena.eixo.rotation.y += amostra.delta * VOLTAS_POR_SEGUNDO * Math.PI * 2;
// A animação que veio dentro do arquivo é avançada pelo mesmo delta que move
// o resto. Fosse por relógio próprio, ela andaria mais rápido na máquina que
// desenha mais rápido, e a peça importada passaria a viver noutro tempo.
ativo?.avancar(amostra.delta);
prateleira.atualizar(palco.camera);
// A mira se refaz a cada quadro porque a câmera se move: parada a cena e
// parado o cursor, girar a órbita muda o que está sob a mira. O lançamento
// custa, e o custo entra no mesmo envelope que o painel exibe.
// A fila de atraso fica ENTRE a leitura das fontes e quem a consome, que é
// exatamente o lugar onde uma suavização mal pensada também ficaria. Desligada
// a provocação, a lista passa direto e o custo é zero.
const ponteiros: readonly Ponteiro[] = demonstracao.atrasar(entrada.ler());
// O primeiro acionamento em sessão aumentada POUSA; os seguintes manipulam.
// A regra é do ambiente, não da tomada, e por isso mora aqui: enquanto a
// bancada não tem lugar na sala, escolher uma peça dela é escolher um objeto
// que ainda não existe em lugar nenhum. Depois de pousada, o mesmo gesto
// volta a ser o de sempre, com o mesmo código de seleção.
const acerto: AcertoDeSuperficie | undefined = superficie.acerto();
if (
aumentado.composto() &&
!pouso.pousada() &&
acerto !== undefined &&
ponteiros.some((ponteiro) => ponteiro.acionado)
) {
pouso.pousar(acerto);
}
if (rastreamento.perdido()) {
// Congelar é a decisão do módulo, e o que ela custa é tudo o que vem
// abaixo desta linha e deixa de rodar: seleção, agarre, encaixe e
// locomoção. Comandar um mundo cuja posição não se conhece produz encaixe
// no lugar errado, e o erro sobrevive à recuperação — a peça fica travada
// onde ninguém a pôs, e nada no ambiente explica como ela chegou lá.
//
// O que continua é o que não depende de posição: o eixo segue girando e a
// animação do ativo segue andando, ambos acima desta linha. Congelar a cena
// inteira faria a perda de rastreamento parecer travamento do programa, que
// é a leitura errada e a mais assustadora das duas.
// O painel continua sendo atualizado com a cena congelada, e é deliberado:
// ele é a única coisa que ainda pode explicar por que nada se move. Encarar
// também continua, porque quem procura a explicação vai contornar a mesa.
painel.encarar(palco.camera);
painel.atualizar(informeDoPainel(), amostra.decorrido);
return;
}
selecao.atualizar(ponteiros);
// A manipulação lê os mesmos ponteiros, e depois da seleção: ela precisa
// saber o que está sob a mira neste quadro, e não no anterior. Inverter as
// duas linhas faz o agarre pegar a peça que estava sob a mira antes do
// movimento, e o defeito só aparece quando o cursor anda rápido.
manipulacao.atualizar(ponteiros);
// O encaixe em curso avança pelo tempo do quadro, como tudo o mais que se
// move nesta cena. Avançar por quadro faria a peça chegar mais rápido na
// máquina que desenha mais rápido.
montagem.avancar(amostra.delta);
// A locomoção entra DEPOIS da manipulação, e a ordem tem consequência: quem
// está com uma peça na mão e salta leva a peça junto, porque ela pende do
// nó portador e o portador acompanha a mão. Invertendo as duas linhas, a
// peça ficaria um quadro para trás a cada salto.
//
// O giro discreto é atualizado sempre; o salto, só quando o deslize está
// desligado. As duas locomoções disputam o mesmo comando, e é por isso que
// não convivem: escolher uma é a resposta da tarefa de comparação, e não uma
// limitação da implementação.
movimento.atualizar(palco.camera, amostra.delta);
if (!movimento.deslizeLigado()) {
teleporte.atualizar(ponteiros, amostra.decorrido);
}
limite.atualizar(palco.camera);
// A máscara fecha enquanto houver deslocamento contínuo em curso, e o salto
// apenas pisca. São duas coisas diferentes com a mesma geometria.
vinheta.atualizar(palco.camera, amostra.delta, movimento.deslizando());
// O painel gira para encarar quem lê ANTES de ser redesenhado, e a ordem não
// é indiferente por um detalhe: a legibilidade é medida contra a posição do
// quadro, e medi-la antes do giro daria a distância do quadro anterior.
painel.encarar(palco.camera);
painel.atualizar(informeDoPainel(), amostra.decorrido);
});
laco.iniciar();
let presa: boolean = false;
let perto: boolean = false;
return {
estrutura(): string[] {
return descreverArvore(cena.sala, 5);
},
inventario(): string[] {
const linhas: string[] = conteudo.inventario.map(
(linha) =>
`${linha.peca}: geometria ${linha.origem}, ${linha.triangulos} triângulos, ` +
`${linha.vertices} vértices`,
);
linhas.push(`Total das peças: ${conteudo.totalDeTriangulos} triângulos`);
return linhas;
},
materiais(): string[] {
return linhasDoCatalogo(conteudo.catalogo);
},
volumes(): string[] {
return conteudo.contato.linhas();
},
alternarVolumes(): boolean {
return conteudo.contato.alternarExibicao();
},
folgas(distanciaDeTeste: number, folgaGenerosa: number): ComparacaoDeFolga {
return compararFolgas(conteudo, distanciaDeTeste, folgaGenerosa);
},
prender(): number {
if (presa) {
return 0;
}
presa = true;
return reparentar(engrenagem, cena.eixo);
},
soltar(): number {
if (!presa) {
return 0;
}
presa = false;
return reparentar(engrenagem, paiOriginal);
},
presa(): boolean {
return presa;
},
posicaoDaEngrenagem(): string {
return emMetros(posicaoDeMundo(engrenagem, new Vector3()));
},
repeticao(): string[] {
return linhasDaRepeticao(custoDosParafusos);
},
niveisDeDetalhe(): string[] {
return prateleira.linhas();
},
medicao(): string[] {
const censo: Censo = censoDaCena(cena.sala);
return linhasDaMedicao(censo, orcamento.ler(), identificarPlaca(palco.renderer));
},
async trazerAtivoExterno(): Promise<string[]> {
if (ativo !== undefined) {
return linhasDoAtivo(ativo);
}
// A morsa tem dezoito centímetros de boca. Esse número não está no arquivo
// e não tem como estar: é o que se sabe da peça, e é dele que sai a escala.
const importado: AtivoImportado = await importarAtivo('/ativos/morsa.gltf', {
maiorLadoRealM: 0.18,
giroEmXGraus: -90,
assentarNaBase: true,
centrarNoPlano: true,
});
importado.no.position.add(POSICAO_DA_MORSA);
tampo.add(importado.no);
importado.tocar('AbrirFechar');
ativo = importado;
return linhasDoAtivo(importado);
},
alternarAproximacao(): boolean {
perto = !perto;
// A aproximação passou a ser um movimento da órbita, e não um reposicionamento
// da câmera. Escrever posição direto na câmera com a órbita instalada faria o
// primeiro arrasto do cursor devolvê-la ao lugar anterior, sem aviso.
if (perto) {
orbita.olharPara(new Vector3(0, 0.6, -2.8), 1.2);
} else {
orbita.olharPara(new Vector3(0, 0.95, 0), 1.8);
}
return perto;
},
enquadrarTudo(): void {
perto = false;
orbita.enquadrar(new Box3().setFromObject(cena.sala));
},
orbita(): string[] {
return orbita.descrever();
},
apontamento(): string[] {
const leitura: readonly Ponteiro[] = entrada.ler();
const ativos: string =
leitura.length === 0
? 'Nenhum ponteiro ativo neste instante: o cursor está fora da cena.'
: `${leitura.length} ponteiro ativo neste instante.`;
return [...entrada.descrever(), ativos, ...linhasDoPonteiro()];
},
fronteira(): string[] {
return linhasDaFronteira();
},
entrarEmSessao(modo: ModoDeSessao): Promise<SessaoAberta> {
return ciclo.entrar(modo);
},
sairDaSessao(): Promise<void> {
return ciclo.sair();
},
emSessao(): boolean {
return ciclo.ativa();
},
sessao(): string[] {
return ciclo.linhas();
},
escalaCorporal(): string[] {
return corpo.linhas();
},
alcance(): string[] {
const auditoria: Auditoria = auditarAlcance(cena.sala, cena.pecas, RECUO_DO_POSTO_M);
return linhasDoAlcance(auditoria, []);
},
aproximarPecas(): string[] {
// A auditoria roda antes do movimento e é ela que sai no relatório: o
// interesse é no estado que motivou o conserto, e não no estado já
// consertado, que não explica por que alguma coisa se mexeu.
const antes: Auditoria = auditarAlcance(cena.sala, cena.pecas, RECUO_DO_POSTO_M);
const movimentos: Aproximacao[] = trazerParaOAlcance(
cena.sala,
cena.pecas,
RECUO_DO_POSTO_M,
);
return linhasDoAlcance(antes, movimentos);
},
conforto(): string[] {
return [...linhasDosEngasgos(engasgos.ler(), TETO_VISOR_MS), '', ...demonstracao.linhas()];
},
provocar(caso: CasoDesconfortavel): string {
const escolhido: Caso = demonstracao.provocar(caso);
return `${escolhido.nome}. O corpo sente: ${escolhido.oQueOCorpoSente}.`;
},
corrigirDesconforto(): void {
demonstracao.corrigir();
},
locomocao(): string[] {
return [
...teleporte.linhas(),
'',
...movimento.linhas(),
'',
...arco.linhas(),
'',
...vinheta.linhas(),
'',
...manche.linhas(),
'',
...transporte.linhas(),
];
},
areaFisica(): string[] {
return limite.linhas();
},
alternarDeslize(): boolean {
const ligado: boolean = movimento.alternarDeslize();
// Trocar de forma no meio de uma mira deixaria o arco aceso sem comando
// algum capaz de confirmá-lo ou apagá-lo.
arco.exibir(false);
return ligado;
},
comparacao(): string[] {
return registroDaComparacao.linhas();
},
iniciarEnsaio(): void {
inicioDoEnsaioS = decorridoNoLacoS;
metrosNoInicioDoEnsaio = movimento.metrosPercorridos();
engasgosNoInicioDoEnsaio = engasgos.ler().engasgos;
},
registrarEnsaio(observador: string, relato: string): Ensaio {
const ensaio: Ensaio = {
forma: movimento.deslizeLigado() ? 'deslize' : 'salto',
observador,
relato,
duracaoS: decorridoNoLacoS - inicioDoEnsaioS,
metrosPercorridos: movimento.metrosPercorridos() - metrosNoInicioDoEnsaio,
engasgosNoPeriodo: engasgos.ler().engasgos - engasgosNoInicioDoEnsaio,
};
registroDaComparacao.registrar(ensaio);
return ensaio;
},
ancoragem(): string[] {
return [
...aumentado.linhas(),
'',
...superficie.linhas(),
'',
...pouso.linhas(),
'',
...rastreamento.linhas(),
];
},
repousar(): void {
pouso.soltar();
},
async entrarPorMarcador(): Promise<void> {
// Entrar por marcador com uma sessão viva poria dois registros disputando
// a mesma bancada, e o resultado seria ela oscilando entre dois lugares
// sem que nada acusasse motivo.
if (ciclo.ativa()) {
await ciclo.sair();
}
await marcado.entrar();
},
sairDoMarcador(): void {
marcado.sair();
},
registrandoPorMarcador(): boolean {
return marcado.ligado();
},
marcador(): string[] {
return [...marcado.linhas(), '', ...cameraDeFundo.linhas()];
},
async decidirRegime(): Promise<Escolha> {
const semSessao: SondaSemSessao = await sondarSemSessao();
const capacidades: CapacidadesDoAparelho = {
modosSuportados: semSessao.modosSuportados,
temCamera: await haCameraDisponivel(),
contextoSeguro: semSessao.contextoSeguro,
};
// A decisão é guardada, e o ambiente NÃO entra sozinho no regime
// escolhido. Abrir sessão imersiva exige gesto de quem usa, e ligar a
// câmera sem que ninguém tenha pedido é pior que isso: a página abriria e
// acenderia a luz do aparelho. O que a decisão faz é dizer qual botão
// apertar, e por quê.
escolhaDeRegime = escolherRegime(capacidades);
return escolhaDeRegime;
},
degradacao(): string[] {
const escolha: Escolha | undefined = escolhaDeRegime;
if (escolha === undefined) {
return [
'A consulta ao aparelho ainda não respondeu. Até ela chegar, o ambiente está no regime ' +
'em janela — que é o caso base, e não uma espera.',
'',
...linhasDaOrdem(),
];
}
return [
...mensagemHonesta(escolha, cameraDeFundo.estado()),
'',
...linhasDaOrdem(),
];
},
emRealidadeAumentada(): boolean {
return aumentado.composto();
},
plataforma(): string[] {
return [contagemDaFronteira(), '', ...linhasDaPlataforma()];
},
aoMudarSessao(abriu: (aberta: SessaoAberta) => void, fechou: () => void): void {
ciclo.aoAbrir(abriu);
ciclo.aoFechar(fechou);
},
interacao(): string[] {
return [...selecao.linhas(), ...destaque.linhas()];
},
aoMudarSelecao(ouvinte: (mudanca: MudancaDeSelecao) => void): void {
selecao.aoMudar(ouvinte);
},
manipulacao(): string[] {
return manipulacao.linhas();
},
tolerancia(): string[] {
return [...linhasDaTolerancia(), ...encaixes.linhas()];
},
montagem(): string[] {
return montagem.linhas();
},
alternarFolgas(): boolean {
return alternarFolgasVisiveis(sockets);
},
aoResponder(ouvinte: (parecer: string) => void): void {
montagem.aoResponder(ouvinte);
},
regimeEmJanela(): string[] {
const nomes: readonly string[] = BANCADA.pecas.map((peca) => peca.id);
const entrega: EntregaDaJanela = medirEntrega(cena.sala, nomes);
return linhasDaJanela(entrega, aferirLimites(palco.renderer));
},
composicao(): string[] {
return composicao.linhas();
},
async abrirMelhorRegime(): Promise<Percurso> {
const escolha: Escolha = escolhaDeRegime ?? (await this.decidirRegime());
// As três aberturas são declaradas aqui, e a janela não tem uma. A ausência
// é o mecanismo: o roteamento para no primeiro regime sem abertura
// registrada, e é assim que o destino da degradação não depende de abrir.
const aberturas: Map<RegimeExecutavel, Abertura> = new Map<RegimeExecutavel, Abertura>([
[
'immersive-ar',
{
abrir: async (): Promise<void> => {
await ciclo.entrar('immersive-ar');
},
abriu: (): boolean => aumentado.composto(),
motivoDaRecusa: (): string =>
'a sessão abriu e o aparelho não compôs sobre o ambiente — ele desenha em tela opaca',
},
],
[
'immersive-vr',
{
abrir: async (): Promise<void> => {
await ciclo.entrar('immersive-vr');
},
abriu: (): boolean => ciclo.ativa(),
motivoDaRecusa: (): string => 'a sessão não ficou de pé depois de aberta',
},
],
[
'marcador',
{
abrir: async (): Promise<void> => {
if (ciclo.ativa()) {
// Dois registros disputando a mesma bancada oscilariam sem que
// nada acusasse a causa. A regra é a do módulo anterior, e o
// roteamento a herda em vez de reescrevê-la.
await ciclo.sair();
}
await marcado.entrar();
},
abriu: (): boolean => marcado.ligado(),
motivoDaRecusa: (): string => `a câmera respondeu: ${cameraDeFundo.estado()}`,
},
],
]);
percursoDaAbertura = await abrirDescendo(escolha, aberturas);
return percursoDaAbertura;
},
percurso(): string[] {
return linhasDoPercurso(percursoDaAbertura);
},
registro(): string[] {
return registroDeAparelhos.linhas();
},
registrarAparelho(aparelho: AparelhoTestado): void {
registroDeAparelhos.registrar(aparelho);
},
exportarRegistro(): string {
return registroDeAparelhos.exportar();
},
uso(): string[] {
return registroDeUso.linhas();
},
registrarObservacao(observacao: Observacao): void {
registroDeUso.registrar(observacao);
},
regimeEmUso(): RegimeExecutavel {
return regimeEmUso();
},
legibilidadeDoPainel(): string[] {
const medida: Legibilidade = painel.legibilidade(palco.camera);
return linhasDaLegibilidade(medida);
},
alternarChamadaDoPainel(): boolean {
if (painel.chamado()) {
painel.devolver();
return false;
}
painel.chamar(palco.camera);
return true;
},
encerrar(): void {
// A sessão sai primeiro. Parar o laço com o visor ainda apresentando
// deixaria quem está com o aparelho no rosto diante de uma imagem
// congelada, e a saída passaria a ser o menu do sistema.
void ciclo.sair();
// A câmera é apagada aqui, e não pelo recolhimento automático da página. A
// luz indicadora do aparelho continuaria acesa depois de o ambiente parar,
// e quem usa leria isso como o programa gravando pelas costas dele.
marcado.sair();
// A provocação se corrige sozinha pelo tempo do laço, e o laço está prestes
// a parar: sem esta linha o mundo ficaria congelado deslocado.
demonstracao.corrigir();
corpo.desfazer();
laco.parar();
orbita.encerrar();
entrada.encerrar();
arco.encerrar();
vinheta.encerrar();
window.removeEventListener('keydown', aoTeclar);
window.removeEventListener('keydown', aoApertarSeta);
window.removeEventListener('keyup', aoSoltarSeta);
},
};
}
/**
* A demonstração da ordem das operações, feita fora da cena porque ela não
* precisa de cena: são duas matrizes e um ponto. O caso é o do encaixe — levar
* uma peça a trinta centímetros de onde está e girá-la um quarto de volta —, e
* os dois destinos ficam a distância de meio braço um do outro.
*/
export function frasesSobreAOrdem(): string[] {
const comparacao: ComparacaoDeOrdem = compararOrdem(
new Vector3(0, 0, 0),
new Vector3(0.3, 0, 0),
new Vector3(0, 1, 0),
90,
);
return [
`Girando primeiro, o ponto vai para ${emMetros(comparacao.girandoPrimeiro)}.`,
`Transladando primeiro, vai para ${emMetros(comparacao.transladandoPrimeiro)}.`,
`As duas ordens deixam a peça a ${comparacao.distancia.toFixed(3)} m uma da outra, e nenhuma das duas acusa erro.`,
];
}
Este arquivo não traz conceito novo, e essa é a função dele. Ele existe para que cada um dos outros contenha um só: o relógio não sabe o que é uma bancada, a cena não sabe medir tempo, e o painel não sabe de onde vêm os números que exibe.
Quem os apresenta uns aos outros é a composição, e é o único arquivo que muda quando a composição muda. Nos módulos de regime e de entrada, esse desenho é o que permite trocar de aparelho sem tocar na oficina.
1.5.6 Onde é fácil errar, e como conferir
O erro mais comum é o mais invisível: somar um valor fixo a cada quadro, em vez de multiplicar pelo tempo transcorrido. Na máquina de quem escreveu, funciona. Na máquina ao lado, o mesmo mecanismo gira mais devagar, e ninguém liga uma coisa à outra.
A conferência é direta e não exige instrumento. Abrir o ambiente em duas máquinas de cadências diferentes e cronometrar uma volta do eixo. Se as voltas divergem, o laço está contando quadros.
O segundo erro é declarar o teto e nunca mais olhar para ele. O painel existe contra isso: o número fica na cena, visível durante toda a construção, e a linha que informa a proporção de quadros acima do teto denuncia a peça pesada no dia em que ela entra.
1.6 Verificação do estado deste módulo
O que precisa estar verdadeiro antes de o módulo seguinte começar.
| Verificação | Como se confere |
|---|---|
| A cena é uma árvore, e o parentesco tem razão de projeto | ler a estrutura impressa e justificar cada nível |
| Mover a bancada leva junto tudo o que está sobre ela | deslocar o nó da bancada e conferir que nada fica para trás |
| A reparentagem preserva a posição no mundo | prender e soltar, lendo o desvio a cada vez |
| O eixo gira na mesma velocidade em máquinas diferentes | cronometrar uma volta em duas máquinas |
| A suspensão da aba não produz salto | trocar de aba, voltar, e observar o mecanismo |
| O custo do quadro é legível de dentro da cena | ler o painel sem recorrer à página |
| O ambiente se ajusta ao redimensionar e ao girar o aparelho | redimensionar a janela e girar o celular |
| O código analisa limpo em modo estrito | verificação de tipos do projeto, sem emissão |
A terceira linha é a que decide o módulo seguinte. Peça que pula um milímetro aqui pula um palmo diante da turma, e o módulo de manipulação inteiro se apoia nessa operação.
O que fica pronto ao fim deste módulo é uma estrutura, e não uma paisagem. A oficina é feia, tem cinco peças e um painel de texto. Ela também tem uma árvore que justifica cada parentesco, um relógio que não mente sobre o tempo e um teto declarado antes do primeiro polígono caro. A partir do módulo seguinte entra conteúdo — e ele entra dentro de um envelope que já existe.