1 Tecnologias de desenvolvimento e o ciclo de sessã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

Mouse, controle e dedo são plugues diferentes na mesma tomada — a lógica de montagem só enxerga a tomada.

A frase acima foi escrita dois módulos atrás, quando havia uma fonte de entrada só. Ela era, naquele dia, uma promessa. Este é o módulo em que ela vira conta a pagar.

O ambiente ganha duas coisas grandes. Ganha o ciclo de sessão, que é o momento em que o programa deixa de mandar no próprio laço. E ganha o segundo plugue: o controle rastreado do visor apanha a mesma engrenagem que o cursor apanhava, com o mesmo código.

Entram três arquivos e mudam quatro. A conferência do módulo cabe numa linha: nenhum dos quatro pertence à camada de interação. Se um pertencesse, a economia dos dois módulos anteriores seria imaginária, e o lugar de descobrir isso é aqui.

Há ainda uma terceira entrega, e ela não desenha nada. É o registro de onde passa a fronteira entre o que o navegador resolve e o que este projeto escreveu — a página que se defende quando a pergunta chega.

1.2 O que muda quando a sessão assume

Três coisas deixam de ser do programa ao mesmo tempo, e a terceira é a que surpreende.

Quem pede o próximo quadro? Fora de sessão, a resposta é o programa. Dentro dela, é o aparelho: o visor tem cadência própria, e é a sessão que entrega o quadro quando a tela dele está pronta para receber.

O alvo de desenho também muda. Deixa de ser a superfície da página e passa a ser a composição que o visor consome, com uma imagem para cada olho e a correção da distorção das lentes por cima. Nada disso é escrito aqui, e é bom que não seja.

A terceira é a que costuma pegar de surpresa. A câmera deixa de ser um objeto que se posiciona e passa a ser a cabeça de uma pessoa. Escrever a posição dela é mentir sobre onde essa pessoa está, e o corpo de quem usa percebe a mentira antes de a cabeça entender.

1.2.1 O laço que não precisou mudar

Este módulo não acrescenta uma linha ao laço de renderização. A razão está escrita seis módulos atrás, no arquivo dele, e naquele dia parecia zelo excessivo.

O laço adotado desde então é o que troca de fonte sozinho quando a sessão começa. Custou a mesma linha que o laço da janela custaria e evitou o retrabalho clássico deste ponto do percurso: o ambiente inteiro escrito em torno do laço da página, funcionando no desktop, e congelado dentro do visor.

O sintoma desse retrabalho merece ser conhecido antes de acontecer. A página continua respondendo, os botões continuam funcionando, e só a imagem no visor congela. O diagnóstico aponta para todo lado menos para o laço, que é a peça que ninguém suspeita por ela estar funcionando à vista de todos.

1.3 Tarefa 1: Entrar e sair de sessão

Um ambiente perfeito servido por endereço sem cifra simplesmente não abre sessão, e o erro do navegador raramente diz isso.

Enunciado da tarefa

O ambiente aprende a entregar o controle. Entrar em sessão, negociar os recursos opcionais que a sonda encontrou, escolher o espaço de referência e devolver o controle ao sair: o laço de render deixa de ser regido pelo próprio código e passa a ser regido de fora.

O que fica pronto é o ciclo completo, com o requisito de contexto seguro atendido e com mensagem legível quando ele falta. Um ambiente perfeito servido por endereço sem cifra simplesmente não abre sessão, e o erro do navegador raramente diz isso com clareza.

src/bancada/modes/sessao.ts
// ---------------------------------------------------------------------------
// O ciclo de sessão: o módulo em que o ambiente entrega o controle.
//
// Até aqui o programa mandava em tudo. Ele pedia o próximo quadro, decidia o
// tamanho da imagem, escolhia de onde a câmera olhava. A partir daqui, dentro de
// uma sessão imersiva, nenhuma dessas três coisas é mais dele: a cadência vem do
// aparelho, o alvo de desenho é o do visor, e a câmera é a cabeça de uma pessoa.
//
// Três decisões deste arquivo pagam o percurso inteiro.
//
// A primeira é que o laço NÃO muda. Ele foi escrito, seis módulos atrás, sobre o
// laço que troca de fonte sozinho quando a sessão começa. Aquela linha custou
// nada naquele dia e é a razão de não haver, aqui, um segundo laço para o modo
// imersivo — que é o retrabalho clássico deste ponto do percurso.
//
// A segunda é que tudo o que se pede à plataforma é pedido como OPCIONAL. Um
// recurso obrigatório que o aparelho não tenha derruba a sessão inteira, e o
// sintoma que chega a quem usa é um botão que não faz nada. Pedindo como
// opcional, a sessão sobe e a ausência do recurso fica observável — que é a
// diferença entre "não funciona aqui" e "aqui falta a detecção de superfície".
//
// A terceira é o posto de trabalho. No regime em janela, quem se move é a
// câmera. No imersivo não há câmera a mover: há uma pessoa de pé, e escrever a
// posição dela é mentir sobre onde ela está. O que se move é a ORIGEM DO ESPAÇO
// DE REFERÊNCIA, e a conta do deslocamento está adiante, junto da constante.
// ---------------------------------------------------------------------------

import type { WebGLRenderer } from 'three';

import {
  RECURSOS_CONSULTADOS,
  estadoDoRecurso,
  type EstadoDeRecurso,
} from '../devices/recursos';

/** Os dois modos de sessão que este ambiente abre. */
export type ModoDeSessao = 'immersive-vr' | 'immersive-ar';

/**
 * Espaços de referência tentados, do mais desejável ao mínimo garantido.
 *
 * `local-floor` é o que faz a bancada nascer na altura certa, porque a origem
 * dele é o chão da sala física. `local` põe a origem na altura em que a cabeça
 * estava ao entrar, e a bancada aparece na cintura de quem é alto e no peito de
 * quem não é. `viewer` é o último recurso e mal merece o nome de espaço: a
 * origem é a própria cabeça, e a cena passa a acompanhá-la.
 */
const ESPACOS_PREFERIDOS: readonly XRReferenceSpaceType[] = ['local-floor', 'local', 'viewer'];

/**
 * A quantos metros à frente da origem da bancada a pessoa começa.
 *
 * O tampo tem oitenta centímetros de profundidade e está centrado na origem,
 * logo a borda da frente está a quarenta centímetros dela. Cinquenta e cinco
 * centímetros deixam quinze de folga entre a pessoa e a borda: é a distância de
 * quem TRABALHA na bancada, encostado nela.
 *
 * Este número já foi um metro e quinze, e a justificativa de então também era
 * boa — setenta e cinco centímetros de folga, a distância de quem CHEGA. O
 * critério mudou quando o alcance do braço entrou como restrição de projeto: de
 * um metro e quinze, nenhuma peça do tampo cabe dentro do braço, e a auditoria de
 * alcance acusa isso peça por peça. Os dois critérios mediam coisas diferentes, e
 * o conflito só podia aparecer aqui.
 *
 * Sem deslocamento algum a pessoa nasce DENTRO da bancada, com o tampo na altura
 * do peito e as peças atrás da cabeça. É o defeito mais previsível da primeira
 * entrada em sessão e o que menos parece defeito: no desktop está tudo certo.
 */
export const RECUO_DO_POSTO_M: number = 0.55;

export interface RecursoNegociado {
  readonly nome: string;
  readonly paraQueServe: string;
  readonly estado: EstadoDeRecurso;
}

/** O que a sessão entregou, depois de a negociação terminar. */
export interface SessaoAberta {
  readonly modo: ModoDeSessao;
  readonly espacoObtido: XRReferenceSpaceType;
  readonly espacosRecusados: readonly XRReferenceSpaceType[];
  readonly recursos: readonly RecursoNegociado[];
  readonly composicao: XREnvironmentBlendMode;
  /** Falso quando a origem não pôde ser deslocada, e a pessoa nasce na bancada. */
  readonly postoAplicado: boolean;
}

export interface CicloDeSessao {
  /** Abre a sessão, negocia o que dá para negociar e devolve o que ficou valendo. */
  entrar(modo: ModoDeSessao): Promise<SessaoAberta>;
  /** Encerra a sessão. Devolver o controle é parte do ciclo, não limpeza. */
  sair(): Promise<void>;
  ativa(): boolean;
  aberta(): SessaoAberta | undefined;
  /** Chamado depois de a sessão subir. */
  aoAbrir(ouvinte: (aberta: SessaoAberta) => void): void;
  /** Chamado quando a sessão termina — inclusive quando quem a encerra é o aparelho. */
  aoFechar(ouvinte: () => void): void;
  linhas(): string[];
}

/**
 * A mensagem de contexto inseguro, escrita para ser lida por quem está com o
 * visor no rosto.
 *
 * O navegador, nessa situação, não expõe a interface de realidade estendida — e
 * o erro que ele devolve não menciona o endereço em lugar nenhum. Quem lê conclui
 * que o aparelho não tem suporte, o que é falso sobre o aparelho e caro de
 * descobrir: a turma passa a tarde trocando de visor.
 */
function exigirAmbiente(): XRSystem {
  if (!window.isSecureContext) {
    throw new Error(
      'Esta página não está em contexto seguro, e por isso o navegador não expõe a ' +
        'interface de realidade estendida. A causa é o endereço, e não o aparelho: ' +
        'sirva por HTTPS e aceite o aviso do certificado neste aparelho antes de tentar de novo.',
    );
  }
  const xr: XRSystem | undefined = navigator.xr;
  if (xr === undefined) {
    throw new Error(
      'Este navegador não implementa a interface de realidade estendida. A página em ' +
        'janela continua inteira; o que não existe aqui é a sessão imersiva.',
    );
  }
  return xr;
}

/** O espaço que a sessão concedeu, e os que ela recusou antes dele. */
interface EscolhaDeEspaco {
  readonly obtido: XRReferenceSpaceType;
  readonly recusados: readonly XRReferenceSpaceType[];
}

/**
 * Tenta os espaços em ordem e devolve o primeiro concedido.
 *
 * A rejeição é a resposta, e não a falha: pedir um espaço que o aparelho não
 * oferece é como se pergunta a ele o que oferece. `viewer` é garantido pela
 * especificação em toda sessão, então a lista sempre termina com resposta.
 */
async function escolherEspaco(sessao: XRSession): Promise<EscolhaDeEspaco> {
  const recusados: XRReferenceSpaceType[] = [];
  for (const tipo of ESPACOS_PREFERIDOS) {
    try {
      await sessao.requestReferenceSpace(tipo);
      return { obtido: tipo, recusados };
    } catch {
      recusados.push(tipo);
    }
  }
  throw new Error(
    'A sessão abriu e não concedeu espaço de referência algum, nem o mínimo. ' +
      'Não há como registrar a cena contra coisa nenhuma, e insistir produziria uma ' +
      'bancada colada na cara de quem entrou.',
  );
}

/**
 * Desloca a origem do espaço de referência para o posto de trabalho.
 *
 * A conta do sinal é a que se erra, e ela é curta. O deslocamento recebe onde
 * fica a origem do espaço NOVO dentro do espaço atual; as poses lidas no espaço
 * novo saem, portanto, com o deslocamento invertido. Quem entra está na origem
 * do espaço atual, e queremos que apareça o recuo do posto ADIANTE da bancada,
 * isto é, em z positivo — logo a origem nova vai para z negativo do mesmo tanto.
 * A mesma conta volta no módulo de locomoção, e lá ela é composta a cada salto.
 *
 * Não se aplica a `viewer`: ali a origem é a cabeça, e deslocá-la produziria uma
 * bancada que foge quando a pessoa anda em direção a ela.
 */
function deslocarParaOPosto(renderer: WebGLRenderer, espaco: XRReferenceSpaceType): boolean {
  if (espaco === 'viewer') {
    return false;
  }
  const base: XRReferenceSpace | null = renderer.xr.getReferenceSpace();
  if (base === null) {
    return false;
  }
  const posto: XRReferenceSpace = base.getOffsetReferenceSpace(
    new XRRigidTransform({ x: 0, y: 0, z: -RECUO_DO_POSTO_M }),
  );
  renderer.xr.setReferenceSpace(posto);
  return true;
}

export function montarCicloDeSessao(renderer: WebGLRenderer): CicloDeSessao {
  const ouvintesDeAbertura: ((aberta: SessaoAberta) => void)[] = [];
  const ouvintesDeFechamento: (() => void)[] = [];

  let sessaoAtual: XRSession | undefined = undefined;
  let resumo: SessaoAberta | undefined = undefined;
  let ultimaFalha: string | undefined = undefined;

  function encerrada(): void {
    sessaoAtual = undefined;
    resumo = undefined;
    // A biblioteca deixa de desenhar para o visor, mas a câmera fica onde a
    // cabeça parou. Quem devolve a câmera ao lugar é a composição, e é por isso
    // que este aviso existe: sem ele, sair da sessão deixa o desktop olhando
    // para o teto.
    for (const ouvinte of ouvintesDeFechamento) {
      ouvinte();
    }
  }

  return {
    async entrar(modo: ModoDeSessao): Promise<SessaoAberta> {
      if (sessaoAtual !== undefined) {
        throw new Error('Já há uma sessão aberta. Encerre a atual antes de abrir outra.');
      }

      const xr: XRSystem = exigirAmbiente();

      // Tudo opcional, e a lista é a mesma que a sonda consulta. Pedir menos do
      // que se pode usar não economiza nada; pedir qualquer item como
      // obrigatório troca uma ausência observável por uma sessão que não abre.
      const sessao: XRSession = await xr.requestSession(modo, {
        optionalFeatures: RECURSOS_CONSULTADOS.map((recurso) => recurso.nome),
      });

      try {
        const escolha: EscolhaDeEspaco = await escolherEspaco(sessao);

        // A ordem destas duas linhas não é livre: o tipo precisa estar declarado
        // antes de a biblioteca assumir a sessão, porque é ela quem pede o
        // espaço ao entregar o primeiro quadro.
        renderer.xr.setReferenceSpaceType(escolha.obtido);
        renderer.xr.enabled = true;
        await renderer.xr.setSession(sessao);

        const postoAplicado: boolean = deslocarParaOPosto(renderer, escolha.obtido);
        const concedidos: readonly string[] | undefined = sessao.enabledFeatures;

        const aberta: SessaoAberta = {
          modo,
          espacoObtido: escolha.obtido,
          espacosRecusados: escolha.recusados,
          recursos: RECURSOS_CONSULTADOS.map((recurso) => ({
            nome: recurso.nome,
            paraQueServe: recurso.paraQueServe,
            estado: estadoDoRecurso(recurso.nome, concedidos),
          })),
          composicao: sessao.environmentBlendMode,
          postoAplicado,
        };

        // O fim da sessão não é sempre nosso: quem usa sai pelo menu do sistema,
        // o aparelho é tirado da cabeça, a bateria acaba. Ouvir o evento é o que
        // separa um ambiente que volta inteiro de um que fica preso no estado de
        // dentro da sessão sem estar mais nela.
        sessao.addEventListener('end', encerrada, { once: true });

        sessaoAtual = sessao;
        resumo = aberta;
        ultimaFalha = undefined;

        for (const ouvinte of ouvintesDeAbertura) {
          ouvinte(aberta);
        }
        return aberta;
      } catch (erro: unknown) {
        // A sessão subiu e a preparação falhou. Deixá-la viva prenderia o visor
        // numa tela vazia, e quem está com o aparelho no rosto só sai pelo menu
        // do sistema.
        ultimaFalha = erro instanceof Error ? erro.message : String(erro);
        await sessao.end();
        throw erro;
      }
    },

    async sair(): Promise<void> {
      if (sessaoAtual === undefined) {
        return;
      }
      await sessaoAtual.end();
    },

    ativa(): boolean {
      return sessaoAtual !== undefined;
    },

    aberta(): SessaoAberta | undefined {
      return resumo;
    },

    aoAbrir(ouvinte: (aberta: SessaoAberta) => void): void {
      ouvintesDeAbertura.push(ouvinte);
    },

    aoFechar(ouvinte: () => void): void {
      ouvintesDeFechamento.push(ouvinte);
    },

    linhas(): string[] {
      const atual: SessaoAberta | undefined = resumo;
      if (atual === undefined) {
        const estado: string =
          ultimaFalha === undefined
            ? 'Nenhuma sessão foi aberta ainda. O regime em janela não precisa de sessão alguma.'
            : `A última tentativa não abriu sessão: ${ultimaFalha}`;
        return [
          estado,
          `Contexto seguro: ${window.isSecureContext ? 'sim' : 'não — e sem ele não há interface de realidade estendida'}.`,
          `Interface de realidade estendida exposta: ${navigator.xr === undefined ? 'não' : 'sim'}.`,
        ];
      }

      const linhas: string[] = [
        `Sessão ${atual.modo}, composição informada ${atual.composicao}.`,
        `Espaço de referência obtido: ${atual.espacoObtido}.`,
        atual.espacosRecusados.length === 0
          ? 'Nenhum espaço mais exigente foi recusado: veio o primeiro que se pediu.'
          : `Recusados antes dele: ${atual.espacosRecusados.join(', ')}.`,
        atual.postoAplicado
          ? `Origem deslocada ${RECUO_DO_POSTO_M.toFixed(2)} m à frente da bancada: quem entra ` +
            'chega de pé diante do tampo, e não dentro dele.'
          : 'A origem NÃO foi deslocada, e quem entrar nasce sobre a bancada. É limite do ' +
            'espaço concedido, e está aqui para ser lido, não escondido.',
        '',
      ];
      for (const recurso of atual.recursos) {
        linhas.push(`${recurso.nome} [${recurso.estado}] — ${recurso.paraQueServe}.`);
      }
      return linhas;
    },
  };
}

O arquivo não conhece a cena, não conhece peça alguma e não decide nada sobre a montagem. Ele pede a sessão, negocia o que dá para negociar, e avisa quem quiser saber. Quem liga o aviso ao resto do ambiente é a composição, e são duas linhas.

1.3.1 A mensagem que nomeia o endereço

Fora de contexto seguro, o navegador não expõe a interface de realidade estendida. Não é que ela responda “não”; ela não existe, e o objeto que a carregaria vem indefinido.

Repare no que isso produz. O sintoma é idêntico ao de um aparelho sem suporte algum, e a mensagem de erro do navegador não menciona o endereço em lugar nenhum. Quem lê conclui que o visor não serve, troca de visor, e o segundo se comporta igual.

Por isso a mensagem escrita aqui nomeia a causa em vez do sintoma. Ela diz que o problema é o endereço, manda servir por HTTPS e lembra do aviso do certificado, que precisa ser aceito em cada aparelho. O tempo que essa frase economiza numa turma de 40 estudantes é a tarde inteira.

1.3.2 Tudo opcional, e a razão não é modéstia

Todo recurso vai no pedido como opcional. Nenhum vai como obrigatório, e essa decisão tem consequência visível.

Um recurso obrigatório que o aparelho não tenha derruba a sessão inteira. O que chega a quem usa, nesse caso, é um botão que não faz nada — e a diferença entre “não funciona aqui” e “aqui falta a detecção de superfície” é a diferença entre desistir e saber o que fazer.

Pedindo tudo como opcional, a sessão sobe e a ausência fica observável. O relatório então lista cada recurso como concedido, negado ou indeterminado, e o terceiro estado é o que mais evita erro. A sessão não é obrigada a reportar o que ligou, e um aparelho calado apareceria, sem ele, como aparelho que negou tudo.

1.3.3 O espaço de referência, do melhor ao mínimo

Onde fica a origem do mundo virtual? A pergunta parece técnica e o efeito dela é imediatamente físico.

Com origem no chão da sala, a bancada nasce na altura certa, porque a altura declarada dela passa a ser medida contra o mesmo piso em que a pessoa está de pé. Com origem na cabeça de quem entrou, a mesma bancada aparece na cintura de quem é alto e no peito de quem não é. O ambiente é o mesmo, e a experiência é outra.

A escolha é feita por tentativa, do mais exigente ao mínimo garantido, e a recusa é a resposta. Pedir um espaço que o aparelho não oferece é o modo de perguntar a ele o que oferece. O último da fila é garantido pela especificação, então a sequência sempre termina com resposta — e o relatório mostra qual veio e quais foram recusados antes dela.

1.3.4 O posto de trabalho, e por que se move o mundo

Quem entra pela primeira vez nasce dentro da bancada. O tampo fica na altura do peito, as peças ficam atrás da cabeça, e a primeira reação de todo grupo é achar que a cena está errada.

A cena não está errada. A origem do espaço de referência é o ponto em que a pessoa está, e a origem da bancada é o centro do tampo, então os dois coincidem. No desktop isso nunca apareceu porque lá a câmera nasce a um metro e meio de distância, posicionada por código.

E aqui está a decisão que separa este módulo dos anteriores. No regime em janela, quem se move é a câmera. No imersivo não há câmera a mover, e a saída é deslocar a origem do espaço de referência: a pessoa passa a estar a 1,15 m à frente da bancada, com folga de 75 cm até a borda do tampo. A conta do sinal está comentada no arquivo, porque é ela que se erra — o deslocamento informa onde fica a origem nova, e as poses saem com ele invertido.

1.3.5 A saída que nem sempre é nossa

Sair da sessão é parte do ciclo, e a metade dele que menos se testa.

O fim da sessão frequentemente não é pedido pelo programa. A pessoa sai pelo menu do sistema, tira o aparelho da cabeça, deixa a bateria acabar. Um ambiente que só reaja ao próprio botão fica preso no estado de dentro da sessão sem estar mais nela, e volta quebrado.

Há ainda um efeito que só se descobre saindo. Durante a sessão, quem escreve a câmera é a biblioteca, a partir da pose da cabeça; ao terminar, a câmera fica onde a cabeça parou. A órbita continua achando que a deixou olhando para a bancada, e o desktop volta olhando para o teto. O primeiro arrasto conserta sozinho, e é isso que faz o defeito parecer intermitente.

1.4 Tarefa 2: Ligar o segundo back-end à mesma abstração

A tarefa fecha quando a mesma peça é apanhada pelo raio do controle com o código que já a apanhava pelo cursor.

Enunciado da tarefa

O controle rastreado entra como mais uma implementação da abstração de apontar, e a lógica de seleção, agarre e encaixe permanece sem alteração. É aqui que a economia do capítulo anterior se paga ou se revela ilusória.

A tarefa fecha quando a mesma peça é apanhada pelo raio do controle exatamente com o código que já a apanhava pelo cursor. Qualquer ramificação por aparelho dentro da lógica de manipulação é sinal de que a abstração ficou pela metade.

src/bancada/input/controle.ts
// ---------------------------------------------------------------------------
// O segundo plugue: o controle rastreado.
//
// Este arquivo é a cobrança da aposta feita dois módulos atrás. A abstração de
// apontar foi desenhada quando havia uma fonte só, e o argumento para desenhá-la
// assim mesmo era este momento: ligar o controle sem tocar em nenhuma linha de
// seleção, de agarre ou de montagem.
//
// A conta se confere pela lista de arquivos alterados no módulo. Se a camada de
// interação aparecer nela, a economia era imaginária.
//
// Duas coisas mudam de verdade em relação ao cursor, e as duas são do aparelho,
// não do desenho.
//
// A pose passa a existir. O controle é um objeto rastreado no espaço, com
// posição e orientação, e é por isso que a peça apanhada por ele herda os seis
// graus de liberdade em vez de deslizar sobre uma esfera. O campo opcional da
// abstração deixa de estar vazio, e é a primeira vez no percurso em que isso
// acontece.
//
// E passam a existir DOIS ponteiros ao mesmo tempo. A camada de seleção lê o
// primeiro ponteiro ativo da lista, e a regra de qual deles manda foi anunciada
// lá como sendo escrita aqui: a lista sai ordenada pelo acionamento mais
// recente. Quem apertou o gatilho por último está na frente. Escrever essa
// regra na camada de cima faria a seleção passar a contar controles, que é
// exatamente o conhecimento que ela não pode ter.
// ---------------------------------------------------------------------------

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

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

/** Quantos controles a fonte acompanha. Dois é o baseline dos aparelhos da turma. */
const CONTROLES_ACOMPANHADOS: number = 2;

/**
 * O eixo do raio de mira, no espaço do próprio controle.
 *
 * A especificação define o raio saindo pelo eixo negativo de profundidade do
 * espaço de mira — a mesma convenção da câmera. Escrever o vetor aqui, com o
 * sinal à vista, evita o erro que faz a mira apontar para trás e que quem testa
 * atribui ao rastreamento do aparelho.
 */
const EIXO_DA_MIRA: Vector3 = new Vector3(0, 0, -1);

/** Estado de um controle entre um quadro e o seguinte. */
interface Plugue {
  readonly indice: number;
  readonly no: XRTargetRaySpace;
  conectado: boolean;
  lado: string;
  /** Gatilho apertado. Estado mantido, ao contrário do pulso do cursor. */
  acionado: boolean;
  /** Botão de pega, quando o aparelho o separa do gatilho. */
  apertando: boolean;
  /** Ordem de acionamento. Quem acionou por último tem o número maior. */
  ordem: number;
}

// Temporários de leitura. A leitura acontece uma vez por quadro e por controle,
// e alocar vetor novo a cada uma delas põe trabalho no coletor de lixo bem no
// laço que menos pode engasgar — no visor, engasgo é desconforto físico.
const posicao: Vector3 = new Vector3();
const orientacao: Quaternion = new Quaternion();
const direcao: Vector3 = new Vector3();

/**
 * Instala a fonte dos controles rastreados sobre o renderizador e a raiz da cena.
 *
 * Os nós precisam pertencer à árvore desenhada, e não por serem visíveis — eles
 * não desenham nada. É a travessia da cena que atualiza a matriz de mundo deles,
 * e um controle fora da árvore devolve pose parada no lugar em que nasceu.
 */
export function montarFonteDeControles(
  renderer: WebGLRenderer,
  raiz: Object3D,
): FonteDePonteiro {
  let contadorDeOrdem: number = 0;

  const plugues: Plugue[] = [];
  const desligadores: (() => void)[] = [];

  for (let indice: number = 0; indice < CONTROLES_ACOMPANHADOS; indice += 1) {
    const no: XRTargetRaySpace = renderer.xr.getController(indice);
    const plugue: Plugue = {
      indice,
      no,
      conectado: false,
      lado: 'desconhecido',
      acionado: false,
      apertando: false,
      ordem: 0,
    };

    function aoConectar(evento: { data: XRInputSource }): void {
      // A declaração do modo de raio entrou quando o terceiro plugue chegou.
      // Enquanto só havia controles, toda fonte de entrada da sessão era
      // controle e a verificação seria ruído; com o dedo na mesma sessão
      // aumentada, cada plugue passa a dizer o que atende — sem isso o mesmo
      // toque seria lido duas vezes, uma como dedo e outra como controle
      // rastreado que ninguém está segurando.
      if (evento.data.targetRayMode !== 'tracked-pointer') {
        return;
      }
      plugue.conectado = true;
      plugue.lado = evento.data.handedness;
    }

    function aoDesconectar(): void {
      // Controle que sai do rastreamento não larga a peça sozinho: quem decide
      // isso é a camada de manipulação, e ela decide pelo estado do agarre. O
      // que a fonte faz é parar de responder, e o ponteiro simplesmente some da
      // lista.
      plugue.conectado = false;
      plugue.acionado = false;
      plugue.apertando = false;
    }

    function aoAcionar(): void {
      plugue.acionado = true;
      contadorDeOrdem += 1;
      plugue.ordem = contadorDeOrdem;
    }

    function aoSoltar(): void {
      plugue.acionado = false;
    }

    function aoApertar(): void {
      plugue.apertando = true;
      contadorDeOrdem += 1;
      plugue.ordem = contadorDeOrdem;
    }

    function aoAfrouxar(): void {
      plugue.apertando = false;
    }

    no.addEventListener('connected', aoConectar);
    no.addEventListener('disconnected', aoDesconectar);
    no.addEventListener('selectstart', aoAcionar);
    no.addEventListener('selectend', aoSoltar);
    no.addEventListener('squeezestart', aoApertar);
    no.addEventListener('squeezeend', aoAfrouxar);

    desligadores.push((): void => {
      no.removeEventListener('connected', aoConectar);
      no.removeEventListener('disconnected', aoDesconectar);
      no.removeEventListener('selectstart', aoAcionar);
      no.removeEventListener('selectend', aoSoltar);
      no.removeEventListener('squeezestart', aoApertar);
      no.removeEventListener('squeezeend', aoAfrouxar);
      raiz.remove(no);
    });

    raiz.add(no);
    plugues.push(plugue);
  }

  function lerPlugue(plugue: Plugue): Ponteiro {
    plugue.no.getWorldPosition(posicao);
    plugue.no.getWorldQuaternion(orientacao);
    direcao.copy(EIXO_DA_MIRA).applyQuaternion(orientacao).normalize();

    return {
      id: `controle-${plugue.indice}-${plugue.lado}`,
      regime: 'imersivo',
      origem: posicao.clone(),
      direcao: direcao.clone(),
      // Estado mantido, e não pulso: o gatilho fica verdadeiro enquanto o dedo
      // aperta. A camada de seleção lê a borda de subida e produz uma escolha
      // só, exatamente como produz com o pulso do cursor — e é por isso que ela
      // não precisa saber qual dos dois está ligado.
      acionado: plugue.acionado,
      // A pega vem do gatilho mantido, e o botão de pega soma a ele onde o
      // aparelho o separa. Um aperto qualquer segura a peça, e não há gesto a
      // decorar por aparelho.
      agarre: plugue.acionado || plugue.apertando,
      estado: plugue.acionado ? 'acionado' : 'apontando',
      // O campo que estava vazio desde o módulo da abstração. Com ele, a peça
      // apanhada acompanha os seis graus de liberdade da mão.
      pose: {
        posicao: posicao.clone(),
        orientacao: orientacao.clone(),
      },
    };
  }

  return {
    nome: 'controles rastreados do visor',
    regime: 'imersivo',
    ler(): readonly Ponteiro[] {
      // Fora de sessão os nós existem e não são rastreados. Devolver a pose
      // deles aqui entregaria dois ponteiros parados na origem do mundo, e o
      // realce ficaria aceso em cima do que estivesse perto dela.
      if (!renderer.xr.isPresenting) {
        return [];
      }

      const ativos: Plugue[] = plugues.filter((plugue) => plugue.conectado);
      // O acionamento mais recente vai para a frente da lista. É esta linha, e
      // não um ramo na camada de interação, que responde a "com dois controles,
      // qual deles manda".
      ativos.sort((a: Plugue, b: Plugue) => b.ordem - a.ordem);
      return ativos.map(lerPlugue);
    },
    encerrar(): void {
      for (const desligar of desligadores) {
        desligar();
      }
      desligadores.length = 0;
      plugues.length = 0;
    },
  };
}

O arquivo traduz aparelho em raio de mundo, e nada mais. A estrutura dele é a mesma do cursor: instala ouvintes, guarda estado entre quadros, devolve uma leitura. Quem lê os dois lado a lado encontra a mesma forma com outro conteúdo, e é isso que a abstração deveria produzir.

1.4.1 O campo que estava vazio

A pose deixa de ser opcional na prática. Ela era, desde o módulo da abstração, um campo que a tabela declarava existir só no controle rastreado, e até agora nenhuma fonte o preenchia.

O efeito é imediato na camada que não mudou. Com pose, o nó portador do agarre passa a ser a mão de verdade, e a peça herda os seis graus de liberdade sem uma linha nova. Sem pose, ela continua deslizando sobre a esfera de raio fixo, como fazia no cursor.

Vale insistir num ponto que o módulo anterior já anunciou. As duas coisas não são equivalentes, e a tabela de campos existe justamente para dizer isso por escrito antes de alguém descobrir na demonstração. A tomada uniformiza a interface, e não os aparelhos.

1.4.2 Dois ponteiros, e quem manda

O visor traz dois controles. A camada de seleção lê o primeiro ponteiro ativo da lista, e essa regra foi escrita quando não havia dois de nada para exercitá-la.

Ela continua valendo sem alteração, porque a regra de qual controle manda foi escrita aqui, e não lá. A lista sai ordenada pelo acionamento mais recente: quem apertou o gatilho por último aparece na frente. Trocar de mão no meio da montagem funciona, e a seleção nunca soube que havia mão alguma.

A alternativa mediria mal o custo. Bastaria uma comparação na camada de seleção para resolver o caso hoje, e essa comparação obrigaria a camada a contar controles — o conhecimento que ela não pode ter. O terceiro plugue chegaria depois, com outra regra, e a comparação viraria uma cadeia.

1.4.3 O cursor que não sabe que saiu de cena

Há um defeito silencioso escondido aqui, e ele não aparece no desktop nem no visor isoladamente. Aparece nos dois ao mesmo tempo.

Com a sessão aberta, o cursor continua achando que está sobre a superfície de desenho. A última posição conhecida dele é de antes de a pessoa pôr o visor, e nada lhe disse que a cena mudou de lugar. Ele segue devolvendo um ponteiro ativo a cada quadro, e ele disputa a mira com o controle rastreado.

src/bancada/input/ponteiro.ts
// ---------------------------------------------------------------------------
// A abstração de apontar: o ponto de virada do projeto.
//
// Mouse, controle e dedo são plugues diferentes na mesma tomada — a lógica de
// montagem só enxerga a tomada. Este arquivo é a tomada, e ele existe para que
// nenhuma linha de seleção, agarre ou encaixe precise saber qual dos três está
// ligado do outro lado.
//
// A fronteira foi traçada onde os três regimes têm o que dizer em comum: um raio
// no espaço do mundo, um acionamento, um agarre e um estado. A pose de seis graus
// de liberdade fica de fora dessa lista comum e entra como campo opcional, porque
// ela existe no controle rastreado e não existe no cursor. Fingir que existe nos
// três produziria uma pose inventada para o mouse, e a mentira só apareceria no
// módulo em que a mão fecha sobre a peça a quarenta centímetros do rosto.
//
// O acionamento merece um parágrafo, porque é onde o desenho quase escorrega. No
// controle rastreado o gatilho é estado mantido: fica verdadeiro enquanto o dedo
// aperta. No cursor não dá para saber, no instante em que o botão desce, se
// aquilo vai virar clique ou arrasto de câmera — só a soltura responde. O campo
// continua sendo um booleano de estado, e o cursor o entrega como pulso de uma
// leitura; quem consome lê a borda de subida, e as duas formas cabem no mesmo
// campo sem que a camada de interação saiba da diferença.
// ---------------------------------------------------------------------------

import { Quaternion, Vector3 } from 'three';

/** O regime de entrada que uma fonte atende. Serve ao relatório, não à lógica. */
export type RegimeDeEntrada = 'janela' | 'imersivo' | 'aumentado';

/**
 * Em que situação o apontamento está.
 *
 * `inativo` é o ponteiro que existe mas não aponta para dentro da cena — o cursor
 * fora da superfície de desenho, o controle que o aparelho parou de rastrear.
 * Distinguir esse caso de `apontando` é o que impede o realce de ficar aceso em
 * cima de uma peça depois que o cursor saiu da tela.
 */
export type EstadoDoPonteiro = 'inativo' | 'apontando' | 'acionado';

/** Posição e orientação da mão ou do controle, quando o aparelho as fornece. */
export interface PoseDoPonteiro {
  readonly posicao: Vector3;
  readonly orientacao: Quaternion;
}

// recorte:inicio ponteiro-a-tomada
/**
 * Uma leitura de apontamento, no espaço do mundo.
 *
 * Os vetores são cópias, e não referências ao estado interno da fonte. Devolver a
 * referência economizaria alocação e criaria o defeito mais difícil de enxergar
 * desta camada: a leitura de um quadro mudando sozinha durante o quadro seguinte.
 */
export interface Ponteiro {
  /** Identificação estável da fonte, para o relatório e para depuração. */
  readonly id: string;
  readonly regime: RegimeDeEntrada;
  readonly origem: Vector3;
  /** Unitário, no espaço do mundo. */
  readonly direcao: Vector3;
  /** Gatilho, botão ou toque. Estado no controle; pulso de uma leitura no cursor. */
  readonly acionado: boolean;
  /** Agarre, onde o aparelho o separa do acionamento. Falso onde não existe. */
  readonly agarre: boolean;
  readonly estado: EstadoDoPonteiro;
  /** Ausente onde o aparelho não rastreia posição e orientação da mão. */
  readonly pose: PoseDoPonteiro | undefined;
}
// recorte:fim ponteiro-a-tomada

/**
 * O contrato que cada implementação de entrada cumpre.
 *
 * A leitura é feita uma vez por quadro e devolve os ponteiros ativos daquela
 * fonte — plural porque o visor tem dois controles e a tela tem dois dedos. A
 * camada de interação recebe a lista já unificada e não pergunta de onde veio
 * cada item.
 */
export interface FonteDePonteiro {
  readonly nome: string;
  readonly regime: RegimeDeEntrada;
  ler(): readonly Ponteiro[];
  /** Solta ouvintes e recursos. Trocar de regime sem isto deixa a fonte antiga viva. */
  encerrar(): void;
}

/**
 * O que cada campo do ponteiro carrega, quem o preenche e o que acontece onde
 * ele falta.
 *
 * A lista é dado tipado pela mesma razão que os limites do regime em janela são:
 * o que precisa ser confrontado depois não sobrevive como parágrafo. Quando o
 * controle rastreado chegar, é esta tabela que diz o que passa a estar preenchido
 * e o que continua vazio — e a diferença fica visível na página, e não na memória
 * de quem escreveu.
 */
export interface CampoDoPonteiro {
  readonly campo: string;
  readonly carrega: string;
  readonly preenchidoPor: string;
  readonly ausenciaSignifica: string;
}

export const CAMPOS_DO_PONTEIRO: readonly CampoDoPonteiro[] = [
  {
    campo: 'origem e direção',
    carrega: 'o raio no espaço do mundo, de onde parte e para onde vai',
    preenchidoPor: 'cursor, controle rastreado e toque',
    ausenciaSignifica: 'não há apontamento nenhum; é o único par obrigatório',
  },
  {
    campo: 'acionado',
    carrega: 'gatilho, botão ou toque — a intenção de agir sobre o que está sob a mira',
    preenchidoPor: 'cursor, controle rastreado e toque',
    ausenciaSignifica: 'a mira existe e ninguém pediu nada',
  },
  {
    campo: 'agarre',
    carrega: 'a pega, onde o aparelho a separa do acionamento',
    preenchidoPor:
      'controle rastreado, pelo gatilho mantido; cursor, derivando-a do clique, ' +
      'que alterna a pega em vez de mantê-la; toque, pelo dedo que permanece na tela',
    ausenciaSignifica: 'o regime terá de derivar a pega do próprio acionamento',
  },
  // recorte:inicio ponteiro-campo-pose
  {
    campo: 'pose',
    carrega: 'posição e orientação da mão, seis graus de liberdade',
    preenchidoPor: 'somente o controle rastreado',
    ausenciaSignifica: 'não há mão a que prender a peça; o regime move por raio e plano',
  },
  // recorte:fim ponteiro-campo-pose
];

/**
 * O conjunto de fontes ativas, lido como uma lista só.
 *
 * É aqui que a economia do projeto se materializa: a camada de interação recebe
 * uma lista de ponteiros e não tem como perguntar quantas fontes existem nem
 * quais são. Trocar o cursor pelo controle é registrar outra fonte, e nada mais.
 */
export interface Entrada {
  registrar(fonte: FonteDePonteiro): void;
  /**
   * Passa a ler somente as fontes deste regime.
   *
   * Acrescentado no módulo do ciclo de sessão, e a razão é concreta: com a
   * sessão aberta, o cursor continua achando que está sobre a superfície de
   * desenho, porque nada lhe disse o contrário — a última posição conhecida dele
   * é de antes de a pessoa pôr o visor. Sem esta linha, o ponteiro do mouse
   * disputa a mira com o controle rastreado dentro do visor, e quem perde é
   * quem está lá dentro.
   *
   * A escolha do lugar é a de sempre. A tomada aprende qual plugue está vivo; a
   * lógica de montagem continua sem ter como perguntar quantos existem.
   */
  emRegime(regime: RegimeDeEntrada): void;
  /**
   * Passa a ler as fontes de mais de um regime ao mesmo tempo.
   *
   * Acrescentado no módulo aumentado, e o caso que o obrigou é concreto: a
   * sessão sobre o mundo físico é 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.
   *
   * A alternativa seria a composição escolher, por aparelho, qual dos dois
   * regimes ativar — e aí a pergunta "este aparelho tem controle?" passaria a
   * ser feita acima da tomada, que é exatamente o conhecimento que ela existe
   * para conter.
   */
  emRegimes(regimes: readonly RegimeDeEntrada[]): void;
  regimesAtivos(): readonly RegimeDeEntrada[];
  ler(): readonly Ponteiro[];
  /** As fontes registradas, em texto, para o relatório. */
  descrever(): string[];
  encerrar(): void;
}

export function montarEntrada(): Entrada {
  const fontes: FonteDePonteiro[] = [];
  // O regime em janela é o estado inicial porque ele é o caso base do projeto:
  // a bancada sobe sem sessão alguma, e é assim que a maior parte da turma a
  // usa na maior parte do tempo.
  //
  // O estado é uma lista, e não um valor, desde o módulo aumentado: a sessão
  // sobre o mundo físico admite duas famílias de apontamento ao mesmo tempo. Na
  // esmagadora maioria dos quadros ela tem um elemento só.
  let regimes: readonly RegimeDeEntrada[] = ['janela'];

  return {
    registrar(fonte: FonteDePonteiro): void {
      fontes.push(fonte);
    },
    emRegime(novo: RegimeDeEntrada): void {
      regimes = [novo];
    },
    emRegimes(novos: readonly RegimeDeEntrada[]): void {
      regimes = [...novos];
    },
    regimesAtivos(): readonly RegimeDeEntrada[] {
      return regimes;
    },
    ler(): readonly Ponteiro[] {
      const leitura: Ponteiro[] = [];
      for (const fonte of fontes) {
        if (!regimes.includes(fonte.regime)) {
          continue;
        }
        leitura.push(...fonte.ler());
      }
      return leitura;
    },
    descrever(): string[] {
      if (fontes.length === 0) {
        return ['Nenhuma fonte de apontamento registrada.'];
      }
      return fontes.map(
        (fonte) =>
          `${fonte.nome} — regime ${fonte.regime}` +
          (regimes.includes(fonte.regime) ? ' (em uso agora)' : ' (registrada, em silêncio)'),
      );
    },
    encerrar(): void {
      for (const fonte of fontes) {
        fonte.encerrar();
      }
      fontes.length = 0;
    },
  };
}

/** Uma linha por campo, com o que ele carrega e o que a ausência dele significa. */
export function linhasDoPonteiro(): string[] {
  return CAMPOS_DO_PONTEIRO.map(
    (campo) =>
      `${campo.campo}: ${campo.carrega}. Preenchido por ${campo.preenchidoPor}. ` +
      `Ausente, ${campo.ausenciaSignifica}.`,
  );
}

/** Uma leitura em texto, para conferir a olho o que a fonte está entregando. */
export function descreverPonteiro(ponteiro: Ponteiro): string {
  const pose: string = ponteiro.pose === undefined ? 'sem pose' : 'com pose de seis graus';
  return (
    `${ponteiro.id} (${ponteiro.regime}): ${ponteiro.estado}, ` +
    `origem ${ponteiro.origem.x.toFixed(2)}, ${ponteiro.origem.y.toFixed(2)}, ` +
    `${ponteiro.origem.z.toFixed(2)} m · direção ${ponteiro.direcao.x.toFixed(2)}, ` +
    `${ponteiro.direcao.y.toFixed(2)}, ${ponteiro.direcao.z.toFixed(2)} · ${pose}`
  );
}

A correção é uma linha no conjunto de fontes, e a escolha do lugar é a de sempre. A tomada aprende qual plugue está vivo e passa a ler apenas as fontes do regime em uso. A lógica de montagem continua sem ter como perguntar quantas fontes existem, e o cursor não ganhou um teste sobre sessão dentro de si.

1.5 Tarefa 3: Desenhar a fronteira com a plataforma

A pergunta chega sempre na mesma forma: o que exatamente vocês implementaram?

Enunciado da tarefa

Parte do trabalho é feita pelo navegador e parte é escrita à mão, e confundir as duas é o que impede alguém de responder ao que exatamente o projeto implementou.

O que fica pronto é o registro dessa fronteira: o que a plataforma resolve, o que foi negociado com ela e o que o ambiente faz por conta própria. Essa página é a que se defende quando a pergunta chega.

src/bancada/modes/plataforma.ts
// ---------------------------------------------------------------------------
// A fronteira com a plataforma: o que o navegador resolve, o que foi negociado
// com ele e o que este projeto escreveu.
//
// Existe uma fronteira parecida escrita dois módulos atrás, e as duas não são a
// mesma. Aquela separa biblioteca de projeto — o que se instala do que se
// digita. Esta separa PLATAFORMA de projeto, e a diferença é que a plataforma
// não se instala, não se troca e não se lê: ela está no aparelho, e o que se faz
// com ela é pedir.
//
// A pergunta que este arquivo responde é a que chega na arguição, e ela é sempre
// a mesma: o que exatamente vocês implementaram? Quem não sabe traçar esta linha
// erra para os dois lados, e os dois erros custam. Superestimar leva a defender
// como conquista o rastreamento da cabeça, que veio pronto. Subestimar leva a
// tratar como detalhe a abstração de apontar, que é o trabalho de verdade.
//
// A terceira coluna é a que mais se esquece e a que mais vale. Negociado não é
// nem uma coisa nem outra: é o aparelho que decide, o projeto que pede, e a
// resposta que muda de aparelho para aparelho na mesma turma.
// ---------------------------------------------------------------------------

/** De quem é cada parte do que o ambiente faz. */
export type Responsavel =
  | 'plataforma' // vem do navegador e do aparelho; não se escreve nem se troca
  | 'negociado' // o projeto pede, o aparelho concede ou recusa
  | 'projeto'; // escrito aqui, linha por linha

export interface ItemDaPlataforma {
  readonly assunto: string;
  readonly responsavel: Responsavel;
  /** Como se comprova, no ambiente em execução, que a linha está no lugar certo. */
  readonly comoSeVerifica: string;
  /** O que se vê quando esta parte falta ou é recusada. */
  readonly quandoFalta: string;
}

/**
 * A fronteira, item a item.
 *
 * A ordem é a do fluxo de uma sessão: primeiro o que precisa existir antes de
 * qualquer pedido, depois o pedido, depois o quadro, e por último o que o
 * projeto faz com o quadro que recebeu.
 */
export const FRONTEIRA_COM_A_PLATAFORMA: readonly ItemDaPlataforma[] = [
  {
    assunto: 'expor a interface de realidade estendida à página',
    responsavel: 'plataforma',
    comoSeVerifica: 'a folha da sessão informa se a interface está exposta e se o contexto é seguro',
    quandoFalta:
      'não há sessão possível, e o sintoma é idêntico ao de um aparelho sem suporte — ' +
      'por isso a mensagem escrita aqui nomeia o endereço, e não o aparelho',
  },
  {
    assunto: 'exigir gesto de quem usa para abrir sessão imersiva',
    responsavel: 'plataforma',
    comoSeVerifica: 'a sessão só é pedida dentro do tratamento do toque no botão',
    quandoFalta:
      'o pedido feito ao carregar a página é recusado, e a recusa se parece com defeito do código',
  },
  {
    assunto: 'conceder ou recusar os recursos opcionais pedidos',
    responsavel: 'negociado',
    comoSeVerifica:
      'a folha da sessão lista cada recurso como concedido, negado ou indeterminado, ' +
      'a partir do que a própria sessão reportou',
    quandoFalta:
      'a sessão sobe sem o recurso e o ambiente segue de pé — que é o motivo de tudo ' +
      'ser pedido como opcional',
  },
  {
    assunto: 'conceder o espaço de referência com origem no chão da sala',
    responsavel: 'negociado',
    comoSeVerifica: 'a folha da sessão nomeia o espaço obtido e os que foram recusados antes dele',
    quandoFalta:
      'a origem passa a ser a altura em que a cabeça estava ao entrar, e a bancada nasce ' +
      'na cintura de quem é alto e no peito de quem não é',
  },
  {
    assunto: 'rastrear a pose da cabeça e a dos controles',
    responsavel: 'plataforma',
    comoSeVerifica: 'a folha do apontamento mostra pose preenchida enquanto a sessão está aberta',
    quandoFalta:
      'não há o que este projeto possa fazer: rastreamento é sensor e algoritmo do aparelho, ' +
      'e nenhuma linha daqui o substitui',
  },
  {
    assunto: 'compor a imagem para os dois olhos e corrigir a distorção das lentes',
    responsavel: 'plataforma',
    comoSeVerifica:
      'a aferição dos limites do regime em janela passa de uma vista para duas assim que ' +
      'a sessão abre, sem uma linha de projeção escrita aqui',
    quandoFalta:
      'nada: não existe caminho em que isso falte e a sessão exista — é a plataforma que ' +
      'define o que é uma sessão imersiva',
  },
  {
    assunto: 'entregar o próximo quadro na cadência do visor',
    responsavel: 'plataforma',
    comoSeVerifica:
      'o laço não mudou uma linha entre os dois regimes, e o painel continua medindo o custo do quadro',
    quandoFalta:
      'o ambiente congela dentro do visor com a página respondendo normalmente — o defeito ' +
      'que o laço escrito seis módulos atrás existe para não acontecer',
  },
  {
    assunto: 'deslocar a origem para o posto de trabalho diante da bancada',
    responsavel: 'projeto',
    comoSeVerifica: 'quem entra chega de pé diante do tampo, e não dentro dele',
    quandoFalta: 'a pessoa nasce sobre a bancada, com as peças atrás da cabeça',
  },
  {
    assunto: 'a abstração de apontar e as implementações que a alimentam',
    responsavel: 'projeto',
    comoSeVerifica:
      'a lista de arquivos alterados neste módulo não inclui nenhum da camada de interação',
    quandoFalta:
      'um caminho de código por aparelho, descoberto quando o terceiro chegar e já for tarde',
  },
  {
    assunto: 'decidir o que foi mirado, escolhido, apanhado e encaixado',
    responsavel: 'projeto',
    comoSeVerifica: 'a mesma peça é apanhada pelo raio do controle com o código que a apanhava pelo cursor',
    quandoFalta: 'a interação passa a ter o formato do exemplo da dependência, e não o da montagem',
  },
  {
    assunto: 'seguir de pé no aparelho que não entra em sessão alguma',
    responsavel: 'projeto',
    comoSeVerifica: 'a bancada é montável inteira no regime em janela, sem qualquer sessão aberta',
    quandoFalta:
      'o ambiente vira demonstração de visor, e a maior parte da turma assiste em vez de usar',
  },
];

/** Uma linha por item, com o responsável, a verificação e o custo da ausência. */
export function linhasDaPlataforma(): string[] {
  return FRONTEIRA_COM_A_PLATAFORMA.map(
    (item: ItemDaPlataforma) =>
      `${item.assunto} — ${item.responsavel}. ` +
      `Verifica-se assim: ${item.comoSeVerifica}. ` +
      `Faltando: ${item.quandoFalta}.`,
  );
}

/** Quantos itens cabem a cada responsável. É o resumo que a arguição pede primeiro. */
export function contagemDaFronteira(): string {
  const conta = (quem: Responsavel): number =>
    FRONTEIRA_COM_A_PLATAFORMA.filter((item) => item.responsavel === quem).length;
  return (
    `${conta('plataforma')} itens são da plataforma, ${conta('negociado')} são negociados com ela ` +
    `e ${conta('projeto')} foram escritos neste projeto.`
  );
}

Já existe uma fronteira escrita dois módulos atrás, e as duas não são a mesma. Aquela separa biblioteca de projeto — o que se instala do que se digita. Esta separa plataforma de projeto, e a diferença é que a plataforma não se instala nem se troca: ela está no aparelho, e o que se faz com ela é pedir.

1.5.1 Os dois erros, e o que cada um custa

Quem não sabe traçar a linha erra para os dois lados, e os dois erros aparecem na mesma banca.

Superestimar leva a defender como conquista o rastreamento da cabeça, que veio pronto no aparelho e não custou uma linha. Quem ouve percebe, e a partir dali passa a duvidar do resto — inclusive do que é mérito de verdade.

Subestimar custa o mesmo e é mais comum entre os grupos bons. Eles tratam como detalhe a abstração de apontar, porque ela não impressiona em vídeo, e entregam como trabalho o que a plataforma fez. É a decisão de arquitetura mais cara do percurso, e ela desaparece justamente por funcionar em silêncio.

1.5.2 A coluna que ninguém escreve

Três valores, e não dois. O terceiro é o que dá utilidade ao registro.

Negociado não é da plataforma nem do projeto: o projeto pede, o aparelho concede ou recusa, e a resposta muda de aparelho para aparelho na mesma turma. Espaço de referência com origem no chão é negociado. Detecção de superfície é negociada. Permissão para abrir a sessão é negociada com quem usa, e não com o aparelho.

Sem essa coluna, cada linha teria de escolher um lado errado. O grupo escreveria que a origem no chão é da plataforma, o que é falso no aparelho que a recusa, ou que é do projeto, o que é falso em qualquer aparelho.

1.5.3 Cada linha com a verificação ao lado

Cada item declara como se comprova, no ambiente em execução, que ele está do lado em que foi posto.

A razão é a mesma que fez os limites do regime em janela virarem dado tipado, e não parágrafo em documento à parte. Declaração que ninguém confronta envelhece bem demais: continua parecendo certa depois de ter deixado de ser. Aqui, a composição estereoscópica se verifica pela contagem de vistas que a placa desenha, e o laço se verifica pela lista de arquivos que este módulo alterou.

Escada rolante parada ainda é escada; ambiente que só existe com o visor no rosto não é nada sem ele. O último item da fronteira é esse, e ele é do projeto. A bancada continua montável inteira em janela, sem sessão alguma — o que, com três visores para a turma, não é virtude teórica.

1.6 A composição, e as duas linhas que ela acrescenta

O módulo inteiro entra no ambiente por duas linhas, e é assim que se sabe quanto ele custou.

O ciclo de sessão avisa quando abre e quando fecha. A composição escuta os dois avisos e faz, em cada um, uma coisa só.

Ao abrir, ela diz à tomada que o regime passou a ser o imersivo. Ao fechar, diz que voltou a ser o da janela e manda a órbita reescrever a câmera a partir do estado que ela sempre guardou. Nada mais muda: nem o laço, nem a seleção, nem o agarre, nem a montagem.

A fonte dos controles se registra ao lado da fonte do cursor, numa linha, exatamente onde o módulo anterior previu que ela se registraria. Ela fica em silêncio enquanto o regime for o da janela, e passa a falar quando a sessão abrir.

Vale registrar por que o teclado continua na composição. Girar por tecla foi o remendo do regime em janela, onde não há pose de mão; com o controle ligado, ele deixa de ser necessário e não é removido. Fica útil onde era útil e irrelevante onde a orientação vem da mão, que é a forma correta de um remendo envelhecer.

1.7 O que este módulo deixa pronto

A aposta dos dois módulos anteriores foi cobrada, e o recibo é a lista de arquivos que não mudaram.

A Bancada entra em sessão e sai dela. Negocia o que pode negociar, informa o que foi concedido e o que foi recusado, e diz por que não abriu quando não abre. Dentro do visor, a mesma engrenagem é apanhada pelo raio do controle, girada pela mão e encaixada pela máquina de estados que nunca soube da existência de um visor.

Vale registrar o que não entrou, porque a lista é grande e nada nela é pendência deste módulo. Não há locomoção: a pessoa alcança o que está ao alcance do braço, e caminhar pela sala física é o que existe até o módulo que trata disso. Não há conforto medido, não há vinheta, não há teleporte. A entrada em realidade aumentada abre a sessão e ainda não registra nada contra superfície real — o retículo, a âncora e o marcador impresso são trabalho adiante.

E há um limite honesto sobre a verificação. O que se confere no desktop e no emulador é o ciclo de sessão e a geometria da cena. Conforto, escala corporal e registro contra superfície física não se conferem ali, e tratá-los como verificados porque o emulador não reclamou seria o engano que o percurso inteiro tenta desmontar.