1 Realidade virtual não imersiva — 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

Escada rolante parada ainda é escada; ambiente que só existe com o visor no rosto não é nada sem ele.

Até aqui a Bancada tinha câmera fixa. A cena estava completa, o conteúdo estava medido, e ninguém conseguia olhar a oficina pelo outro lado. Este módulo fecha o buraco.

O que entra é pequeno em linhas e grande em consequência: a câmera passa a girar em volta da cena, e o regime que roda em qualquer máquina fica utilizável por quem nunca ouviu falar do projeto. É o piso de todas as demonstrações do percurso, inclusive da última.

Entra também a metade que quase nenhum projeto escreve: a lista do que este regime não faz, com a razão técnica de cada ausência e com a aferição de cada uma contra o ambiente em execução.

O código ocupa dois arquivos novos, um em core/ e outro em modes/. Nenhum arquivo anterior é reescrito, mas a composição muda em um ponto que merece registro: quem manda na câmera passa a ser a órbita, e não mais quem monta a cena.

Essa troca de dono tem uma consequência imediata dentro do próprio projeto. O botão que aproximava a câmera da prateleira, no módulo anterior, escrevia posição direto na câmera. Com a órbita instalada, aquele código continuaria compilando e o primeiro arrasto do cursor desfaria o efeito dele, sem aviso nenhum.

A correção foi transformar a aproximação em movimento da órbita: muda-se o alvo e o raio, e a órbita reescreve a posição. É a regra geral de quem instala um controlador de câmera — a partir dali, ninguém mais escreve posição de câmera fora dele.

1.2 A tentação de tratar este módulo como degrau

Os aparelhos mais capazes do laboratório são três, e a turma tem 40 estudantes.

O grupo que já ouviu falar em visor tende a entregar este módulo pela metade, contando com o regime imersivo para impressionar depois. O argumento contra isso é aritmético, antes de ser pedagógico.

Os visores são poucos e o parque de celulares é heterogêneo. O regime em janela é o único que sempre abre, em qualquer máquina da sala, sem permissão, sem certificado aceito e sem rodízio de equipamento. Toda demonstração do período passa por ele.

Há ainda a consequência de projeto. Este é o destino para onde tudo degrada adiante: quando o aparelho não entra em sessão imersiva, é aqui que a pessoa cai. Um destino de degradação construído pela metade transforma cada falha de hardware em tela preta.

E há a consequência de avaliação, que costuma ser a que convence. A demonstração final do percurso é assistida por quem não tem visor na mão, e o que essa pessoa vê é a janela. Um projeto excelente no aparelho e capenga na tela é lido como projeto capenga, porque a tela é o que se lê.

O instrumento que este módulo usa contra a entrega pela metade é a conferência cruzada, e não a nota: cada grupo abre o ambiente de outro e tenta usá-lo sem que ninguém explique nada. O relato de quem travou chega sem hierarquia e com evidência, e vale mais que qualquer observação vinda da tutoria.

1.3 Tarefa 1: Entregar o ambiente em janela

Azimute, elevação e raio — e a câmera em órbita inteira sai desses três.

Enunciado da tarefa

O regime que roda em qualquer máquina fecha aqui, e fecha utilizável. Câmera em órbita controlada pelo cursor, cena completa, custo dentro do teto medido, sem exigir equipamento algum além do navegador.

O que fica pronto é um caminho de navegação inteiro, percorrido por tela: quem abre o endereço enxerga a cena, gira em volta dela, aproxima, afasta e alcança todos os objetos do domínio. Este regime é o destino para onde tudo degrada adiante, e por isso precisa bastar-se antes de qualquer outro existir.

src/bancada/core/orbita.ts
// ---------------------------------------------------------------------------
// A câmera em órbita: o único jeito de percorrer a cena que não exige aparelho.
//
// A conta inteira deste arquivo cabe em três números. Azimute é o quanto se
// girou em volta do alvo; elevação é o quanto se subiu ou desceu em relação a
// ele; raio é a distância até ele. Do trio saem as coordenadas cartesianas da
// câmera, e é só isso que a órbita é.
//
// Escrever isso à mão, em vez de usar o controlador pronto da biblioteca, tem
// motivo didático declarado: a habilidade que este módulo cobra é implementar a
// órbita, e arrastar um auxiliar pronto entrega o comportamento sem entregar a
// conta. Fora da sala, num projeto de produção, o controlador pronto seria a
// escolha certa — ele trata amortecimento, inércia e casos de borda que aqui
// ficam de fora de propósito.
//
// Dois cuidados que não são óbvios e que produzem defeito silencioso. A elevação
// é travada antes dos polos: passar de noventa graus inverte o sentido do
// arrasto, e quem usa acha que o ambiente enguiçou. E os eventos são de ponteiro,
// e não de mouse: a mesma função atende mouse, dedo e caneta, o que é o que
// permite ao regime em janela funcionar no celular sem uma segunda
// implementação.
//
// Cuidado de nomenclatura: este ponteiro é o do navegador, não a abstração de
// entrada do projeto. A abstração que unifica mouse, controle e dedo para a
// lógica de montagem chega adiante, e é outra coisa — esta órbita move a câmera,
// e não toca em objeto nenhum da cena.
// ---------------------------------------------------------------------------

import { Box3, MathUtils, PerspectiveCamera, Sphere, Vector3 } from 'three';

/** Limites de elevação, em graus. Travados antes dos polos, e não neles. */
const ELEVACAO_MINIMA: number = -80;
const ELEVACAO_MAXIMA: number = 85;

/** Limites de raio, em metros. O mínimo evita entrar dentro das peças. */
const RAIO_MINIMO: number = 0.35;
const RAIO_MAXIMO: number = 8;

/** Graus de giro por pixel arrastado. */
const SENSIBILIDADE: number = 0.35;

/** Fração do raio ganha ou perdida a cada passo da roda. */
const PASSO_DA_RODA: number = 0.12;

/** Folga sobre o raio calculado ao enquadrar, para a cena não encostar na borda. */
const FOLGA_DE_ENQUADRAMENTO: number = 1.25;

export interface EstadoDaOrbita {
  readonly azimuteGraus: number;
  readonly elevacaoGraus: number;
  readonly raioM: number;
  readonly alvo: Vector3;
}

export interface Orbita {
  /** Reposiciona o alvo e o raio de uma vez. Usado para levar a câmera a outro ponto da cena. */
  olharPara(alvo: Vector3, raio: number): void;
  /** Ajusta alvo e raio para que a caixa inteira caiba no campo de visão. */
  enquadrar(caixa: Box3): void;
  /**
   * Reescreve a câmera a partir do estado guardado, sem mudar o estado.
   *
   * Existe por causa da saída da sessão imersiva. Enquanto ela dura, 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, e a órbita continua achando que a deixou
   * olhando para a bancada. Sem esta chamada, sair do visor devolve um desktop
   * olhando para o teto, e o primeiro arrasto conserta sozinho — o que faz o
   * defeito parecer intermitente.
   */
  reaplicar(): void;
  estado(): EstadoDaOrbita;
  descrever(): string[];
  /** Solta os ouvintes do elemento. Sem isto, trocar de regime deixa a órbita ativa por baixo. */
  encerrar(): void;
}

/**
 * Instala a órbita sobre um elemento e uma câmera.
 *
 * A câmera é escrita a cada interação, e não a cada quadro. Recalcular a posição
 * em todo quadro custaria trigonometria em sessenta ou noventa quadros por
 * segundo para produzir o mesmo valor de antes na esmagadora maioria deles.
 */
export function montarOrbita(camera: PerspectiveCamera, elemento: HTMLElement): Orbita {
  const alvo: Vector3 = new Vector3(0, 0.95, 0);
  let azimute: number = 0;
  let elevacao: number = 12;
  let raio: number = 1.8;

  // recorte:inicio orbita-mapa-de-ponteiros
  // Ponteiros ativos, por identificador. O mapa existe por causa do celular: com
  // dois dedos na tela, a distância entre eles é o que controla o raio, e sem
  // guardar os dois não há como medir essa distância.
  const ponteiros: Map<number, { x: number; y: number }> = new Map();
  let distanciaEntreDedos: number = 0;
  // recorte:fim orbita-mapa-de-ponteiros

  // recorte:inicio orbita-a-conta-esferica
  function aplicar(): void {
    const a: number = MathUtils.degToRad(azimute);
    const e: number = MathUtils.degToRad(elevacao);
    const horizontal: number = raio * Math.cos(e);
    camera.position.set(
      alvo.x + horizontal * Math.sin(a),
      alvo.y + raio * Math.sin(e),
      alvo.z + horizontal * Math.cos(a),
    );
    camera.lookAt(alvo);
  }
  // recorte:fim orbita-a-conta-esferica

  // recorte:inicio orbita-elevacao-travada
  function girar(dx: number, dy: number): void {
    azimute -= dx * SENSIBILIDADE;
    elevacao = MathUtils.clamp(elevacao + dy * SENSIBILIDADE, ELEVACAO_MINIMA, ELEVACAO_MAXIMA);
    aplicar();
  }
  // recorte:fim orbita-elevacao-travada

  function aproximar(fator: number): void {
    raio = MathUtils.clamp(raio * fator, RAIO_MINIMO, RAIO_MAXIMO);
    aplicar();
  }

  // recorte:inicio orbita-captura-do-ponteiro
  function aoDescer(evento: PointerEvent): void {
    ponteiros.set(evento.pointerId, { x: evento.clientX, y: evento.clientY });
    // A captura mantém os eventos vindo para este elemento mesmo quando o cursor
    // sai dele durante o arrasto. Sem ela, arrastar para fora da tela deixa a
    // órbita presa girando, e o botão já foi solto sem ninguém saber.
    elemento.setPointerCapture(evento.pointerId);
  }
  // recorte:fim orbita-captura-do-ponteiro

  // recorte:inicio orbita-pinca-multiplica
  function aoMover(evento: PointerEvent): void {
    const anterior = ponteiros.get(evento.pointerId);
    if (anterior === undefined) {
      return;
    }
    const dx: number = evento.clientX - anterior.x;
    const dy: number = evento.clientY - anterior.y;
    ponteiros.set(evento.pointerId, { x: evento.clientX, y: evento.clientY });

    if (ponteiros.size >= 2) {
      const nova: number = distanciaAtual();
      if (distanciaEntreDedos > 0 && nova > 0) {
        aproximar(distanciaEntreDedos / nova);
      }
      distanciaEntreDedos = nova;
      return;
    }

    girar(dx, dy);
  }
  // recorte:fim orbita-pinca-multiplica

  function aoSubir(evento: PointerEvent): void {
    ponteiros.delete(evento.pointerId);
    distanciaEntreDedos = 0;
    if (elemento.hasPointerCapture(evento.pointerId)) {
      elemento.releasePointerCapture(evento.pointerId);
    }
  }

  function aoRodar(evento: WheelEvent): void {
    // Sem isto, a roda rola a página em vez de aproximar a câmera — e no
    // celular o mesmo problema aparece com o arrasto de um dedo.
    evento.preventDefault();
    aproximar(evento.deltaY > 0 ? 1 + PASSO_DA_RODA : 1 - PASSO_DA_RODA);
  }

  function distanciaAtual(): number {
    const lista = [...ponteiros.values()];
    const a = lista[0];
    const b = lista[1];
    if (a === undefined || b === undefined) {
      return 0;
    }
    return Math.hypot(a.x - b.x, a.y - b.y);
  }

  elemento.addEventListener('pointerdown', aoDescer);
  elemento.addEventListener('pointermove', aoMover);
  elemento.addEventListener('pointerup', aoSubir);
  elemento.addEventListener('pointercancel', aoSubir);
  elemento.addEventListener('wheel', aoRodar, { passive: false });
  // O toque prolongado abriria o menu do sistema no meio do arrasto.
  elemento.style.touchAction = 'none';

  aplicar();

  return {
    olharPara(novoAlvo: Vector3, novoRaio: number): void {
      alvo.copy(novoAlvo);
      raio = MathUtils.clamp(novoRaio, RAIO_MINIMO, RAIO_MAXIMO);
      aplicar();
    },
    /**
     * Enquadra a caixa inteira.
     *
     * O raio sai da esfera que envolve a caixa e da metade do campo de visão
     * vertical: a distância em que a esfera cabe na tela é o raio dela dividido
     * pelo seno desse meio-ângulo. É essa conta que garante a exigência da
     * tarefa — alcançar todos os objetos do domínio, e não só os que por acaso
     * estavam perto da posição inicial.
     */
    // recorte:inicio orbita-enquadrar-pela-esfera
    enquadrar(caixa: Box3): void {
      const esfera: Sphere = new Sphere();
      caixa.getBoundingSphere(esfera);
      alvo.copy(esfera.center);
      const meioCampo: number = MathUtils.degToRad(camera.fov / 2);
      const necessario: number = esfera.radius / Math.sin(meioCampo);
      raio = MathUtils.clamp(necessario * FOLGA_DE_ENQUADRAMENTO, RAIO_MINIMO, RAIO_MAXIMO);
      aplicar();
    },
    // recorte:fim orbita-enquadrar-pela-esfera
    reaplicar(): void {
      aplicar();
    },
    estado(): EstadoDaOrbita {
      return {
        azimuteGraus: azimute,
        elevacaoGraus: elevacao,
        raioM: raio,
        alvo: alvo.clone(),
      };
    },
    descrever(): string[] {
      return [
        `Alvo da órbita: ${alvo.x.toFixed(2)}, ${alvo.y.toFixed(2)}, ${alvo.z.toFixed(2)} m`,
        `Azimute: ${azimute.toFixed(0)} graus  ·  elevação: ${elevacao.toFixed(0)} graus`,
        `Distância ao alvo: ${raio.toFixed(2)} m ` +
          `(entre ${RAIO_MINIMO.toFixed(2)} e ${RAIO_MAXIMO.toFixed(0)})`,
        `Elevação travada entre ${ELEVACAO_MINIMA} e ${ELEVACAO_MAXIMA} graus, antes dos polos`,
      ];
    },
    encerrar(): void {
      elemento.removeEventListener('pointerdown', aoDescer);
      elemento.removeEventListener('pointermove', aoMover);
      elemento.removeEventListener('pointerup', aoSubir);
      elemento.removeEventListener('pointercancel', aoSubir);
      elemento.removeEventListener('wheel', aoRodar);
      ponteiros.clear();
    },
  };
}

1.3.1 A conta, que cabe em uma linha

Azimute é o quanto se girou em volta do alvo. Elevação é o quanto se subiu ou desceu em relação a ele. Raio é a distância até ele.

Do trio saem as coordenadas da câmera por duas funções trigonométricas, e não há mais nada. O componente horizontal é o raio vezes o cosseno da elevação; a altura é o raio vezes o seno dela; o azimute distribui o horizontal entre os eixos X e Z.

Confira o número com o material do módulo: com o alvo no tampo, elevação de 12 graus e raio de 1,8 metro, a câmera fica a 1,32 metro de altura e a 1,76 metro à frente. São contas que se refazem à mão em papel, e refazê-las em tutoria é a maneira mais rápida de encerrar a discussão sobre o que a órbita faz.

1.3.2 Por que não usar o controlador pronto

A biblioteca de renderização traz um controlador de órbita pronto, e ele é melhor que o nosso: trata amortecimento, inércia e casos de borda que aqui ficam de fora.

A escolha de escrever à mão é didática e está declarada no arquivo. A habilidade que este módulo cobra é implementar a órbita, e arrastar um auxiliar pronto entrega o comportamento sem entregar a conta. Fora da sala, num projeto de produção, a escolha seria a outra.

Repare no contraste com o módulo anterior. Lá a simplificação de malha foi entregue à ferramenta, e de propósito: reimplementar um algoritmo de colapso de arestas consumiria o período inteiro e não está na ementa. Aqui a conta é de três linhas e está na ementa. O critério é esse, e não preferência por escrever tudo.

1.3.3 A câmera se escreve na interação, não no quadro

Por que recalcular a posição da câmera só quando alguém arrasta?

Porque em noventa quadros por segundo a trigonometria produziria o mesmo valor de antes na esmagadora maioria deles. O orçamento do quadro já está declarado e medido desde o módulo anterior, e gasto sem retorno é a definição do que ele existe para impedir.

O efeito prático aparece no visor, e não no computador de mesa. A conta é barata, mas ela concorre com tudo o mais que o quadro precisa fazer, e o teto lá é de onze milissegundos.

1.3.4 Enquadrar tudo, e a esfera que decide a distância

A tarefa exige alcançar todos os objetos do domínio, e não os que por acaso nasceram diante da câmera.

O enquadramento resolve isso por medição. Toma-se a esfera que envolve a cena inteira, e a distância em que ela cabe na tela é o raio dela dividido pelo seno da metade do campo de visão vertical. Sobre esse valor entra uma folga para a cena não encostar na borda.

O número desta cena: a esfera envolvente tem cerca de 1,9 metro de raio e o campo é de 55 graus, o que dá 5,19 metros de distância. Um objeto fora dessa esfera é um objeto que a câmera nunca encontra, e o sintoma é a peça que ninguém acha e que todo mundo jura ter posicionado.

1.3.5 Servir o ambiente para quem não estava na sala

A tarefa não termina no código: termina em outra pessoa abrindo o endereço e usando o ambiente sem que ninguém explique nada.

A infraestrutura já existe desde o começo do percurso — o servidor escuta em todas as interfaces da rede, sobre canal seguro, e o endereço se abre no navegador de qualquer aparelho do laboratório. O que este módulo acrescenta é a exigência de que o resultado se explique sozinho.

Daí a instrução de uso na própria página, acima da cena, dizendo que se arrasta para girar e se usa a roda para aproximar. É uma linha de texto, e ela é a diferença entre a conferência cruzada produzir um relato sobre o ambiente e produzir um relato sobre não saber por onde começar.

1.3.6 Dois dedos, e o mapa de ponteiros ativos

Por que guardar os ponteiros num mapa, se o mouse tem um cursor só?

Por causa do celular, que é metade do parque desta disciplina. Com dois dedos na tela, o que controla a distância até o alvo é a distância entre eles, e medir essa distância exige ter guardado os dois. Com um dedo só, o gesto é o mesmo do arrasto do mouse e cai no mesmo caminho de código.

O mapa é indexado pelo identificador que o navegador atribui a cada ponteiro. Esse identificador é o que permite tratar o segundo dedo como segundo dedo, e não como um salto brusco do primeiro — que é exatamente o defeito de quem guarda uma posição só e recebe eventos de dois.

O gesto de pinça produz um fator, e não um deslocamento. A razão entre a distância anterior e a atual multiplica o raio, o que faz o gesto responder igual perto e longe. Somar centímetros em vez de multiplicar produz aproximação lenta a três metros e brusca a trinta centímetros.

1.3.7 Onde é fácil errar, e como conferir

Três defeitos aparecem sempre, e nenhum deles produz erro no console.

O primeiro é a elevação sem trava. Passar de noventa graus inverte o sentido do arrasto, e quem usa acha que o ambiente enguiçou. A trava aqui fica antes dos polos, e não neles.

O segundo é o arrasto que fica preso. Sem capturar o ponteiro, arrastar para fora do elemento deixa a órbita girando com o botão já solto. A conferência é sair da tela durante o arrasto e voltar.

O terceiro é o toque no celular. Sem desligar a ação de toque padrão, o navegador rola a página e abre menu de sistema no meio do gesto. A conferência não tem substituto: é abrir no aparelho.

O ponteiro daqui não é a abstração do projeto. Os eventos de ponteiro do navegador unificam mouse, dedo e caneta, e é isso que faz a órbita funcionar no celular sem uma segunda implementação. A abstração que unifica mouse, controle rastreado e toque para a lógica de montagem chega adiante e é outra coisa: esta órbita move a câmera e não toca em objeto nenhum da cena. Confundir as duas em aula produz um grupo inteiro tentando agarrar peça com o código da câmera.

1.4 Tarefa 2: Registrar o que este regime não oferece

Um limite escrito em documento à parte não se confronta com nada — e este é aferido em execução.

Enunciado da tarefa

Um regime bem construído é também um regime cujos limites quem o construiu sabe enunciar. Aqui não há escala corporal, não há alcance de braço, e a profundidade é inferida por perspectiva e oclusão em vez de ser vista com dois olhos.

A tarefa fecha com esses limites escritos e com o que eles impedem de demonstrar. A lista serve de contraste quando o mesmo ambiente ganhar corpo adiante, e impede que a versão em janela seja exibida depois no lugar de um regime imersivo que nunca foi construído.

src/bancada/modes/janela.ts
// ---------------------------------------------------------------------------
// O regime em janela, e a lista do que ele não faz.
//
// Este arquivo tem duas metades, e a segunda é a que costuma não existir em
// projeto nenhum. A primeira declara o que o regime entrega. A segunda declara o
// que ele NÃO entrega, item a item, com a razão técnica de cada ausência e com
// aquilo que a ausência impede de demonstrar.
//
// A segunda metade é escrita como dado, e não como parágrafo em documento à
// parte, pela mesma razão que levou a delimitação do domínio a ser escrita
// assim: limite em texto solto não se confronta com nada. Aqui cada limite
// carrega uma verificação, e a verificação é rodada contra o ambiente em
// execução — de modo que a afirmação "não há visão estereoscópica" deixa de ser
// promessa e passa a ser leitura.
//
// Escada rolante parada ainda é escada; ambiente que só existe com o visor no
// rosto não é nada sem ele. Este regime é o piso de todas as demonstrações do
// percurso, inclusive da última, e por isso fecha utilizável antes de qualquer
// outro existir.
// ---------------------------------------------------------------------------

import { Box3, Object3D, Vector3, WebGLRenderer } from 'three';

import { regimePorId, type Regime } from './regimes';

/** Como a verificação de um limite terminou. */
export type Afericao = 'confirmado' | 'contrariado' | 'sem-resposta';

// recorte:inicio limite-com-quatro-campos
export interface LimiteDoRegime {
  readonly id: string;
  /** O que falta, em uma frase que quem lê consegue conferir. */
  readonly enunciado: string;
  /** Por que falta — a razão técnica, e não a lamentação. */
  readonly porque: string;
  /** O que a ausência impede de demonstrar. É esta coluna que a tutoria cobra. */
  readonly impedeDemonstrar: string;
}
// recorte:fim limite-com-quatro-campos

/**
 * Os três limites do regime em janela.
 *
 * Nenhum deles é defeito de implementação: os três decorrem de o ambiente ser
 * exibido numa superfície plana, a uma distância que não é a do corpo de quem
 * olha. Corrigi-los dentro deste regime é impossível, e prometer corrigi-los é o
 * que produz a demonstração desonesta que o percurso adiante desmonta.
 */
export const LIMITES_DA_JANELA: readonly LimiteDoRegime[] = [
  {
    id: 'profundidade-inferida',
    enunciado: 'A profundidade é inferida, e não vista.',
    porque:
      'a tela entrega uma imagem só, para os dois olhos; o que sugere distância é ' +
      'perspectiva, oclusão e tamanho relativo, que são pistas de imagem e não de ' +
      'disparidade entre duas vistas',
    impedeDemonstrar:
      'julgar a distância entre duas peças próximas sem girar a câmera — e é ' +
      'justamente esse julgamento que o encaixe vai exigir adiante',
  },
  {
    id: 'sem-escala-corporal',
    enunciado: 'Não há escala corporal.',
    porque:
      'a bancada está a noventa centímetros do chão da cena, e esse número não tem ' +
      'contra o que ser comparado: quem olha está sentado diante de um monitor, e o ' +
      'ambiente não sabe a altura dos olhos de ninguém',
    impedeDemonstrar:
      'que a bancada está na altura certa para trabalhar de pé — a afirmação só se ' +
      'verifica quando o piso da cena coincidir com o piso da sala',
  },
  {
    id: 'sem-alcance-de-braco',
    enunciado: 'Não há alcance de braço.',
    porque:
      'o cursor alcança qualquer pixel da tela com o mesmo esforço, esteja o objeto ' +
      'a vinte centímetros ou a cinco metros dentro da cena',
    impedeDemonstrar:
      'que uma peça está longe demais para ser apanhada sem caminhar até ela, que é ' +
      'a restrição de onde nasce a necessidade de locomoção',
  },
];

export interface LimiteAferido {
  readonly limite: LimiteDoRegime;
  readonly afericao: Afericao;
  readonly observacao: string;
}

/**
 * Confronta cada limite declarado com o ambiente em execução.
 *
 * Dois dos três se aferem por leitura direta: a ausência de sessão imersiva e a
 * contagem de vistas que a placa está desenhando. O terceiro não tem leitura
 * possível de dentro do programa, e dizer isso é mais honesto do que arranjar um
 * indicador que apenas pareça responder.
 */
export function aferirLimites(renderer: WebGLRenderer): LimiteAferido[] {
  const emSessao: boolean = renderer.xr.isPresenting;
  const camadas: number = emSessao ? 2 : 1;

  return LIMITES_DA_JANELA.map((limite: LimiteDoRegime): LimiteAferido => {
    switch (limite.id) {
      case 'profundidade-inferida':
        return {
          limite,
          afericao: camadas === 1 ? 'confirmado' : 'contrariado',
          observacao:
            camadas === 1
              ? 'a placa está desenhando uma vista só: não há duas imagens para os ' +
                'dois olhos, logo não há disparidade de onde tirar profundidade'
              : `a placa está desenhando ${camadas} vistas, o que já não é o regime em janela`,
        };
      case 'sem-escala-corporal':
        return {
          limite,
          afericao: emSessao ? 'contrariado' : 'confirmado',
          observacao: emSessao
            ? 'há sessão imersiva ativa, e com ela chega o espaço de referência do chão'
            : 'não há sessão imersiva, logo não há espaço de referência ancorado no ' +
              'chão físico nem altura de olhos conhecida',
        };
      // recorte:inicio limite-sem-resposta
      default:
        return {
          limite,
          afericao: 'sem-resposta',
          observacao:
            'não há leitura possível de dentro do programa: o alcance do braço é uma ' +
            'propriedade de quem usa, e o cursor não tem corpo para medir',
        };
      // recorte:fim limite-sem-resposta
    }
  });
}

export interface EntregaDaJanela {
  readonly regime: Regime;
  readonly cenaEnquadrada: Box3;
  readonly objetosAlcancaveis: number;
}

/**
 * Mede o que o regime entrega: a caixa que envolve tudo o que existe na cena e
 * quantos objetos do domínio cabem dentro dela.
 *
 * A conta serve à exigência da tarefa — alcançar todos os objetos do domínio —,
 * e serve porque é ela que alimenta o enquadramento da órbita. Um objeto fora
 * desta caixa é um objeto que a câmera nunca vai encontrar, e o sintoma é a peça
 * que ninguém acha e que todo mundo jura ter posicionado.
 */
export function medirEntrega(raiz: Object3D, nomes: readonly string[]): EntregaDaJanela {
  const caixa: Box3 = new Box3().setFromObject(raiz);
  let alcancaveis: number = 0;
  for (const nome of nomes) {
    if (raiz.getObjectByName(nome) !== undefined) {
      alcancaveis += 1;
    }
  }
  return {
    regime: regimePorId('inline'),
    cenaEnquadrada: caixa,
    objetosAlcancaveis: alcancaveis,
  };
}

/** As linhas do regime, para a página e para a conferência cruzada entre grupos. */
export function linhasDaJanela(
  entrega: EntregaDaJanela,
  aferidos: readonly LimiteAferido[],
): string[] {
  const tamanho: Vector3 = entrega.cenaEnquadrada.getSize(new Vector3());
  const linhas: string[] = [
    `Regime: ${entrega.regime.nome} — ${entrega.regime.papel}`,
    `Registro contra: ${entrega.regime.registroContra}`,
    `Objetos do domínio alcançáveis pela órbita: ${entrega.objetosAlcancaveis}`,
    `Caixa que a órbita precisa enquadrar: ${tamanho.x.toFixed(2)} x ${tamanho.y.toFixed(2)} x ` +
      `${tamanho.z.toFixed(2)} m`,
    '',
  ];
  for (const aferido of aferidos) {
    linhas.push(
      `${aferido.limite.enunciado} [${aferido.afericao}]`,
      `  Por quê: ${aferido.limite.porque}.`,
      `  Impede demonstrar: ${aferido.limite.impedeDemonstrar}.`,
      `  Aferição: ${aferido.observacao}.`,
      '',
    );
  }
  return linhas;
}

1.4.1 Limite como dado, e não como parágrafo

A decisão de estrutura deste arquivo repete a que a delimitação do domínio tomou no primeiro módulo: o que precisa ser confrontado depois é escrito como dado tipado.

Cada limite carrega quatro campos. O enunciado, que quem lê consegue conferir. A razão técnica, que não é lamentação. O que a ausência impede de demonstrar — e essa é a coluna que a tutoria cobra. E, calculada em execução, a aferição.

Limite guardado num documento à parte envelhece bem demais: continua parecendo verdadeiro por anos depois de ter deixado de ser. Aqui, quando o ambiente ganhar sessão imersiva, duas das aferições mudam sozinhas de valor, e a mudança aparece na tela de quem abrir a página.

1.4.2 Os três limites, e de onde vem cada um

Nenhum dos três é defeito de implementação. Os três decorrem de o ambiente ser exibido numa superfície plana, a uma distância que não é a do corpo de quem olha.

A profundidade é inferida. A tela entrega uma imagem só para os dois olhos, e o que sugere distância é perspectiva, oclusão e tamanho relativo. Isso impede julgar a separação entre duas peças próximas sem girar a câmera, que é justamente o julgamento que o encaixe vai exigir adiante.

Não há escala corporal. A bancada está a 90 centímetros do chão da cena, e esse número não tem contra o que ser comparado: o ambiente não sabe a altura dos olhos de ninguém. Isso impede demonstrar que a bancada está na altura certa para trabalhar de pé.

Não há alcance de braço. O cursor alcança qualquer pixel com o mesmo esforço, esteja o objeto a vinte centímetros ou a cinco metros dentro da cena. Isso impede demonstrar que uma peça está longe demais para ser apanhada sem caminhar até ela — que é a restrição de onde nasce a necessidade de locomoção.

1.4.3 O que se afere, e o que se declara sem resposta

Dois dos três limites se aferem por leitura direta do ambiente.

A profundidade inferida se lê na contagem de vistas que a placa está desenhando: uma vista só significa que não há duas imagens para os dois olhos, logo não há disparidade de onde tirar profundidade. A ausência de escala corporal se lê na ausência de sessão imersiva, que é o que traria o espaço de referência ancorado no chão físico.

O terceiro não tem leitura possível de dentro do programa. O alcance do braço é propriedade de quem usa, e o cursor não tem corpo para medir. A aferição dele é sem-resposta, e dizer isso é mais honesto do que arranjar um indicador que apenas pareça responder.

Essa terceira posição é a que costuma sumir. Um relatório com duas colunas — atende e não atende — força tudo a caber numa das duas, e o que não cabe vira resposta inventada.

1.4.4 Onde é fácil errar, e como conferir

O erro previsível aqui é escrever a lista de limites e guardá-la no repositório.

A conferência cruzada deste módulo funciona porque outro grupo abre o endereço e tenta usar o ambiente sem instrução verbal. Limite que só existe num arquivo de projeto não alcança quem está usando. Por isso a lista sai na mesma página do ambiente, logo abaixo da cena.

A segunda conferência é de leitura, e ela é rápida: cada limite tem de dizer o que impede demonstrar. Um grupo que escreveu “não é imersivo” não escreveu limite nenhum — repetiu o nome do regime.

A terceira é de coerência, e vale a pena fazê-la em aula com a página aberta. Peça a aferição de cada limite e compare com o texto ao lado. Enquanto não houver sessão imersiva, os dois primeiros saem confirmado e o terceiro sai sem-resposta; qualquer outra combinação significa que a lista e o ambiente discordam, e a discordância é o achado.

1.5 O que este módulo deixa pronto

O primeiro bloco entregável do projeto fecha aqui, e ele fecha inteiro.

A Bancada agora é um ambiente que outra pessoa abre pelo endereço, percorre e usa sem instrução. Domínio delimitado, regimes declarados, sonda de capacidades, cena montada como árvore, geometria autorada, ativo externo ajustado, repetição instanciada, custo medido e navegação completa.

Vale registrar o que não entrou, porque a fronteira é a que a tutoria mais precisa defender. Não há seleção de peça, não há agarre e não há encaixe. A órbita move a câmera e nada mais; nenhum objeto da cena responde a clique. Um grupo que já implementou seleção aqui adiantou o módulo seguinte e quase sempre o adiantou errado, porque o fez com o código da câmera.

Também não entrou sessão imersiva. A sonda de capacidades continua respondendo o que o aparelho declara, e continua sem abrir sessão para trabalhar — o que ela faz desde o módulo de dispositivos. O visor ainda não recebeu a cena, e o marco em que ele a recebe é o próximo bloco do percurso.

O que sobrevive deste módulo para todos os seguintes é a lista de limites. A partir do próximo entra a camada de interação, e depois dela a sessão imersiva e a ancoragem — cada um desses passos apaga uma linha da lista, e é contra ela que o ganho vai ser medido. Sem a lista escrita agora, o regime em janela seria exibido adiante no lugar de um imersivo que ninguém construiu, e a diferença ninguém saberia nomear.