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.