1 Realidade aumentada: registro, detecção de superfície e ancoragem — 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

“Mouse, controle e dedo são plugues diferentes na mesma tomada — a lógica de montagem só enxerga a tomada.” Hoje o dedo entra, e a frase deixa de ser promessa.

De quantas maneiras já se apanhou a engrenagem grande neste percurso? De duas. Com o cursor, arrastada sobre um plano. Com o controle rastreado, segurada a quarenta centímetros do rosto, com os seis graus de liberdade da mão.

Falta a terceira, e ela acontece sobre uma mesa que existe. A bancada pousa no tampo de fórmica da sala de aula, o dedo encosta na tela, e a peça é apanhada pelo mesmo caminho de sempre. Nenhuma linha de seleção, agarre ou encaixe muda para isso.

Entram cinco arquivos, quatro deles numa pasta nova. A lista dos que não mudaram é o recibo do módulo, e ela é a mesma de sempre: a camada de interação inteira ficou intocada pela terceira vez.

1.2 O momento em que a aparência vira prova

Uma figura que serviu bem por onze módulos morre aqui, e o lugar de anunciar isso é antes de o aluno tropeçar nela.

Presença é o palco acertando escala e tempo; fidelidade é a pintura do cenário, e cenário pintado não segura ninguém no lugar. Essa separação sustentou o percurso inteiro. Ela mandava acertar a altura do tampo antes de caprichar no latão, e mandava certo.

Neste regime ela deixa de valer, e a razão não é estética. Uma bancada com escala correta, latência baixa e registro impecável, desenhada por cima da mão que passa à frente dela, para de convencer. Ninguém acredita que ela está sobre a mesa.

O que mudou é o papel da aparência. No ambiente que substitui o mundo, sombra e oclusão eram enfeite — não havia com que comparar. No ambiente que preserva o mundo, elas são a evidência de que o objeto ocupa aquele lugar, porque tudo em volta as tem.

Compramos alguma das duas? Nenhuma — e isso está escrito no código como dado, não como omissão. O arquivo do regime aumentado carrega os três limites — oclusão, sombra e estimativa de luz —, cada um com o que o olho vê por ele faltar e por que ele ficou de fora. É a diferença entre uma lacuna declarada e um defeito por descobrir.

1.3 Tarefa 1: Ligar o terceiro back-end e abrir a cena sobre o mundo

O plugue que falta é o mais simples dos três, e chega pelo caminho que ninguém adivinha na primeira vez.

Enunciado da tarefa

O toque na tela entra como a última implementação da abstração de apontar, e a sessão passa a compor a cena sobre a imagem do ambiente físico captada pela câmera. A lógica de seleção, agarre e encaixe continua a mesma dos dois regimes anteriores.

O que fica pronto é o ambiente aberto sobre o mundo real, com os objetos do domínio manipuláveis pelo mesmo caminho de sempre. Três formas de apontar, uma lógica só: é a economia do percurso inteiro ficando visível num gesto.

1.3.1 Onde o dedo NÃO chega

A primeira versão deste arquivo foi escrita sobre os eventos de toque do navegador. Ela funcionou perfeitamente no computador de mesa, com o dedo na tela sensível, e não respondeu a nada no celular. Foi meia tarde até a causa aparecer.

Numa sessão aumentada de aparelho de mão, a página não está sendo desenhada. A sessão tomou a tela, e o navegador deixou de entregar evento algum ao elemento de desenho. O toque chega por outro caminho: como fonte de entrada da própria sessão, com modo de raio de tela.

Essa fonte é transitória. Ela nasce quando o dedo encosta e morre quando ele sai, e isso muda um comportamento que o percurso vinha dando de graça. Não existe mira sem toque num aparelho de mão.

src/bancada/input/toque.ts
// ---------------------------------------------------------------------------
// O terceiro e último plugue: o toque na tela.
//
// Mouse, controle e dedo são plugues diferentes na mesma tomada — a lógica de
// montagem só enxerga a tomada. Este arquivo fecha a conta: com ele, a frase
// deixa de ser desenho e passa a ser as três implementações que existem, e a
// camada de interação continua sem uma linha que fale de pixel, de gatilho ou de
// dedo.
//
// O dedo entra por onde ninguém espera na primeira vez. Numa sessão aumentada de
// aparelho de mão, a página não está sendo desenhada — a sessão tomou a tela —, e
// os eventos de toque do navegador não chegam mais ao elemento de desenho.
// Escrever o back-end sobre eles funciona no teste de mesa e produz um ambiente
// que não responde a toque nenhum no celular, que é justamente o aparelho para o
// qual ele foi escrito.
//
// O toque chega, em vez disso, como fonte de entrada da própria sessão, com
// modo de raio DE TELA. Ela é TRANSITÓRIA: nasce quando o dedo encosta e morre
// quando ele sai, e essa é a diferença de comportamento que a abstração absorve
// sem alterações. O cursor dava a mira de graça, por passagem, antes de qualquer
// clique; o dedo não dá. Não existe mira sem toque num aparelho de mão, e por
// isso o realce por passagem — que a camada de seleção implementa desde o módulo
// de interação — simplesmente não tem o que realçar até alguém encostar.
//
// Isso não é limitação a corrigir. É o aparelho, e a resposta certa é deixá-lo
// aparecer: o ponteiro some da lista entre um toque e outro, exatamente como o
// cursor some quando sai da superfície de desenho, e nada acima precisa saber.
//
// A separação com o segundo plugue exigiu dele uma declaração que até agora não
// era necessária. Enquanto só havia controles, toda fonte de entrada da sessão
// era controle; com o dedo na mesma sessão, cada um dos dois passa a dizer qual
// modo de raio atende — e sem isso o mesmo toque seria lido duas vezes, uma como
// dedo e outra como controle rastreado que ninguém segura.
// ---------------------------------------------------------------------------

import {
  Object3D,
  Quaternion,
  Vector3,
  type WebGLRenderer,
  type XRTargetRaySpace,
} from 'three';

import type { FonteDePonteiro, Ponteiro } from './ponteiro';

/**
 * Quantas fontes de toque a fonte acompanha.
 *
 * Duas, e não uma: a tela reconhece dois dedos ao mesmo tempo, e a camada de
 * seleção já sabe lidar com uma lista de ponteiros desde que os dois controles
 * chegaram. Ignorar o segundo dedo faria o ambiente parar de responder no meio de
 * um gesto de duas mãos, sem nada que explicasse por quê.
 */
const TOQUES_ACOMPANHADOS: number = 2;

/** O eixo do raio, no espaço da fonte. A mesma convenção do controle rastreado. */
const EIXO_DA_MIRA: Vector3 = new Vector3(0, 0, -1);

/** Estado de um dedo entre um quadro e o seguinte. */
interface Dedo {
  readonly indice: number;
  readonly no: XRTargetRaySpace;
  /** Verdadeiro entre o encostar e o sair do dedo. Fora disso a fonte não existe. */
  encostado: boolean;
  /** Ordem de acionamento. Quem encostou por último tem o número maior. */
  ordem: number;
}

// Temporários de leitura, pela mesma razão do controle rastreado: o laço do
// aparelho de mão é o mais apertado dos três, e alocação por quadro aparece nele
// antes de aparecer em qualquer outro.
const posicao: Vector3 = new Vector3();
const orientacao: Quaternion = new Quaternion();
const direcao: Vector3 = new Vector3();

/**
 * Instala a fonte de toque sobre o renderizador e a raiz da cena.
 *
 * Os nós de mira são os mesmos objetos que a biblioteca entrega por índice, e é
 * por isso que este arquivo NÃO os cria nem os destrói: ele apenas escuta os
 * avisos deles e devolve leitura quando o modo de raio for o de tela. Quem os
 * anexa à árvore é a fonte que chegou primeiro, e a travessia que atualiza a
 * matriz de mundo deles serve às duas.
 */
export function montarFonteDeToque(renderer: WebGLRenderer, raiz: Object3D): FonteDePonteiro {
  let contadorDeOrdem: number = 0;

  const dedos: Dedo[] = [];
  const desligadores: (() => void)[] = [];

  for (let indice: number = 0; indice < TOQUES_ACOMPANHADOS; indice += 1) {
    const no: XRTargetRaySpace = renderer.xr.getController(indice);
    const dedo: Dedo = { indice, no, encostado: false, ordem: 0 };

    function aoConectar(evento: { data: XRInputSource }): void {
      // A verificação do modo de raio é o que separa este plugue do anterior. Sem
      // ela, um controle rastreado alimentaria a fonte de toque e um toque
      // alimentaria a fonte de controles, e o sintoma seria a peça sendo apanhada
      // duas vezes no mesmo gesto.
      if (evento.data.targetRayMode !== 'screen') {
        return;
      }
      dedo.encostado = true;
      contadorDeOrdem += 1;
      dedo.ordem = contadorDeOrdem;
    }

    function aoDesconectar(): void {
      // O dedo saiu da tela e a fonte de entrada deixou de existir. O ponteiro
      // some da lista, e quem estava com uma peça na mão continua com ela: soltar
      // é decisão da camada de manipulação, tomada pelo estado do agarre, e não
      // consequência de o aparelho ter deixado de reportar a fonte.
      dedo.encostado = false;
    }

    no.addEventListener('connected', aoConectar);
    no.addEventListener('disconnected', aoDesconectar);

    desligadores.push((): void => {
      no.removeEventListener('connected', aoConectar);
      no.removeEventListener('disconnected', aoDesconectar);
    });

    raiz.add(no);
    dedos.push(dedo);
  }

  function lerDedo(dedo: Dedo): Ponteiro {
    dedo.no.getWorldPosition(posicao);
    dedo.no.getWorldQuaternion(orientacao);
    direcao.copy(EIXO_DA_MIRA).applyQuaternion(orientacao).normalize();

    return {
      id: `toque-${dedo.indice}`,
      regime: 'aumentado',
      // A origem é a própria câmera do aparelho, e a direção passa pelo pixel
      // tocado: é a mesma geometria do cursor, com a diferença de que aqui quem
      // faz a conta é a plataforma, porque só ela sabe onde a câmera está.
      origem: posicao.clone(),
      direcao: direcao.clone(),
      // Enquanto o dedo está na tela, há acionamento. Não existe o estado
      // intermediário do cursor — mira sem clique —, e é essa ausência que o
      // comentário do topo do arquivo descreve.
      acionado: true,
      // A pega vem do próprio toque mantido, como no controle. Um dedo que
      // encosta e não sai é um dedo que está segurando.
      agarre: true,
      estado: 'acionado',
      // Sem pose. O aparelho de mão rastreia a si mesmo, e não a mão de quem o
      // segura: não há mão a que prender a peça, e a manipulação cai no mesmo
      // caminho por raio e plano que o cursor já usava.
      pose: undefined,
    };
  }

  return {
    nome: 'toque na tela do aparelho de mão',
    regime: 'aumentado',
    ler(): readonly Ponteiro[] {
      if (!renderer.xr.isPresenting) {
        return [];
      }
      const ativos: Dedo[] = dedos.filter((dedo) => dedo.encostado);
      // A mesma regra do controle rastreado, e pelo mesmo motivo: quem tocou por
      // último manda. Ela mora aqui porque é conhecimento do aparelho, e a camada
      // de seleção continua lendo o primeiro da lista sem saber contar dedos.
      ativos.sort((a: Dedo, b: Dedo) => b.ordem - a.ordem);
      return ativos.map(lerDedo);
    },
    encerrar(): void {
      for (const desligar of desligadores) {
        desligar();
      }
      desligadores.length = 0;
      dedos.length = 0;
    },
  };
}

O realce por passagem, que o cursor oferecia antes de qualquer clique, simplesmente não tem o que realçar aqui. E isso é o aparelho, e a resposta certa é deixá-la aparecer. O ponteiro some da lista entre um toque e outro, exatamente como o cursor some ao sair da superfície de desenho.

O terceiro plugue cobrou do segundo uma declaração que até agora ninguém precisava fazer. Enquanto só havia controles, toda fonte de entrada da sessão era controle, e verificar isso seria ruído. Com o dedo na mesma sessão, cada plugue passa a dizer qual modo de raio atende — sem isso, o mesmo toque seria lido duas vezes, e a peça seria apanhada e largada no mesmo gesto.

1.3.2 Duas famílias de apontar, uma sessão só

Aqui a tomada precisou de uma mudança, e ela é a primeira em três módulos. A sessão aumentada é aberta tanto pelo aparelho de mão, cuja entrada é o dedo, quanto pelo visor com passagem de vídeo, cuja entrada continua sendo o controle rastreado.

O regime da sessão é um só. As fontes que ele admite são duas. Ativar apenas o regime aumentado calaria os controles dentro do visor; ativar apenas o imersivo calaria o dedo no celular. Nenhum dos dois casos daria erro, e os dois deixariam metade da turma sem apontar.

A saída foi deixar a tomada aprender uma lista de regimes ativos, em vez de um valor. A alternativa era a composição perguntar, por aparelho, se há controle — e essa pergunta acima da tomada é exatamente o conhecimento que ela existe para conter.

1.3.3 A linha que apaga o mundo

A operação que abre a cena sobre o ambiente é decepcionante de tão curta: apaga-se o fundo da cena. O que estava atrás dele passa a ser a sala.

A demonstração mais barata do módulo se faz sem escrever nada. Entra-se em sessão aumentada com o fundo cinza ainda pintado, e o visor mostra um retângulo opaco no lugar do mundo. Depois disso ninguém precisa que a palavra composição seja definida.

src/bancada/modes/aumentado.ts
// ---------------------------------------------------------------------------
// O terceiro regime: compor a cena sobre a imagem do ambiente físico.
//
// A operação técnica é quase decepcionante de tão curta — apaga-se o fundo da
// cena, e o que estava atrás dele passa a ser a sala. O que ela custa é entender
// o que essa linha significa, e a demonstração mais barata do módulo é feita sem
// código: entra-se em sessão aumentada com o fundo opaco ainda pintado, e o
// visor mostra um retângulo cinza no lugar do mundo. Ninguém precisa explicar o
// que é composição depois disso.
//
// O que este arquivo NÃO faz é a parte mais importante dele, e está escrito como
// dado logo abaixo. A bancada não é ocluída pelos objetos reais, não projeta
// sombra na mesa e não recebe a cor da luz do cômodo. Nenhuma dessas três coisas
// é esquecimento: são capacidades que existem, que custam caro e que este
// percurso não compra. Declará-las é o que impede a conclusão errada — a de que
// o registro está ruim quando, na verdade, ele está certo e a APARÊNCIA é que
// não o confirma.
//
// E é aqui que uma figura útil do começo do percurso encontra o seu limite.
// Presença é o palco acertando escala e tempo; fidelidade é a pintura do
// cenário, e cenário pintado não segura ninguém no lugar. Isso vale nos dois
// regimes anteriores e deixa de valer neste: um objeto com escala e latência
// corretas, sem oclusão e sem sombra, para de convencer — não porque esteja
// feio, mas porque a aparência virou EVIDÊNCIA de que ele ocupa aquele lugar. A
// sombra deixou de ser pintura e passou a ser prova.
// ---------------------------------------------------------------------------

import { Color, Scene, type Texture } from 'three';

import { regimePorId, type ModoDeComposicao } from './regimes';
import type { SessaoAberta } from './sessao';

/** Uma capacidade que existe na área, que a aparência cobra, e que não está aqui. */
export interface LimiteDaComposicao {
  readonly capacidade: string;
  /** O que o olho vê por ela faltar. */
  readonly oQueOOlhoVe: string;
  /** Por que ela não foi comprada neste percurso. */
  readonly porQueNaoEstaAqui: string;
}

/**
 * Os três limites da composição, escritos antes de alguém os descobrir na
 * demonstração.
 *
 * A ordem é a do quanto cada um denuncia o objeto virtual. A oclusão é a
 * primeira porque é a que qualquer pessoa nota sem ser avisada: a bancada
 * aparecendo por cima da mão de quem a toca é o defeito que a turma inteira
 * enxerga ao mesmo tempo.
 */
export const LIMITES_DA_COMPOSICAO: readonly LimiteDaComposicao[] = [
  {
    capacidade: 'oclusão pelo ambiente real',
    oQueOOlhoVe:
      'a bancada continua desenhada por cima da mão que passa à frente dela e da cadeira ' +
      'que deveria escondê-la',
    porQueNaoEstaAqui:
      'exige o mapa de profundidade do aparelho como textura de teste, com formato próprio ' +
      'pedido na abertura da sessão; um pedido malformado derruba a sessão inteira em vez ' +
      'de apenas negar o recurso',
  },
  {
    capacidade: 'sombra projetada sobre a superfície real',
    oQueOOlhoVe:
      'a bancada parece flutuar um dedo acima da mesa, mesmo estando exatamente sobre ela',
    porQueNaoEstaAqui:
      'exige superfície receptora de sombra alinhada ao plano detectado e um mapa de sombra ' +
      'no orçamento de quadro, que é o mesmo orçamento que o conforto do visor consome',
  },
  {
    capacidade: 'estimativa da luz do cômodo',
    oQueOOlhoVe:
      'a bancada é iluminada pelas duas luzes que nós declaramos, e não pela janela que está ' +
      'à esquerda de quem olha; o latão brilha do lado errado',
    porQueNaoEstaAqui:
      'é capacidade concedida por poucos aparelhos e consumida por quadro; entra na lista de ' +
      'extensões da demonstração, não no núcleo',
  },
];

export interface RegimeAumentado {
  /** Apaga o fundo e confronta a composição declarada com a que a sessão informa. */
  compor(aberta: SessaoAberta): void;
  /** Devolve o fundo. A sessão terminou, e a janela precisa dele de volta. */
  desfazer(): void;
  composto(): boolean;
  linhas(): string[];
}

export function montarRegimeAumentado(sala: Scene): RegimeAumentado {
  const fundoOriginal: Color | Texture | null = sala.background;

  let ativo: boolean = false;
  let informada: ModoDeComposicao | undefined = undefined;

  return {
    compor(aberta: SessaoAberta): void {
      // O fundo nulo é toda a operação. O que ele apaga é a cor que preenchia o
      // quadro antes de qualquer objeto ser desenhado; sem ela, os pixels em que
      // nada foi desenhado saem transparentes, e o aparelho compõe o que a câmera
      // dele está vendo por baixo.
      sala.background = null;
      ativo = true;
      // Sem conversão: a união que a especificação declara para o modo de
      // composição é a mesma que o primeiro módulo escreveu à mão, e é essa
      // coincidência deliberada que permite comparar os dois sem tradução.
      informada = aberta.composicao;
    },

    desfazer(): void {
      sala.background = fundoOriginal;
      ativo = false;
      informada = undefined;
    },

    composto(): boolean {
      return ativo;
    },

    linhas(): string[] {
      const esperada: ModoDeComposicao = regimePorId('immersive-ar').composicaoEsperada;
      const linhas: string[] = [
        `Composição sobre o ambiente: ${ativo ? 'ligada' : 'desligada'}.`,
      ];

      if (informada === undefined) {
        linhas.push(
          `Composição declarada para este regime no primeiro módulo: ${esperada}. A leitura do ` +
            'valor real só existe com sessão aumentada aberta.',
        );
      } else {
        // O confronto que o primeiro módulo prometeu. A declaração dos três
        // regimes foi escrita antes de haver sessão, justamente para poder ser
        // cobrada aqui — e o caso interessante não é o acerto, é a divergência.
        linhas.push(
          `Declarado no primeiro módulo: ${esperada}. Informado por esta sessão: ${informada}.`,
          informada === esperada
            ? 'A declaração se confirmou neste aparelho.'
            : 'Divergência, e ela é fato do aparelho, não defeito da declaração: fundo aditivo ' +
              'soma luz em vez de substituir pixel, e o preto da cena simplesmente não aparece.',
        );
        if (informada === 'opaque') {
          linhas.push(
            'A sessão abriu em modo opaco: este aparelho não compõe sobre o mundo. O ambiente ' +
              'está correto e o mundo está tapado — é limite do aparelho, e a mensagem certa a ' +
              'quem usa diz isso, não "erro ao iniciar".',
          );
        }
      }

      linhas.push('');
      for (const limite of LIMITES_DA_COMPOSICAO) {
        linhas.push(
          `${limite.capacidade}: o olho vê ${limite.oQueOOlhoVe}. Fora do percurso porque ` +
            `${limite.porQueNaoEstaAqui}.`,
        );
      }
      return linhas;
    },
  };
}

Este arquivo também cobra uma promessa antiga. No primeiro módulo declaramos, por escrito e sem sessão nenhuma aberta, qual composição de fundo cada regime deveria informar. Agora há sessão, e a declaração é confrontada com o que o aparelho responde.

O caso interessante é a divergência: um fundo aditivo soma luz em vez de substituir pixel, e o preto da cena não aparece de jeito nenhum. E há o caso extremo, que também está tratado — a sessão que abre em modo opaco simplesmente não compõe sobre o mundo. Ali o ambiente está correto e o aparelho é que não faz aquilo, e a mensagem honesta diz isso.

1.4 Tarefa 2: Pousar o objeto sobre a superfície detectada

Esta é a tarefa em que o esvaziamento mais comum da disciplina acontece, e ele impressiona na tela.

Enunciado da tarefa

O objeto virtual passa a se localizar em relação ao que o aparelho detectou do ambiente — a superfície encontrada, a pose estimada, a âncora criada —, e não em relação à tela. Um desenho fixo por cima do vídeo usa a câmera como papel de parede, e o assunto deste trecho passa ao largo.

A tarefa fecha quando a consulta de interseção contra a superfície detectada decide onde o objeto pousa, e quando ele permanece onde foi posto enquanto quem observa caminha em volta. O sensor do aparelho precisa estar produzindo o dado de mundo que o ambiente consome.

1.4.1 Contra o que o raio é lançado

A pergunta que separa realidade aumentada de desenho sobre vídeo cabe em cinco palavras: contra o que o raio é lançado?

Nos módulos anteriores ele era lançado contra a árvore da cena. Objetos que nós criamos, cujas posições nós escrevemos, cuja existência é decisão nossa. Aqui ele é lançado contra superfícies que ninguém modelou.

Elas existem porque o aparelho desenha a planta da sala enquanto atravessa a sala, e corrige o desenho toda vez que reconhece um canto por onde já passou. O cartógrafo do módulo de dispositivos vem trabalhando desde que a sessão abriu, e a consulta de interseção é a primeira vez que o projeto pergunta a ele alguma coisa.

src/bancada/anchoring/superficie.ts
// ---------------------------------------------------------------------------
// A consulta de interseção contra o que o aparelho encontrou do ambiente.
//
// Este é o arquivo que separa realidade aumentada de desenho sobre vídeo, e a
// diferença cabe numa pergunta: contra o que o raio é lançado? Nos módulos
// anteriores ele era lançado contra a árvore da cena — objetos que nós mesmos
// criamos, cujas posições nós escrevemos. Aqui ele é lançado contra superfícies
// que ninguém modelou, que o aparelho descobriu enquanto a pessoa andava pela
// sala, e que só existem porque o cartógrafo do módulo de dispositivos vem
// desenhando a planta do cômodo desde que a sessão abriu.
//
// A consulta é da plataforma, e é ela quem responde. O que o projeto faz é pedir
// a fonte uma vez, ler o resultado a cada quadro e desenhar o retículo onde a
// resposta caiu. A tentação de "melhorar" a resposta com um plano suposto no
// nível do chão é o esvaziamento do módulo inteiro: um plano suposto acerta na
// sala em que se testou e erra em qualquer outra.
//
// A fonte é pedida contra o espaço do VISOR, e não contra o espaço de trabalho.
// O raio sai do meio do campo de visão e acompanha a cabeça — quem escolhe onde
// a bancada pousa é quem olha, não uma direção fixa da sala. Pedir contra o
// espaço de trabalho daria um raio parado, apontando sempre para o mesmo canto,
// e a demonstração pareceria funcionar até alguém virar a cabeça.
// ---------------------------------------------------------------------------

import { Mesh, MeshBasicMaterial, Object3D, RingGeometry } from 'three';

import { assentarNoEspacoLocal, type QuadroDaSessao } from './espaco';

/** Por que não há resposta agora. Nenhum destes casos é erro de código. */
export type MotivoDeAusencia =
  | 'fora de sessão'
  | 'a consulta não foi concedida por este aparelho'
  | 'a consulta ainda está sendo preparada'
  | 'nenhuma superfície encontrada sob a mira';

/** Raio interno e externo do anel do retículo, em metros. */
const RAIO_INTERNO_M: number = 0.06;
const RAIO_EXTERNO_M: number = 0.08;

/** Uma resposta da consulta, já no espaço de referência da sessão. */
export interface AcertoDeSuperficie {
  /** O resultado bruto. É dele que a âncora nasce, e por isso ele viaja junto. */
  readonly resultado: XRHitTestResult;
  /** A pose do ponto atingido, em coordenadas do espaço de referência. */
  readonly matriz: Float32Array;
}

export interface ConsultaDeSuperficie {
  /** O anel que marca onde a resposta caiu. Filho da raiz, como tudo o mais. */
  readonly no: Object3D;
  /** Pede a fonte de consulta à sessão recém-aberta. Uma vez por sessão. */
  preparar(sessao: XRSession): void;
  atualizar(contexto: QuadroDaSessao | undefined): void;
  /** A última resposta válida, ou nada quando não há superfície sob a mira. */
  acerto(): AcertoDeSuperficie | undefined;
  /** Cancela a fonte e esquece o estado. A sessão terminou. */
  esquecer(): void;
  linhas(): string[];
}

export function montarConsultaDeSuperficie(raiz: Object3D): ConsultaDeSuperficie {
  // O anel deitado sobre o plano do piso: a geometria nasce de pé, e o giro em
  // torno do eixo lateral a deita. Sem ele o retículo aparece como uma linha
  // fina quando visto de cima, que é exatamente de onde ele é olhado.
  const anel: Mesh = new Mesh(
    new RingGeometry(RAIO_INTERNO_M, RAIO_EXTERNO_M, 32).rotateX(-Math.PI / 2),
    // Material sem iluminação, e de propósito: o retículo é instrumento de mira,
    // não objeto da cena. Escurecer com a luz o tornaria ilegível justamente na
    // sala mal iluminada, que é onde a detecção já é mais difícil.
    new MeshBasicMaterial({ color: 0x4f7cff }),
  );
  anel.name = 'reticulo';
  anel.visible = false;
  raiz.add(anel);

  let fonte: XRHitTestSource | undefined = undefined;
  let pedida: boolean = false;
  let motivo: MotivoDeAusencia = 'fora de sessão';
  let ultimo: AcertoDeSuperficie | undefined = undefined;
  let quadrosComResposta: number = 0;
  let quadrosSemResposta: number = 0;

  return {
    no: anel,

    preparar(sessao: XRSession): void {
      if (pedida) {
        return;
      }
      pedida = true;
      motivo = 'a consulta ainda está sendo preparada';

      // O método é opcional na especificação, e a ausência dele é a resposta de
      // um aparelho que abriu sessão aumentada sem conceder a consulta. Chamar
      // sem verificar produziria uma exceção dentro do laço, no visor, onde
      // ninguém abre console para lê-la.
      const pedirFonte:
        | ((opcoes: XRHitTestOptionsInit) => Promise<XRHitTestSource> | undefined)
        | undefined = sessao.requestHitTestSource;
      if (pedirFonte === undefined) {
        motivo = 'a consulta não foi concedida por este aparelho';
        return;
      }

      void sessao
        .requestReferenceSpace('viewer')
        .then((espacoDoVisor: XRReferenceSpace): Promise<XRHitTestSource> | undefined =>
          // A lista de tipos de alvo fica de fora do pedido de propósito.
          // Restringir a planos recusaria a malha reconstruída que os aparelhos
          // mais recentes oferecem, e o pouso ficaria pior justamente no aparelho
          // que sabe mais sobre a sala.
          pedirFonte.call(sessao, { space: espacoDoVisor }),
        )
        .then((obtida: XRHitTestSource | undefined): void => {
          if (obtida === undefined) {
            motivo = 'a consulta não foi concedida por este aparelho';
            return;
          }
          fonte = obtida;
          motivo = 'nenhuma superfície encontrada sob a mira';
        })
        .catch((): void => {
          // A recusa é resposta, e não falha: o aparelho não tem a capacidade, ou
          // a sessão não a concedeu. O ambiente segue de pé sem ela, e o que muda
          // é que a bancada não pousa — o que precisa ser lido, não escondido.
          motivo = 'a consulta não foi concedida por este aparelho';
        });
    },

    atualizar(contexto: QuadroDaSessao | undefined): void {
      if (contexto === undefined || fonte === undefined) {
        anel.visible = false;
        ultimo = undefined;
        return;
      }

      const respostas: XRHitTestResult[] = contexto.quadro.getHitTestResults(fonte);
      if (respostas.length === 0) {
        // Sem superfície sob a mira. Acontece o tempo todo em uso real — apontar
        // para o vão de uma porta, para uma parede de vidro, para o teto —, e o
        // retículo apagar é a resposta certa. Deixá-lo aceso na última posição
        // conhecida faria alguém pousar a bancada onde nada foi encontrado.
        anel.visible = false;
        ultimo = undefined;
        motivo = 'nenhuma superfície encontrada sob a mira';
        quadrosSemResposta += 1;
        return;
      }

      // A primeira resposta é a mais próxima da origem do raio, e é a que
      // interessa: a mesa antes da parede atrás dela.
      const primeira: XRHitTestResult = respostas[0];
      const pose: XRPose | undefined = primeira.getPose(contexto.espaco);
      if (pose === undefined) {
        anel.visible = false;
        ultimo = undefined;
        quadrosSemResposta += 1;
        return;
      }

      ultimo = { resultado: primeira, matriz: pose.transform.matrix };
      assentarNoEspacoLocal(pose.transform.matrix, raiz, anel);
      anel.visible = true;
      quadrosComResposta += 1;
    },

    acerto(): AcertoDeSuperficie | undefined {
      return ultimo;
    },

    esquecer(): void {
      fonte?.cancel();
      fonte = undefined;
      pedida = false;
      ultimo = undefined;
      anel.visible = false;
      motivo = 'fora de sessão';
      quadrosComResposta = 0;
      quadrosSemResposta = 0;
    },

    linhas(): string[] {
      const total: number = quadrosComResposta + quadrosSemResposta;
      const proporcao: string =
        total === 0
          ? 'ainda não houve quadro consultado'
          : `${((quadrosComResposta / total) * 100).toFixed(0)}% dos ${total} quadros consultados`;
      return [
        `Consulta de interseção: ${fonte === undefined ? 'indisponível' : 'ativa'}.`,
        fonte === undefined
          ? `Motivo: ${motivo}.`
          : `Superfície sob a mira agora: ${ultimo === undefined ? 'nenhuma' : 'sim'}.`,
        `Encontrou superfície em ${proporcao}.`,
        'O raio sai do meio do campo de visão e acompanha a cabeça: quem escolhe onde a bancada ' +
          'pousa é quem olha.',
        'A proporção acima é medida da SALA, e não do código. Sobre a mesa lisa e sob luz fraca ' +
          'ela cai, e é isso que se leva para o relatório do ensaio.',
      ];
    },
  };
}

A fonte é pedida contra o espaço do visor, e não contra o espaço de trabalho. O raio sai do meio do campo de visão e acompanha a cabeça, de modo que quem escolhe onde a bancada pousa é quem olha. Pedir contra o espaço de trabalho daria um raio parado, apontando sempre para o mesmo canto — e a demonstração pareceria funcionar até alguém virar a cabeça.

A tentação de melhorar a resposta é o esvaziamento inteiro do módulo. Um plano suposto na altura do chão acerta na sala em que se testou e erra em qualquer outra, e o código que o contém parece código de realidade aumentada. A regra que adotamos é simples: a resposta é da plataforma, e o que ela não encontra não se inventa.

O retículo apaga quando nada é encontrado, e isso acontece o tempo todo em uso real. Apontar para o vão de uma porta, para uma parede de vidro, para o teto. Deixá-lo aceso na última posição conhecida faria alguém pousar a bancada onde superfície nenhuma existe.

1.4.2 O espaço em que a resposta vem escrita

Toda pose que a sessão devolve neste módulo vem em coordenadas do espaço de referência. Tudo o que a Bancada desenha vive dentro da raiz da cena, que tem transformação própria — o módulo de escala corporal desce o mundo inteiro quando o aparelho concede a altura da cabeça em vez do piso.

Somar as duas coisas sem converter produz o defeito mais caro daqui, e ele não acusa erro em lugar nenhum. A bancada pousa sobre a mesa num aparelho e meio metro abaixo dela no aparelho ao lado, que é justamente o que concedeu menos.

Quem vê conclui que o registro é impreciso. O registro está correto: errado é o espaço em que a resposta foi escrita.

src/bancada/anchoring/espaco.ts
// ---------------------------------------------------------------------------
// A ponte entre dois espaços que o percurso manteve separados até aqui.
//
// Tudo o que a sessão devolve neste módulo — a pose de uma superfície real, a
// pose de uma âncora, a pose da cabeça — vem em coordenadas do ESPAÇO DE
// REFERÊNCIA, que é o mesmo em que a câmera do visor é colocada. Tudo o que a
// Bancada desenha vive dentro da raiz da cena, que tem transformação própria: o
// módulo de escala corporal desce o mundo inteiro quando o aparelho concede a
// altura da cabeça em vez do piso real, e essa descida está guardada ali.
//
// Somar as duas coisas sem converter produz o defeito mais caro deste módulo, e
// ele não acusa erro em lugar nenhum: a bancada pousa na mesa em um aparelho e
// meio metro abaixo dela no aparelho ao lado, que é justamente o que concedeu
// menos. Quem vê conclui que o registro é impreciso, e o registro está correto —
// o que está errado é o espaço em que a resposta foi escrita.
//
// A conta é uma multiplicação de matrizes e cabe em três linhas. O que ela custa
// é a lembrança de fazê-la, e é por isso que ela mora em arquivo próprio, com
// nome que diz o que faz, em vez de repetida dentro de cada um dos três módulos
// que precisam dela.
// ---------------------------------------------------------------------------

import { Matrix4, Object3D } from 'three';

/**
 * O que um quadro de sessão entrega, reunido antes de ser distribuído.
 *
 * Os três módulos de ancoragem precisam exatamente destas três coisas, e nenhum
 * deles tem como obtê-las sozinho: o quadro chega pelo laço, o espaço vem do
 * renderizador e a sessão vem do ciclo. Montar o trio uma vez por quadro, na
 * composição, evita que cada módulo repita a mesma sequência de três verificações
 * de ausência — e evita, sobretudo, que um deles a esqueça.
 */
export interface QuadroDaSessao {
  readonly quadro: XRFrame;
  readonly espaco: XRReferenceSpace;
  readonly sessao: XRSession;
}

// Temporários de conversão. A conversão acontece algumas vezes por quadro, e
// alocar matriz nova a cada uma põe trabalho no coletor de lixo dentro do laço
// que menos pode engasgar.
const doEspacoDeReferencia: Matrix4 = new Matrix4();
const inversaDaRaiz: Matrix4 = new Matrix4();
const noEspacoLocal: Matrix4 = new Matrix4();

/**
 * Escreve, em um nó da cena, a pose que a sessão devolveu.
 *
 * A matriz de entrada está no espaço de referência; o nó de destino é filho da
 * raiz, logo lê a transformação dela. A raiz tem a sua matriz de mundo atualizada
 * antes da leitura porque a travessia que a atualiza acontece na renderização, e
 * este código roda antes dela: sem esta linha, o primeiro quadro depois de um
 * assentamento usaria a transformação anterior.
 */
export function assentarNoEspacoLocal(
  matriz: Float32Array,
  raiz: Object3D,
  destino: Object3D,
): void {
  doEspacoDeReferencia.fromArray(matriz);
  assentarMatrizNoEspacoLocal(doEspacoDeReferencia, raiz, destino);
}

/**
 * A mesma conversão, com a matriz já montada.
 *
 * O registro por marcador chega ao arquivo por esta porta, e a diferença entre
 * as duas entradas é só a procedência da matriz: da sessão, no caso da âncora,
 * e da própria imagem da câmera, no caso do papel impresso. A conta de converter
 * para o espaço da raiz é idêntica, e duplicá-la em outro arquivo seria repetir
 * exatamente o esquecimento que este existe para impedir.
 */
export function assentarMatrizNoEspacoLocal(
  matriz: Matrix4,
  raiz: Object3D,
  destino: Object3D,
): void {
  if (matriz !== doEspacoDeReferencia) {
    doEspacoDeReferencia.copy(matriz);
  }
  raiz.updateWorldMatrix(true, false);
  inversaDaRaiz.copy(raiz.matrixWorld).invert();
  noEspacoLocal.multiplyMatrices(inversaDaRaiz, doEspacoDeReferencia);
  // A decomposição é o que permite ao nó continuar com atualização automática de
  // matriz. Escrever a matriz direto exigiria desligá-la, e um nó com atualização
  // desligada ignora em silêncio qualquer mudança de posição feita depois.
  noEspacoLocal.decompose(destino.position, destino.quaternion, destino.scale);
}

A conta é uma multiplicação de matrizes e cabe em três linhas. O que ela custa é a lembrança de fazê-la, e é por isso que ela mora em arquivo próprio, com nome que diz o que faz. Repetida dentro de cada módulo que precisa dela, ela seria esquecida em um deles.

Este arquivo carrega também o trio que um quadro de sessão entrega. Os três módulos de ancoragem precisam exatamente do quadro, do espaço e da sessão, e nenhum deles obtém as três coisas sozinho. Montar o trio uma vez, na composição, evita que cada um repita as três verificações de ausência — e evita, sobretudo, que um deles as esqueça.

1.4.3 Pôr num lugar, e ficar nesse lugar

A consulta de interseção responde uma vez, e a resposta envelhece. Ela diz onde a mesa estava segundo o mapa daquele quadro, e o mapa continua sendo corrigido depois — a cada canto reconhecido, a cada volta em torno da sala.

Uma bancada escrita com a resposta de um quadro e esquecida ali deriva junto com o erro do mapa antigo. A deriva é visível a olho nu, e é exatamente o teste que o enunciado pede: quem anda em volta da mesa vê a bancada escorregar sobre ela.

A âncora é o pedido para que a plataforma continue respondendo. Em vez de guardar coordenadas, guarda-se um compromisso: este ponto, aquele lugar do mapa. A cada quadro pergunta-se de novo onde ele está agora.

src/bancada/anchoring/ancora.ts
// ---------------------------------------------------------------------------
// A âncora: a diferença entre pôr a bancada num lugar e ela FICAR nesse lugar.
//
// A consulta de superfície responde uma vez, e a resposta envelhece. Ela diz
// onde a mesa estava segundo o mapa que o aparelho tinha naquele quadro, e esse
// mapa continua sendo corrigido depois — a cada canto reconhecido, a cada volta
// completa em torno da sala. Uma bancada escrita com a resposta de um quadro e
// esquecida ali deriva junto com o erro do mapa antigo, e a deriva é visível a
// olho nu: quem anda em volta da mesa vê a bancada escorregar sobre ela.
//
// A âncora é o pedido para que a plataforma CONTINUE respondendo. Em vez de
// guardar coordenadas, guarda-se um compromisso: este ponto, aquele lugar do
// mapa. A cada quadro pergunta-se de novo onde ele está agora, e a resposta muda
// sozinha quando o aparelho corrige o mapa. É a única coisa deste percurso em
// que a posição de um objeto do projeto é escrita por outra pessoa.
//
// O caminho de degradação está declarado e não é silencioso. Onde a âncora não
// existe — porque o aparelho não a concedeu —, a bancada é registrada uma vez e
// fica onde caiu. Isso funciona por alguns minutos e depois deriva, e a diferença
// entre os dois casos é exatamente o que se pede para o aluno observar em vez de
// ouvir: são os mesmos gestos, o mesmo código, e um objeto que fica contra um que
// escorrega.
// ---------------------------------------------------------------------------

import { Object3D, Vector3 } from 'three';

import { assentarNoEspacoLocal, type QuadroDaSessao } from './espaco';
import type { AcertoDeSuperficie } from './superficie';

/** Em que regime de registro a bancada está agora. */
export type EstadoDaAncora =
  | 'sem pouso' // ainda não se escolheu onde ela fica
  | 'ancorada' // a plataforma corrige a posição a cada quadro
  | 'registro estático' // pousou uma vez e ninguém mais corrige — vai derivar
  | 'âncora perdida'; // havia âncora, e o aparelho a descartou

export interface Ancoragem {
  /** Pousa a bancada no ponto que a consulta devolveu. Assíncrono por dentro. */
  pousar(acerto: AcertoDeSuperficie): void;
  atualizar(contexto: QuadroDaSessao | undefined): void;
  /** Suspende a aplicação das poses. Usado enquanto o rastreamento está perdido. */
  congelar(congelado: boolean): void;
  /** Desfaz o pouso e devolve a bancada ao lugar de origem. */
  soltar(): void;
  pousada(): boolean;
  estado(): EstadoDaAncora;
  linhas(): string[];
}

/**
 * Instala a ancoragem sobre a raiz da cena e o nó que pousa.
 *
 * Quem pousa é a bancada inteira, e não a cena: a cena é a raiz, e mover a raiz
 * moveria junto o retículo, o aviso de rastreamento e a própria origem contra a
 * qual tudo isto é medido. O nó que pousa é filho da raiz, e é essa separação que
 * mantém a conta legível.
 */
export function montarAncoragem(raiz: Object3D, pousa: Object3D): Ancoragem {
  const posicaoDeOrigem: Vector3 = pousa.position.clone();

  let ancora: XRAnchor | undefined = undefined;
  let estadoAtual: EstadoDaAncora = 'sem pouso';
  let congelado: boolean = false;
  let pedindo: boolean = false;
  let porQueEstatico: string = '';

  // A medida que interessa ao aluno: quanto o aparelho corrigiu desde o pouso.
  // Ela é a evidência de que a âncora não é enfeite — em uma volta em torno da
  // mesa, ela costuma passar de um centímetro, e essa é a distância que a
  // bancada teria escorregado sem ela.
  const posicaoDoPouso: Vector3 = new Vector3();
  const posicaoAgora: Vector3 = new Vector3();
  let correcaoAcumuladaM: number = 0;
  let quadrosSemPoseDaAncora: number = 0;

  function registrarEstatico(acerto: AcertoDeSuperficie, porQue: string): void {
    assentarNoEspacoLocal(acerto.matriz, raiz, pousa);
    posicaoDoPouso.copy(pousa.position);
    estadoAtual = 'registro estático';
    porQueEstatico = porQue;
  }

  return {
    pousar(acerto: AcertoDeSuperficie): void {
      if (pedindo) {
        return;
      }
      pedindo = true;
      correcaoAcumuladaM = 0;
      quadrosSemPoseDaAncora = 0;

      // A criação da âncora nasce do próprio resultado da consulta, e não de uma
      // pose recopiada por nós. A diferença importa: o resultado carrega a qual
      // parte do mapa aquele ponto pertence, e é esse vínculo que a âncora
      // preserva. Recriar a pose à mão produziria uma âncora presa a um ponto
      // solto do espaço, que é quase — e não exatamente — a mesma coisa.
      const criar: (() => Promise<XRAnchor>) | undefined = acerto.resultado.createAnchor;
      if (criar === undefined) {
        pedindo = false;
        registrarEstatico(
          acerto,
          'este aparelho não oferece âncoras, e o registro fica sem quem o corrija',
        );
        return;
      }

      // A pose é aplicada JÁ, antes de a âncora chegar. A criação é assíncrona e
      // leva alguns quadros; sem isto, a bancada ficaria no lugar antigo durante
      // esse tempo, e quem tocou a tela veria o gesto não responder.
      assentarNoEspacoLocal(acerto.matriz, raiz, pousa);
      posicaoDoPouso.copy(pousa.position);

      void criar
        .call(acerto.resultado)
        .then((criada: XRAnchor): void => {
          ancora = criada;
          estadoAtual = 'ancorada';
          porQueEstatico = '';
        })
        .catch((erro: unknown): void => {
          estadoAtual = 'registro estático';
          porQueEstatico =
            erro instanceof Error
              ? `a criação da âncora foi recusada: ${erro.message}`
              : 'a criação da âncora foi recusada, sem motivo informado';
        })
        .finally((): void => {
          pedindo = false;
        });
    },

    atualizar(contexto: QuadroDaSessao | undefined): void {
      if (contexto === undefined || ancora === undefined || congelado) {
        return;
      }

      // O conjunto de âncoras rastreadas é opcional na especificação. Quando ele
      // vem e a nossa não está lá, o aparelho descartou a âncora — costuma
      // acontecer quando o mapa é refeito depois de uma perda longa. Tratar isso
      // como "pose indisponível" faria a bancada esperar para sempre por uma
      // resposta que não vem mais.
      const rastreadas: XRAnchorSet | undefined = contexto.quadro.trackedAnchors;
      if (rastreadas !== undefined && !rastreadas.has(ancora)) {
        estadoAtual = 'âncora perdida';
        ancora = undefined;
        return;
      }

      const pose: XRPose | undefined = contexto.quadro.getPose(ancora.anchorSpace, contexto.espaco);
      if (pose === undefined) {
        // Ausência de pose em um quadro é normal e passageira: o aparelho ainda
        // não relocalizou aquele ponto. A bancada fica onde está, que é o
        // comportamento certo — corrigir para uma posição inventada seria pior
        // que não corrigir.
        quadrosSemPoseDaAncora += 1;
        return;
      }

      posicaoAgora.copy(pousa.position);
      assentarNoEspacoLocal(pose.transform.matrix, raiz, pousa);
      correcaoAcumuladaM += posicaoAgora.distanceTo(pousa.position);
      estadoAtual = 'ancorada';
    },

    congelar(valor: boolean): void {
      congelado = valor;
    },

    soltar(): void {
      ancora = undefined;
      estadoAtual = 'sem pouso';
      congelado = false;
      porQueEstatico = '';
      correcaoAcumuladaM = 0;
      quadrosSemPoseDaAncora = 0;
      pousa.position.copy(posicaoDeOrigem);
      pousa.quaternion.identity();
    },

    pousada(): boolean {
      return estadoAtual !== 'sem pouso';
    },

    estado(): EstadoDaAncora {
      return estadoAtual;
    },

    linhas(): string[] {
      const linhas: string[] = [`Registro da bancada: ${estadoAtual}.`];
      switch (estadoAtual) {
        case 'sem pouso':
          linhas.push(
            'Mire uma superfície e acione para pousar. Enquanto não houver pouso, a bancada ' +
              'está onde o modelador a deixou, e não onde a sala a admite.',
          );
          break;
        case 'ancorada':
          linhas.push(
            `A plataforma já corrigiu ${(correcaoAcumuladaM * 100).toFixed(1)} cm desde o pouso.`,
            'Esse número é o que a bancada teria escorregado sobre a mesa sem âncora nenhuma. ' +
              'Ande em volta dela e veja-o crescer.',
            `Quadros em que a âncora não teve pose: ${quadrosSemPoseDaAncora}.`,
          );
          break;
        case 'registro estático':
          linhas.push(
            `Por quê: ${porQueEstatico}.`,
            'A bancada foi escrita uma vez e ninguém mais a corrige. Ela vai derivar, e a deriva ' +
              'aparece em minutos — é a mesma demonstração, com o resultado que se quer contrastar.',
          );
          break;
        case 'âncora perdida':
          linhas.push(
            'O aparelho descartou a âncora, provavelmente ao refazer o mapa depois de uma perda ' +
              'longa. A bancada ficou onde estava; pousar de novo é o caminho, e é uma decisão de ' +
              'quem usa, não do ambiente.',
          );
          break;
      }
      return linhas;
    },
  };
}

Repare em quem escreve a posição da bancada. É a única coisa deste percurso inteiro cuja posição é escrita por outra pessoa, e o número que o arquivo acumula é a evidência disso: quantos centímetros a plataforma já corrigiu desde o pouso. Numa volta em torno da mesa, ele costuma passar de um centímetro.

Essa medida é o argumento da tarefa, e ela responde à pergunta que o aluno faz. A âncora parece burocracia até alguém ver o número crescer, porque ele é exatamente a distância que a bancada teria escorregado sem ela.

O caminho de degradação está escrito e não é silencioso. Onde a âncora não existe, porque o aparelho não a concedeu, a bancada é registrada uma vez e fica onde caiu. Funciona por alguns minutos e depois deriva — e a comparação entre os dois casos, com o mesmo código e os mesmos gestos, é a melhor aula do módulo.

A criação da âncora nasce do próprio resultado da consulta, e não de uma pose recopiada por nós. A diferença importa: o resultado carrega a qual parte do mapa aquele ponto pertence, e é esse vínculo que a âncora preserva. Recriar a pose à mão produz uma âncora presa a um ponto solto do espaço, que é quase a mesma coisa.

1.5 Tarefa 3: Definir o comportamento sob perda de rastreamento

O caso que a demonstração de laboratório nunca mostra, e que o uso real produz em minutos.

Enunciado da tarefa

Deriva e perda de referência acontecem em uso real, e o que o ambiente faz nesse instante é parte do projeto, não acidente a esconder.

O que fica pronto é o comportamento definido e visível para a perda e para a recuperação: o que se mostra, o que se congela, o que se pede a quem está usando. Ambiente que desaparece sem explicação transforma uma limitação conhecida da tecnologia em defeito aparente do projeto.

1.5.1 Três decisões, e as três têm uma alternativa pior

Quanto tempo leva, numa sala de aula, até alguém tapar a câmera com a mão? Menos do que uma demonstração inteira. Alguém apaga a luz. Alguém se vira de frente para uma parede branca sem textura, ou anda rápido demais. O cartógrafo perde o desenho da sala, e com ele a resposta para onde eu estou.

A plataforma já fez a parte dela: parou de entregar pose, e não mentiu. O que sobra é decisão de projeto, e ela é visível para qualquer pessoa que use o ambiente — o que a torna parte da entrega.

src/bancada/anchoring/rastreamento.ts
// ---------------------------------------------------------------------------
// O que o ambiente faz quando o aparelho deixa de saber onde está.
//
// Este arquivo trata do caso que a demonstração de laboratório nunca mostra e
// que o uso real produz em minutos: alguém tapa a câmera com a mão, apaga a luz,
// vira-se de frente para uma parede branca sem textura, ou simplesmente anda
// rápido demais. O cartógrafo perde o desenho da sala, e com ele a resposta para
// "onde eu estou".
//
// O que se decide aqui não é técnico, é de projeto. A plataforma já fez a parte
// dela: parou de entregar pose, e não mente. O que sobra é escolher o que se
// mostra, o que se congela e o que se pede a quem está usando — e essa escolha é
// visível para qualquer pessoa que use o ambiente, o que a torna parte da
// entrega, e não detalhe de implementação.
//
// Três decisões, e as três têm uma alternativa comum que é pior.
//
// CONGELA-SE, em vez de continuar. Sem pose, qualquer atualização da cena usaria
// a última posição conhecida como se fosse a atual — e a bancada nadaria pela
// sala enquanto a pessoa anda. Congelada, ela fica errada de um jeito honesto:
// parada, no lugar em que o registro ainda valia.
//
// AVISA-SE PRESO À CABEÇA, e esta é a única exceção do percurso à regra de que
// cartaz não se prende ao rosto. A regra existe porque texto grudado no campo de
// visão desconforta; a exceção existe porque, sem rastreamento, não há mundo onde
// pendurar o aviso — um cartaz preso à bancada, que acabou de deixar de estar no
// lugar certo, é exatamente o lugar onde ninguém vai ler.
//
// PEDE-SE UMA AÇÃO CONCRETA, e não paciência. "Rastreamento perdido" não diz o
// que fazer; "olhe em volta, para um canto com objetos" diz — e é o que de fato
// recupera, porque devolve ao aparelho a textura de que ele precisa para
// reconhecer onde está.
// ---------------------------------------------------------------------------

import { Object3D, PerspectiveCamera, Quaternion, Vector3 } from 'three';

import { montarPainel, type Painel } from '../ui/painel';
import type { QuadroDaSessao } from './espaco';

/** Em que situação está a informação de posição que sustenta o registro. */
export type EstadoDoRastreamento = 'rastreando' | 'perdido' | 'recuperado';

/** A que distância do rosto o aviso é posto, em metros. */
const DISTANCIA_DO_AVISO_M: number = 0.9;

/**
 * Fator de redução do cartaz.
 *
 * O painel do orçamento tem tamanho de cartaz de bancada, pensado para ser lido
 * a um metro e meio. A noventa centímetros do rosto, em tamanho cheio, ele ocupa
 * o campo de visão inteiro — e um aviso que tapa o mundo é uma segunda falha
 * empilhada sobre a primeira.
 */
const REDUCAO_DO_AVISO: number = 0.45;

/** Por quanto tempo o aviso de recuperação permanece, em segundos. */
const PERMANENCIA_DO_AVISO_S: number = 3;

export interface Rastreamento {
  atualizar(
    contexto: QuadroDaSessao | undefined,
    camera: PerspectiveCamera,
    decorrido: number,
  ): void;
  perdido(): boolean;
  estado(): EstadoDoRastreamento;
  /** Zera o estado. A sessão terminou, e o que se sabia dela não vale mais. */
  reiniciar(): void;
  linhas(): string[];
}

const frenteDaCabeca: Vector3 = new Vector3();
const posicaoDaCabeca: Vector3 = new Vector3();
const giroDaCabeca: Quaternion = new Quaternion();
const giroDaRaiz: Quaternion = new Quaternion();

export function montarRastreamento(raiz: Object3D): Rastreamento {
  const cartaz: Painel = montarPainel('Registro suspenso');
  cartaz.no.scale.setScalar(REDUCAO_DO_AVISO);
  cartaz.no.visible = false;
  raiz.add(cartaz.no);

  let estadoAtual: EstadoDoRastreamento = 'rastreando';
  let perdasNaSessao: number = 0;
  let instanteDaPerda: number = 0;
  let instanteDaRecuperacao: number = 0;
  let maiorPerdaS: number = 0;
  let ultimaCausa: string = 'nenhuma perda nesta sessão';

  function posicionarDiante(camera: PerspectiveCamera): void {
    // A posição vem da matriz de mundo da câmera, e não da posição declarada
    // dela: dentro da sessão quem escreve a câmera é o aparelho, e é a matriz que
    // carrega o resultado. Ler a posição declarada devolveria o ponto em que a
    // órbita a deixou, no desktop, antes de a sessão abrir.
    camera.getWorldPosition(posicaoDaCabeca);
    camera.getWorldDirection(frenteDaCabeca);
    cartaz.no.position
      .copy(posicaoDaCabeca)
      .addScaledVector(frenteDaCabeca, DISTANCIA_DO_AVISO_M);
    raiz.worldToLocal(cartaz.no.position);
    // A orientação também precisa sair do espaço de mundo para o espaço da raiz.
    // A raiz deste projeto não gira — o assentamento é só uma descida —, e por
    // isso a conta abaixo devolve hoje o mesmo que copiar o giro direto. Ela
    // está escrita mesmo assim: no dia em que alguém girar a raiz para alinhar a
    // bancada com uma parede, o cartaz continuaria de frente para o rosto por
    // acidente, e o defeito não teria sintoma até esse dia.
    camera.getWorldQuaternion(giroDaCabeca);
    raiz.getWorldQuaternion(giroDaRaiz);
    cartaz.no.quaternion.copy(giroDaRaiz.invert().multiply(giroDaCabeca));
  }

  return {
    atualizar(
      contexto: QuadroDaSessao | undefined,
      camera: PerspectiveCamera,
      decorrido: number,
    ): void {
      if (contexto === undefined) {
        cartaz.no.visible = false;
        estadoAtual = 'rastreando';
        return;
      }

      // Duas fontes de perda, e elas são diferentes. A pose ausente é o mapa
      // perdido — a câmera tapada, a parede lisa, o escuro. A visibilidade
      // diferente de visível é a sessão em segundo plano: quem tirou o visor da
      // cabeça, quem abriu o menu do sistema. As duas suspendem o registro, e a
      // segunda não é falha nenhuma; separá-las na causa evita ensinar que o
      // ambiente quebrou quando alguém apenas coçou a testa.
      const pose: XRViewerPose | undefined = contexto.quadro.getViewerPose(contexto.espaco);
      const emPrimeiroPlano: boolean = contexto.sessao.visibilityState === 'visible';
      const temRegistro: boolean = pose !== undefined && emPrimeiroPlano;

      if (!temRegistro && estadoAtual !== 'perdido') {
        estadoAtual = 'perdido';
        perdasNaSessao += 1;
        instanteDaPerda = decorrido;
        ultimaCausa =
          pose === undefined
            ? 'o aparelho parou de informar a pose da cabeça — mapa perdido'
            : `a sessão saiu do primeiro plano (${contexto.sessao.visibilityState})`;
      } else if (temRegistro && estadoAtual === 'perdido') {
        estadoAtual = 'recuperado';
        instanteDaRecuperacao = decorrido;
        maiorPerdaS = Math.max(maiorPerdaS, decorrido - instanteDaPerda);
      } else if (
        estadoAtual === 'recuperado' &&
        decorrido - instanteDaRecuperacao > PERMANENCIA_DO_AVISO_S
      ) {
        // O aviso de recuperação não some no instante em que o registro volta. Ele
        // permanece alguns segundos de propósito: quem estava olhando para o lado
        // no momento da perda precisa saber que a bancada esteve congelada, ou vai
        // atribuir ao próprio gesto o objeto que não respondeu.
        estadoAtual = 'rastreando';
      }

      if (estadoAtual === 'rastreando') {
        cartaz.no.visible = false;
        return;
      }

      posicionarDiante(camera);
      cartaz.no.visible = true;
      cartaz.atualizar(
        estadoAtual === 'perdido'
          ? [
              'O aparelho perdeu a',
              'referência do ambiente.',
              'A bancada está congelada',
              'onde o registro valia.',
              'Olhe em volta, para um',
              'canto com objetos.',
            ]
          : [
              'Referência recuperada.',
              'A bancada voltou a ser',
              'corrigida pela âncora.',
            ],
        decorrido,
      );
    },

    perdido(): boolean {
      return estadoAtual === 'perdido';
    },

    estado(): EstadoDoRastreamento {
      return estadoAtual;
    },

    reiniciar(): void {
      estadoAtual = 'rastreando';
      cartaz.no.visible = false;
      perdasNaSessao = 0;
      maiorPerdaS = 0;
      ultimaCausa = 'nenhuma perda nesta sessão';
    },

    linhas(): string[] {
      return [
        `Estado do registro: ${estadoAtual}.`,
        `Perdas nesta sessão: ${perdasNaSessao}. Mais longa: ${maiorPerdaS.toFixed(1)} s.`,
        `Última causa: ${ultimaCausa}.`,
        'Enquanto perdido, a manipulação e a locomoção ficam suspensas: comandar um mundo cuja ' +
          'posição não se conhece produz encaixe no lugar errado, e o erro sobrevive à recuperação.',
        'Provocação de bancada, e a única que não precisa de código: tape a câmera do aparelho com ' +
          'a mão por três segundos. É o mesmo caminho que a sala escura percorre sozinha.',
      ];
    },
  };
}

Congela-se, em vez de continuar. Sem pose, qualquer atualização usaria a última posição conhecida como se fosse a atual, e a bancada nadaria pela sala enquanto a pessoa anda. Congelada, ela fica errada de um jeito honesto: parada, no lugar em que o registro ainda valia.

Avisa-se preso à cabeça, e esta é a única exceção do percurso à regra que proibiu cartaz grudado no rosto. A regra existe porque texto no campo de visão desconforta. A exceção existe porque, sem rastreamento, não há mundo onde pendurar o aviso — e um cartaz preso à bancada, que acabou de deixar de estar no lugar certo, é onde ninguém vai lê-lo.

Pede-se uma ação concreta, e não paciência. “Rastreamento perdido” não diz o que fazer. “Olhe em volta, para um canto com objetos” diz, e é o que de fato recupera: devolve ao aparelho a textura de que ele precisa para reconhecer onde está.

O arquivo separa duas causas que se parecem e não são a mesma. A pose ausente é o mapa perdido. A sessão fora do primeiro plano é quem tirou o visor da cabeça ou abriu o menu do sistema — e isso não é falha nenhuma. Confundi-las ensinaria que o ambiente quebrou quando alguém apenas coçou a testa.

O aviso de recuperação não some no instante em que o registro volta. Ele permanece alguns segundos, de propósito. Quem estava olhando para o lado no momento da perda precisa saber que a bancada esteve congelada, ou vai atribuir ao próprio gesto o objeto que não respondeu.

1.6 A composição, e o que ela acrescentou

O recibo do módulo é a lista de arquivos que não mudaram, e ela é a mesma de três módulos atrás.

Quantos arquivos mudaram fora das pastas novas? Um: a composição. Ela registra o terceiro plugue em uma linha, monta os quatro objetos da ancoragem, liga o par de avisos do ciclo de sessão e acrescenta quatro chamadas no laço. Nenhuma delas está na camada de interação.

A ordem dessas chamadas dentro do quadro tem consequência, e ela é a mesma lição da locomoção. A ancoragem é atualizada antes de tudo o que lê posição, porque a âncora move a bancada e a mira mede distâncias contra peças que pendem dela. Atualizar depois deixaria a interação um quadro atrás do mundo real, e a peça escaparia da mão de quem caminha.

Uma regra de ambiente mora ali e em nenhum outro lugar: o primeiro acionamento em sessão aumentada pousa, e os seguintes manipulam. Enquanto a bancada não tem lugar na sala, escolher uma peça dela é escolher um objeto que não está em parte alguma. Depois de pousada, o mesmo gesto volta a ser o de sempre.

E o congelamento sob perda é implementado como um retorno antecipado do laço. O que ele custa é tudo o que deixa de rodar abaixo dele: seleção, agarre, encaixe e locomoção. O que continua rodando é o que não depende de posição — o eixo segue girando, a animação do ativo segue andando. Congelar a cena inteira faria a perda de referência parecer travamento do programa, que é a leitura errada e a mais assustadora das duas.

1.7 O que este módulo deixa pronto

Três formas de apontar, uma lógica só, e agora sobre uma mesa que existe.

A Bancada roda nos três regimes previstos desde o primeiro módulo. O aluno que abre a mesma URL no computador, no visor e no celular encontra o mesmo ambiente, e o painel de capacidades explica a diferença entre os três aparelhos sem que ninguém precise defendê-la.

E como se sabe que isto não é uma demonstração vazia? A verificação leva segundos e não pede instrumento nenhum. Pouse a bancada na mesa e ande em volta dela. Se ela acompanhar a tela, a câmera virou papel de parede; se ficar onde foi posta, há registro.

Fica pendente o registro por marcador impresso, que é o assunto do próximo trecho. Ele existe porque a consulta de interseção depende de uma capacidade que boa parte dos aparelhos da turma não tem — e um percurso em que só três pessoas conseguem executar realidade aumentada não é um percurso.