1 Realidade virtual imersiva: presença, escala e conforto — Projeto do Professor

Este é o projeto de referência resolvido pelo professor: a implementação que serve de modelo do que cada grupo deve produzir no Projeto Integrador. Ele existe para ser estudado, discutido e criticado — não para ser copiado. O que está aqui é uma solução defensável, com as decisões explicadas uma a uma; a solução de cada grupo será outra, e terá de ser defendida do mesmo jeito.

1.1 Visão geral

O arquivo da cena promete, desde o módulo do grafo, que a altura do tampo “vai ser cobrada de verdade no regime imersivo”. Este é o módulo da cobrança.

Uma unidade é um metro. Essa frase está escrita no código há sete módulos e, até agora, ninguém teve como conferi-la. No desktop não há contra o que comparar: a câmera se afasta o quanto for preciso, e uma bancada de nove metros parece do tamanho certo desde que nada mais na cena a contradiga.

No visor há contra o que comparar, e é a pessoa. O erro de escala aparece inteiro no primeiro segundo, e a queixa que chega nunca é técnica — é que a bancada está no peito, ou que a engrenagem tem o tamanho de um prato.

Entram três arquivos e muda um número. O número é o mais interessante dos quatro: ele estava certo no módulo anterior, pelo critério daquele módulo, e passou a estar errado neste sem que ninguém tenha errado nada.

1.2 O que a janela não tinha como cobrar

Três defeitos convivem em silêncio com o regime não imersivo, e não por descuido de quem o escreveu.

Escala arbitrária é o primeiro. Uma cena construída em unidades sem compromisso funciona perfeitamente em janela, porque a única referência de tamanho é a própria cena. Ela é internamente coerente, e coerência interna é tudo o que a órbita exige.

Alcance de braço é o segundo, e ele nem existia como conceito até aqui. Na janela, a distância até uma peça é um número da câmera: se está longe, aproxima-se. Não há ombro, não há cotovelo, e nada no ambiente é inalcançável.

Conforto é o terceiro, e é o único dos três que machuca. Quadro perdido na janela produz uma animação menos lisa; quadro perdido no visor produz mal-estar físico em quem está com o aparelho no rosto. O mesmo defeito, o mesmo código, e consequências que não se comparam.

1.3 Tarefa 1: Dar corpo ao mundo

Erros de escala aparecem todos de uma vez, e é essa a primeira prova de que a cena foi construída em unidades do mundo.

Enunciado da tarefa

A cena passa a nascer ancorada no piso, com escala um para um e com o alcance do braço tratado como restrição de projeto. O que estiver longe demais precisa ser trazido para perto por decisão de desenho, e não por acaso da posição em que o modelo foi importado.

O que fica pronto é o caminho de navegação imersivo, com a posição da cabeça rastreada, funcionando como percurso próprio e distinto do regime em janela. Erros de escala aparecem todos de uma vez aqui, e é essa a primeira prova de que a cena foi construída em unidades do mundo.

src/bancada/modes/corpo.ts
// ---------------------------------------------------------------------------
// Escala corporal: o módulo em que a cena passa a ser medida contra uma pessoa.
//
// Até aqui a escala foi uma promessa escrita em comentário. O arquivo da cena
// diz, desde o módulo do grafo, que uma unidade é um metro e que o tampo está a
// noventa centímetros do chão — e acrescenta que essa medida "vai ser cobrada de
// verdade no regime imersivo". Este é o arquivo que cobra.
//
// A cobrança tem duas metades, e elas falham de formas diferentes.
//
// A primeira é a conferência: o que a cena declara é o que a cena mede? Uma cena
// construída em unidades arbitrárias passa despercebida na janela do desktop
// durante meses, porque ali não há contra o que comparar — a câmera se afasta o
// quanto for preciso e tudo parece do tamanho certo. No visor há: a pessoa. E a
// diferença aparece toda de uma vez, no primeiro segundo.
//
// A segunda é o assentamento: mesmo com a cena em metros, o mundo precisa nascer
// com o chão no chão. Isso depende do espaço de referência que a sessão
// concedeu, e o pior caso não é o de recusa — é o da recusa silenciosa, em que a
// sessão abre, a cena aparece, e a bancada está na altura errada sem que nada
// tenha dado erro.
// ---------------------------------------------------------------------------

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

import { ALTURA_DO_TAMPO } from '../core/cena';
import type { SessaoAberta } from './sessao';

/**
 * Altura dos olhos adotada quando o piso real não é concedido, em metros.
 *
 * É decisão de projeto, e não medida antropométrica de tabela: o número serve de
 * conserto aproximado para o caso em que o aparelho não informa onde fica o chão.
 * Quem for mais alto que isso vê a bancada um pouco baixa; quem for mais baixo,
 * um pouco alta. Esse erro residual é o argumento a favor de pedir o piso real, e
 * é por isso que ele está escrito aqui em vez de escondido dentro de uma soma.
 */
export const ALTURA_DOS_OLHOS_M: number = 1.6;

/** Desvio aceito entre o que a cena declara e o que ela mede, em metros. */
export const TOLERANCIA_DE_ESCALA_M: number = 0.005;

/** Uma medida declarada no código, confrontada com a que a cena de fato tem. */
export interface MedidaConferida {
  readonly oQue: string;
  readonly declaradoM: number;
  readonly medidoM: number;
  readonly desvioM: number;
  readonly dentroDaTolerancia: boolean;
  /** Como se confere isto no mundo físico, sem instrumento além de uma trena. */
  readonly comoSeConfere: string;
}

/**
 * Os dois percursos de navegação do ambiente, declarados lado a lado.
 *
 * A declaração existe porque "os dois regimes" não é a mesma coisa que "a mesma
 * cena com um rótulo trocado". O que separa um percurso do outro é o que move o
 * ponto de vista — e essa é uma frase curta o bastante para ser confrontada com
 * o código a qualquer momento.
 */
export interface Percurso {
  readonly regime: string;
  readonly oQueMoveOPontoDeVista: string;
  readonly oQueOLimita: string;
  /** O que este percurso não oferece, dito sem eufemismo. */
  readonly oQueNaoOferece: string;
}

export const PERCURSOS: readonly Percurso[] = [
  {
    regime: 'Realidade virtual não imersiva',
    oQueMoveOPontoDeVista: 'o arrasto do cursor, que gira a câmera em torno de um alvo fixo',
    oQueOLimita: 'as travas de elevação e de distância da órbita, escritas no projeto',
    oQueNaoOferece:
      'escala corporal e alcance de braço. Não há corpo a que comparar a bancada, e a ' +
      'distância até uma peça é um número da câmera, não do braço de alguém',
  },
  {
    regime: 'Realidade virtual imersiva',
    oQueMoveOPontoDeVista: 'a cabeça de quem está de pé, rastreada com seis graus de liberdade',
    oQueOLimita:
      'o alcance do braço e o espaço físico livre da sala, nenhum dos dois escrito no projeto',
    oQueNaoOferece:
      'a visão de fora do ambiente. Não há como recuar dez metros para enquadrar a bancada ' +
      'inteira, e o que estiver alto demais fica alto demais',
  },
];

/** O que o assentamento fez, e por quê. */
export interface Assentamento {
  readonly espaco: XRReferenceSpaceType;
  /** Verdadeiro quando a origem é o chão físico da sala. */
  readonly pisoReal: boolean;
  /** Quanto o mundo desceu para compensar a origem concedida, em metros. */
  readonly correcaoM: number;
  readonly explicacao: string;
}

/**
 * Assenta o mundo contra a origem que a sessão concedeu e confere a escala da
 * cena contra o que ela declara.
 *
 * O nó recebido é a raiz do mundo, e não a bancada: o que desce é tudo, inclusive
 * a iluminação e a prateleira do fundo. Descer só a bancada deixaria a cena
 * dividida em dois andares, com o chão de um objeto valendo para uns e não para
 * outros — defeito que fica plausível no desktop e absurdo no visor.
 */
export class EscalaCorporal {
  private readonly mundo: Object3D;
  private readonly alturaOriginalM: number;
  private correcaoM: number = 0;
  private ultimo: Assentamento | undefined = undefined;

  constructor(mundo: Object3D) {
    this.mundo = mundo;
    this.alturaOriginalM = mundo.position.y;
  }

  /**
   * Aplica a correção de altura correspondente ao espaço obtido.
   *
   * Com origem no piso, não há nada a corrigir: a bancada de noventa centímetros
   * nasce a noventa centímetros do chão em que a pessoa está de pé. Com `local`,
   * a origem é a altura em que a cabeça estava ao entrar, e é o mundo que precisa
   * descer — sem isso o tampo aparece noventa centímetros ACIMA dos olhos, e a
   * queixa que chega é a de que a bancada está no teto. Com `viewer` não há
   * conserto possível, porque a origem acompanha a cabeça, e dizer isso é melhor
   * que fingir um número.
   */
  public assentar(aberta: SessaoAberta): Assentamento {
    switch (aberta.espacoObtido) {
      case 'local-floor':
      case 'bounded-floor':
      case 'unbounded':
        this.correcaoM = 0;
        this.ultimo = {
          espaco: aberta.espacoObtido,
          pisoReal: true,
          correcaoM: 0,
          explicacao:
            'A origem é o piso da sala física. A bancada nasce na altura declarada, e a ' +
            'escala se confere sem conta alguma: quem está de pé diante dela vê o tampo onde ' +
            'veria o de uma bancada real.',
        };
        break;
      case 'local':
        this.correcaoM = -ALTURA_DOS_OLHOS_M;
        this.ultimo = {
          espaco: aberta.espacoObtido,
          pisoReal: false,
          correcaoM: this.correcaoM,
          explicacao:
            `A origem é a altura da cabeça ao entrar, e o mundo desceu ${ALTURA_DOS_OLHOS_M.toFixed(2)} m ` +
            'para pôr o chão no chão. O conserto é aproximado: quem for mais alto que a altura ' +
            'adotada vê a bancada um pouco baixa, e quem for mais baixo, um pouco alta.',
        };
        break;
      default:
        this.correcaoM = 0;
        this.ultimo = {
          espaco: aberta.espacoObtido,
          pisoReal: false,
          correcaoM: 0,
          explicacao:
            'A origem acompanha a cabeça, e não há altura a corrigir: a cena anda junto com ' +
            'quem a olha. Escala corporal não existe neste espaço, e declarar isso é o oposto ' +
            'de inventar um número que faria a bancada parecer certa enquanto ninguém se mexe.',
        };
        break;
    }
    this.mundo.position.y = this.alturaOriginalM + this.correcaoM;
    return this.ultimo;
  }

  /** Devolve o mundo à altura de origem ao sair da sessão. */
  public desfazer(): void {
    this.correcaoM = 0;
    this.mundo.position.y = this.alturaOriginalM;
    this.ultimo = undefined;
  }

  /**
   * Mede a cena e confronta cada medida com o valor declarado no código.
   *
   * As medidas saem em coordenadas de mundo e a correção de assentamento é
   * descontada, porque o que se confere aqui é a construção da cena, e não onde
   * ela foi posta nesta sessão. Somar as duas coisas produziria alarme falso a
   * cada entrada num espaço sem piso.
   */
  public conferir(): MedidaConferida[] {
    const caixa: Box3 = new Box3();
    const tamanho: Vector3 = new Vector3();
    const conferidas: MedidaConferida[] = [];

    const tampo: Object3D | undefined = this.mundo.getObjectByName('tampo');
    if (tampo !== undefined) {
      const centro: Vector3 = tampo.getWorldPosition(new Vector3());
      conferidas.push(
        this.confrontar(
          'Altura do tampo, do chão ao meio da prancha',
          ALTURA_DO_TAMPO,
          centro.y - this.correcaoM,
          'Meça uma bancada de trabalho real com trena e compare. Errar aqui é o defeito que ' +
            'só aparece no visor, e ele acusa a cena inteira, não a bancada.',
        ),
      );

      caixa.setFromObject(tampo).getSize(tamanho);
      conferidas.push(
        this.confrontar(
          'Largura do tampo, de ponta a ponta',
          1.4,
          tamanho.x,
          'Abra os braços diante dela: a prancha tem de ser larga o bastante para as duas mãos ' +
            'trabalharem sem se encostarem.',
        ),
      );
    }

    const engrenagem: Object3D | undefined = this.mundo.getObjectByName('engrenagem-grande');
    if (engrenagem !== undefined) {
      caixa.setFromObject(engrenagem).getSize(tamanho);
      conferidas.push(
        this.confrontar(
          'Diâmetro da engrenagem grande',
          0.15,
          Math.max(tamanho.x, tamanho.z),
          'Quinze centímetros é o tamanho de uma mão aberta. Se ela parecer um prato de jantar ' +
            'dentro do visor, o erro está na cena e não no aparelho.',
        ),
      );
    }

    return conferidas;
  }

  /** O último assentamento aplicado, ou indefinido fora de sessão. */
  public assentamento(): Assentamento | undefined {
    return this.ultimo;
  }

  public linhas(): string[] {
    const linhas: string[] = [];
    const estado: Assentamento | undefined = this.ultimo;
    linhas.push(
      estado === undefined
        ? 'Fora de sessão: o mundo está na altura de origem, e escala corporal não se afere aqui.'
        : `Espaço obtido ${estado.espaco}; piso real: ${estado.pisoReal ? 'sim' : 'não'}. ${estado.explicacao}`,
    );
    linhas.push('');
    for (const medida of this.conferir()) {
      const veredito: string = medida.dentroDaTolerancia ? 'confere' : 'NÃO CONFERE';
      linhas.push(
        `${medida.oQue}: declarado ${medida.declaradoM.toFixed(3)} m, medido ` +
          `${medida.medidoM.toFixed(3)} m, desvio ${(medida.desvioM * 100).toFixed(1)} cm — ${veredito}.`,
      );
      linhas.push(`   ${medida.comoSeConfere}`);
    }
    linhas.push('');
    for (const percurso of PERCURSOS) {
      linhas.push(`${percurso.regime}:`);
      linhas.push(`   move o ponto de vista — ${percurso.oQueMoveOPontoDeVista}`);
      linhas.push(`   limita — ${percurso.oQueOLimita}`);
      linhas.push(`   não oferece — ${percurso.oQueNaoOferece}`);
    }
    return linhas;
  }

  private confrontar(
    oQue: string,
    declaradoM: number,
    medidoM: number,
    comoSeConfere: string,
  ): MedidaConferida {
    const desvioM: number = medidoM - declaradoM;
    return {
      oQue,
      declaradoM,
      medidoM,
      desvioM,
      dentroDaTolerancia: Math.abs(desvioM) <= TOLERANCIA_DE_ESCALA_M,
      comoSeConfere,
    };
  }
}

O arquivo faz duas coisas que parecem uma só e falham de formas diferentes. Ele assenta o mundo contra a origem que a sessão concedeu, e ele confere a cena contra o que a cena declara. Confundir as duas produz o diagnóstico errado toda vez.

1.3.1 O piso concedido, e o conserto quando ele não vem

Onde está o chão? O ciclo de sessão do módulo anterior já pedia o espaço com origem no piso, e já registrava a recusa. O que ele não fazia era agir sobre a recusa.

Com origem no piso, não há conta nenhuma a fazer. A bancada de 90 cm nasce a 90 cm do chão em que a pessoa está de pé, e a escala se confere pela impressão imediata de quem entra. É o caso bom, e é o que se pede primeiro.

Com origem à altura da cabeça, a bancada nasce 90 cm acima dos olhos. Não é exagero de exemplo: a origem é onde a cabeça estava ao entrar, e a altura declarada da bancada se soma a ela. O mundo precisa descer, e é isso o que o arquivo faz.

O conserto é aproximado, e o arquivo diz isso em vez de esconder. A altura de olhos adotada é decisão de projeto registrada, não medida de tabela: quem for mais alto vê a bancada um pouco baixa, e quem for mais baixo, um pouco alta. Esse resíduo é o melhor argumento a favor de pedir o piso real.

1.3.2 A conferência contra uma medida conhecida

Três medidas, e as três com uma frase dizendo como se confere cada uma sem instrumento além de uma trena. A altura do tampo, a largura da prancha e o diâmetro da engrenagem grande.

A engrenagem é a mais útil das três, e por um motivo pouco óbvio. Quinze centímetros é o tamanho de uma mão aberta, e a mão está ali, dentro do ambiente, ao lado da peça. A comparação se faz sozinha, sem que ninguém precise lembrar de medir coisa alguma.

Repare no desconto que a conferência faz. As medidas saem em coordenadas de mundo, e a correção de assentamento é subtraída antes do confronto. Sem esse desconto, toda entrada num espaço sem piso acusaria a cena de estar 1,6 m fora do lugar — e a cena estaria certa.

1.3.3 Os dois percursos, escritos lado a lado

O ambiente precisa oferecer dois caminhos de navegação distintos, e não a mesma cena com um rótulo trocado. A frase é fácil de assinar e difícil de confrontar, e a saída adotada aqui é a mesma dos módulos anteriores: escrever a declaração como dado, e não como parágrafo em documento à parte.

Cada percurso declara três coisas. O que move o ponto de vista, o que o limita e — a coluna que importa — o que ele não oferece.

A terceira coluna é onde a honestidade cabe. Na janela não existem escala corporal nem alcance de braço, e chamar isso de versão reduzida seria mentir por otimismo. No imersivo não existe a visão de fora: não há como recuar dez metros para enquadrar a bancada inteira, e o que estiver alto demais fica alto demais.

1.4 Tarefa 2: Cumprir a tarefa inteira dentro do ambiente

Toda peça que exigir voltar à janela para ser alcançada é um alcance de braço mal resolvido.

Enunciado da tarefa

Entrar no ambiente não basta. A tarefa do domínio precisa ser cumprível do início ao fim de dentro dele, com quadros estáveis do começo ao fim.

A tarefa fecha quando alguém que nunca viu o ambiente conclui a tarefa sem sair do regime imersivo. Toda peça que exigir voltar à janela para ser alcançada é um alcance de braço mal resolvido, e o conserto é de desenho, jamais de instrução ao usuário.

src/bancada/modes/alcance.ts
// ---------------------------------------------------------------------------
// O alcance do braço como restrição de projeto.
//
// Este arquivo existe por causa de um número que estava certo até este módulo e
// passou a estar errado nele. O ciclo de sessão pôs quem entra a um metro e
// quinze da bancada, e a justificativa escrita lá era boa: setenta e cinco
// centímetros de folga até a borda, para a pessoa não nascer dentro do móvel. O
// critério era o de quem CHEGA. O critério deste módulo é o de quem TRABALHA, e
// pelo segundo aquele mesmo número deixa todas as peças fora do braço.
//
// Nenhum dos dois critérios é errado; eles medem coisas diferentes, e é por isso
// que o conflito só apareceu quando o segundo entrou. O conserto é de desenho —
// aproximar o posto, e trazer para a frente do tampo o que ficou longe — e não de
// instrução: "ande um passo à frente" transfere a quem usa um problema que é de
// quem projetou, e falha justamente na sessão que não tem espaço livre para
// andar.
//
// Os dois raios abaixo são decisão de projeto registrada, e não tabela
// antropométrica. Quem quiser rigor mede a turma; o que não se pode é usar um
// número sem dizer de onde ele veio.
// ---------------------------------------------------------------------------

import { Object3D, Vector3 } from 'three';

import type { PecaId } from '../dominio/dominio';

/** Raio em que a mão chega sem que o tronco se incline, em metros. */
export const RAIO_CONFORTAVEL_M: number = 0.6;

/** Raio em que a mão ainda chega, com o tronco inclinado, em metros. */
export const RAIO_MAXIMO_M: number = 0.75;

/**
 * Altura do ombro adotada, em metros. Decisão de projeto, coerente com a altura
 * dos olhos que o assentamento usa: o ombro fica um palmo e meio abaixo dela.
 */
export const ALTURA_DO_OMBRO_M: number = 1.35;

/** Até onde uma peça pode avançar sobre o tampo sem cair da borda, em metros. */
export const LIMITE_FRONTAL_M: number = 0.34;

export type FaixaDeAlcance = 'confortavel' | 'no-limite' | 'fora';

export interface AlcanceDaPeca {
  readonly peca: PecaId;
  readonly distanciaM: number;
  readonly faixa: FaixaDeAlcance;
}

export interface Auditoria {
  /** Onde estão os pés de quem trabalha, no referencial do mundo. */
  readonly postoM: number;
  readonly itens: readonly AlcanceDaPeca[];
  readonly foraDoAlcance: number;
}

export interface Aproximacao {
  readonly peca: PecaId;
  readonly avancoM: number;
  readonly distanciaAntesM: number;
  readonly distanciaDepoisM: number;
  readonly resolvida: boolean;
}

function faixaDe(distanciaM: number): FaixaDeAlcance {
  if (distanciaM <= RAIO_CONFORTAVEL_M) {
    return 'confortavel';
  }
  if (distanciaM <= RAIO_MAXIMO_M) {
    return 'no-limite';
  }
  return 'fora';
}

/**
 * Onde fica o ombro de quem trabalha, no referencial do mundo.
 *
 * O posto entra como distância à frente da origem da bancada, que é o mesmo
 * número que o ciclo de sessão usa para deslocar a origem do espaço de
 * referência. Manter os dois amarrados é o que impede o conserto de um lado sem
 * o outro — e foi exatamente essa separação que produziu o conflito deste módulo.
 */
function ombro(postoM: number): Vector3 {
  return new Vector3(0, ALTURA_DO_OMBRO_M, postoM);
}

/**
 * Mede, peça por peça, a distância até o ombro de quem está no posto.
 *
 * As posições são lidas no referencial do mundo — e não em coordenadas globais —
 * porque o mundo pode ter descido para compensar um espaço de referência sem
 * piso. Medir em coordenadas globais faria o alcance mudar conforme o aparelho,
 * o que é o oposto do que se quer de uma restrição de projeto.
 */
export function auditarAlcance(
  mundo: Object3D,
  pecas: ReadonlyMap<PecaId, Object3D>,
  postoM: number,
): Auditoria {
  const referencia: Vector3 = ombro(postoM);
  const local: Vector3 = new Vector3();
  const itens: AlcanceDaPeca[] = [];

  for (const [peca, no] of pecas) {
    no.getWorldPosition(local);
    mundo.worldToLocal(local);
    const distanciaM: number = local.distanceTo(referencia);
    itens.push({ peca, distanciaM, faixa: faixaDe(distanciaM) });
  }

  itens.sort((a: AlcanceDaPeca, b: AlcanceDaPeca) => b.distanciaM - a.distanciaM);
  return {
    postoM,
    itens,
    foraDoAlcance: itens.filter((item: AlcanceDaPeca) => item.faixa === 'fora').length,
  };
}

/**
 * Traz para dentro do alcance confortável o que estiver longe, avançando cada
 * peça sobre o tampo em direção a quem trabalha.
 *
 * A conta é fechada, e não uma busca por tentativa. Queremos o menor avanço em
 * profundidade que ponha a peça dentro do raio: com o desvio lateral e o desvio
 * de altura fixos, sobra do raio o que o teorema de Pitágoras deixar, e é essa
 * sobra que dá a profundidade máxima admissível. Quando o que sobra é negativo, a
 * peça está longe demais de lado ou alta demais, e avançá-la não resolveria —
 * caso que o retorno declara em vez de esconder.
 *
 * O avanço nunca empurra a peça para trás nem além da borda da prancha: uma peça
 * corrigida que cai no chão trocaria um defeito por outro pior.
 */
export function trazerParaOAlcance(
  mundo: Object3D,
  pecas: ReadonlyMap<PecaId, Object3D>,
  postoM: number,
): Aproximacao[] {
  const referencia: Vector3 = ombro(postoM);
  const local: Vector3 = new Vector3();
  const movimentos: Aproximacao[] = [];

  for (const [peca, no] of pecas) {
    no.getWorldPosition(local);
    mundo.worldToLocal(local);
    const distanciaAntesM: number = local.distanceTo(referencia);
    if (distanciaAntesM <= RAIO_CONFORTAVEL_M) {
      continue;
    }

    const lateral: number = local.x - referencia.x;
    const vertical: number = local.y - referencia.y;
    const sobra: number =
      RAIO_CONFORTAVEL_M * RAIO_CONFORTAVEL_M - lateral * lateral - vertical * vertical;

    // Sem sobra, nenhuma profundidade cabe no raio: o problema não está em quão
    // longe a peça está de quem trabalha, e sim em quão longe ela está do eixo do
    // corpo. Declarar isso é mais útil que empurrá-la até a borda em vão.
    const profundidadeMaxima: number = sobra <= 0 ? Number.NEGATIVE_INFINITY : Math.sqrt(sobra);
    const zDesejado: number = referencia.z - profundidadeMaxima;
    const zNovo: number = Math.min(Math.max(zDesejado, local.z), LIMITE_FRONTAL_M);
    const avancoM: number = zNovo - local.z;

    if (avancoM > 0) {
      no.position.z += avancoM;
      no.updateMatrixWorld(true);
    }

    no.getWorldPosition(local);
    mundo.worldToLocal(local);
    const distanciaDepoisM: number = local.distanceTo(referencia);

    movimentos.push({
      peca,
      avancoM,
      distanciaAntesM,
      distanciaDepoisM,
      resolvida: distanciaDepoisM <= RAIO_MAXIMO_M,
    });
  }

  return movimentos;
}

export function linhasDoAlcance(
  auditoria: Auditoria,
  movimentos: readonly Aproximacao[],
): string[] {
  const linhas: string[] = [
    `Posto de trabalho a ${auditoria.postoM.toFixed(2)} m da origem da bancada; ombro adotado a ` +
      `${ALTURA_DO_OMBRO_M.toFixed(2)} m do chão.`,
    `Raio confortável ${(RAIO_CONFORTAVEL_M * 100).toFixed(0)} cm, raio máximo ` +
      `${(RAIO_MAXIMO_M * 100).toFixed(0)} cm. Os dois são decisão de projeto registrada, não ` +
      'medida de tabela.',
    '',
  ];

  for (const item of auditoria.itens) {
    linhas.push(
      `${item.peca}: ${(item.distanciaM * 100).toFixed(0)} cm do ombro — ${item.faixa}.`,
    );
  }

  linhas.push('');
  if (movimentos.length === 0) {
    linhas.push('Nenhuma peça precisou ser trazida: todas já estavam dentro do raio confortável.');
    return linhas;
  }

  for (const movimento of movimentos) {
    const desfecho: string = movimento.resolvida
      ? 'passou a estar ao alcance'
      : 'CONTINUA fora do alcance, e o conserto não é de posição';
    linhas.push(
      `${movimento.peca}: avançou ${(movimento.avancoM * 100).toFixed(0)} cm sobre o tampo, de ` +
        `${(movimento.distanciaAntesM * 100).toFixed(0)} cm para ` +
        `${(movimento.distanciaDepoisM * 100).toFixed(0)} cm — ${desfecho}.`,
    );
  }
  return linhas;
}

Os dois raios do arquivo são decisão de projeto registrada, e o comentário diz isso com todas as letras. Quem quiser rigor antropométrico mede a turma. O que não se pode é usar um número sem dizer de onde ele veio, e é essa a diferença entre parâmetro e chute.

1.4.1 O número que estava certo pelo critério anterior

O ciclo de sessão punha quem entra a 1,15 m da bancada, e a justificativa escrita lá era boa. O tampo tem 80 cm de profundidade, a borda da frente fica a 40 cm da origem, e sobravam 75 cm de folga entre a pessoa e o móvel.

Aquele número media a distância de quem chega. Este módulo mede a distância de quem trabalha, e pelo segundo critério nenhuma peça do tampo cabe dentro do braço. Ninguém errou: os dois critérios medem coisas diferentes, e o conflito só podia aparecer quando o segundo entrasse.

O conserto é aproximar o posto para 55 cm da origem, o que deixa 15 cm até a borda — a distância de quem está encostado na bancada, e não a de quem se aproxima dela. O comentário do arquivo antigo foi reescrito para contar essa história inteira, porque um número trocado sem explicação é o primeiro a ser revertido por engano.

E há um segundo conserto, que é o da própria tarefa. As peças que continuam longe são trazidas para a frente do tampo, por decisão de desenho. A alternativa — pedir a quem usa que dê um passo à frente — transfere para o outro lado um problema que é de quem projetou, e falha exatamente na sessão sem espaço livre para andar.

1.4.2 A conta fechada, e o caso em que avançar não resolve

Qual é o menor avanço que põe a peça dentro do raio? A pergunta tem resposta fechada, e vale escrevê-la em vez de procurar por tentativa.

Com o desvio lateral e o desvio de altura já fixos, o raio disponível gasta parte de si nos dois, e o que sobra é a profundidade máxima admissível. Um triângulo retângulo resolve, e o resultado é o quanto a peça pode estar afastada de quem trabalha.

O caso interessante é o da sobra negativa. Ele significa que a peça está longe demais de lado, ou alta demais, e que nenhuma profundidade caberia no raio. Avançá-la até a borda não resolveria nada, e o retorno declara isso em vez de fingir um conserto.

Repare no que o arquivo se recusa a fazer. Ele nunca empurra a peça para trás, e nunca a leva além da borda da prancha. Uma peça corrigida que cai no chão troca um defeito por outro pior, e esse outro é mais difícil de diagnosticar.

1.4.3 O que simplesmente não degrada

Escada rolante parada ainda é escada; ambiente que só existe com o visor no rosto não é nada sem ele. A figura foi adotada no módulo do regime em janela e continua valendo — a bancada é montável inteira sem sessão alguma, o que, com três visores para a turma, não é virtude teórica.

O que este módulo acrescenta é o limite dela, e o limite é curto. Escala corporal e alcance de braço não têm versão em janela. Não existe um equivalente reduzido: existe a ausência do tópico.

Daí a terceira coluna dos percursos. A mensagem correta a quem usa, nesses dois casos, é que o regime não suporta aquela tarefa — nunca um substituto silencioso que produza um número plausível e falso.

1.5 Tarefa 3: Demonstrar o caso ruim e a correção

Um requisito que ninguém sabe violar não é verificável.

Enunciado da tarefa

Conforto é requisito, e requisito se demonstra. Aceleração indesejada, latência e queda de quadros têm consequência física, e quem constrói precisa saber provocá-las de propósito.

O que fica pronto é um caminho curto que exibe o comportamento desconfortável e, em seguida, o mesmo trecho corrigido. Mostrar o defeito e a correção prova domínio de um jeito que nenhuma cena bonita prova, desde que seja breve e avisado a quem participa.

src/bancada/modes/conforto.ts
// ---------------------------------------------------------------------------
// Conforto como requisito, e requisito se demonstra.
//
// Este arquivo tem duas metades que só fazem sentido juntas: a que mede e a que
// provoca.
//
// A que mede corrige uma conta antiga. O envelope do quadro, declarado sete
// módulos atrás, guarda uma janela deslizante de cento e vinte quadros e informa
// a média, o pior intervalo dessa janela e a proporção acima do teto. Aquilo
// serve para orçar. Não serve para conforto, porque a janela ESQUECE: dez
// segundos depois de um engasgo, o número já não o contém. Quem estava com o
// visor no rosto contém — o mal-estar não é média, é o estouro isolado entre dois
// quadros bons, e ele se acumula ao longo da sessão inteira. O contador deste
// arquivo tem memória, e é essa a diferença.
//
// A que provoca existe porque um requisito que ninguém sabe violar não é
// verificável. Quem constrói precisa saber produzir o caso ruim de propósito, em
// poucos segundos e com quem foi avisado, e mostrar o mesmo trecho corrigido em
// seguida. Daí as três provocações abaixo terem duração máxima escrita em
// constante e correção automática ao fim dela: uma demonstração de desconforto
// que dependa de alguém lembrar de desligá-la é a receita de passar do ponto.
//
// Uma honestidade que precisa estar no código, e não só na fala: a latência que
// mais incomoda em realidade virtual é a que separa o movimento da cabeça da
// imagem correspondente, e essa não é nossa nem para injetar nem para consertar —
// ela é da plataforma e do aparelho. A que é nossa é a do que penduramos na mão,
// e é ela que a terceira provocação exibe.
// ---------------------------------------------------------------------------

import type { Object3D } from 'three';

import type { Ponteiro } from '../input/ponteiro';

/**
 * Quantas vezes o teto um intervalo precisa passar para contar como engasgo.
 *
 * Dois é a menor escolha defensável: um intervalo de dois tetos significa que
 * uma imagem não foi entregue e a anterior ficou no lugar dela. Abaixo disso a
 * contagem encheria de ruído, e a lista de engasgos deixaria de apontar para
 * alguma coisa.
 */
export const FATOR_DE_ENGASGO: number = 2;

/** Quantos engasgos recentes o contador guarda para exibição. */
const ENGASGOS_LEMBRADOS: number = 8;

export interface Engasgo {
  readonly instanteS: number;
  readonly intervaloMs: number;
}

export interface LeituraDeEngasgos {
  readonly quadros: number;
  readonly engasgos: number;
  readonly piorIntervaloMs: number;
  readonly tempoObservadoS: number;
  readonly desdeOUltimoS: number | undefined;
  readonly recentes: readonly Engasgo[];
}

/**
 * Conta engasgos desde o começo da sessão, sem janela deslizante.
 *
 * A ausência da janela é o ponto do arquivo, e não descuido: o que se quer saber
 * aqui é quantas vezes, na sessão inteira, a imagem falhou de um jeito que o
 * corpo registra. Média não responde a isso, e janela curta apaga a resposta
 * enquanto a pessoa ainda está sentindo o efeito.
 */
export class ContadorDeEngasgos {
  private readonly tetoMs: number;
  private readonly recentes: Engasgo[] = [];
  private quadros: number = 0;
  private engasgos: number = 0;
  private piorIntervaloMs: number = 0;
  private tempoObservadoS: number = 0;
  private ultimoEngasgoS: number | undefined = undefined;

  constructor(tetoMs: number) {
    this.tetoMs = tetoMs;
  }

  /**
   * Registra o quadro. O intervalo é o real, medido antes de qualquer limite de
   * salto: é o tempo que a pessoa esperou pela imagem, e limitá-lo aqui
   * esconderia justamente o evento que se quer contar.
   */
  public registrar(decorridoS: number, intervaloRealS: number): void {
    this.quadros += 1;
    this.tempoObservadoS = decorridoS;

    const intervaloMs: number = intervaloRealS * 1000;
    if (intervaloMs > this.piorIntervaloMs) {
      this.piorIntervaloMs = intervaloMs;
    }
    if (intervaloMs < this.tetoMs * FATOR_DE_ENGASGO) {
      return;
    }

    this.engasgos += 1;
    this.ultimoEngasgoS = decorridoS;
    this.recentes.push({ instanteS: decorridoS, intervaloMs });
    if (this.recentes.length > ENGASGOS_LEMBRADOS) {
      this.recentes.shift();
    }
  }

  public ler(): LeituraDeEngasgos {
    return {
      quadros: this.quadros,
      engasgos: this.engasgos,
      piorIntervaloMs: this.piorIntervaloMs,
      tempoObservadoS: this.tempoObservadoS,
      desdeOUltimoS:
        this.ultimoEngasgoS === undefined ? undefined : this.tempoObservadoS - this.ultimoEngasgoS,
      recentes: this.recentes,
    };
  }
}

export function linhasDosEngasgos(leitura: LeituraDeEngasgos, tetoMs: number): string[] {
  if (leitura.quadros === 0) {
    return ['Ainda sem quadros observados.'];
  }
  const limiar: number = tetoMs * FATOR_DE_ENGASGO;
  const linhas: string[] = [
    `Limiar de engasgo: ${limiar.toFixed(1)} ms — o dobro do teto, isto é, ao menos uma imagem ` +
      'que não foi entregue.',
    `${leitura.engasgos} engasgos em ${leitura.tempoObservadoS.toFixed(0)} s de observação ` +
      `(${leitura.quadros} quadros). Pior intervalo da sessão inteira: ` +
      `${leitura.piorIntervaloMs.toFixed(1)} ms.`,
    leitura.desdeOUltimoS === undefined
      ? 'Nenhum engasgo até agora. A observação é curta, e ausência aqui não é promessa.'
      : `Último engasgo há ${leitura.desdeOUltimoS.toFixed(0)} s.`,
  ];
  for (const engasgo of leitura.recentes) {
    linhas.push(
      `   aos ${engasgo.instanteS.toFixed(1)} s: ${engasgo.intervaloMs.toFixed(1)} ms entre imagens.`,
    );
  }
  return linhas;
}

export type CasoDesconfortavel = 'aceleracao' | 'engasgo' | 'atraso';

/** De quem é o conserto de cada causa. */
export type ResponsavelPeloConserto = 'projeto' | 'plataforma';

export interface Caso {
  readonly id: CasoDesconfortavel;
  readonly nome: string;
  readonly oQueOCorpoSente: string;
  readonly comoEProvocadoAqui: string;
  readonly correcao: string;
  readonly conserto: ResponsavelPeloConserto;
}

export const CASOS: readonly Caso[] = [
  {
    id: 'aceleracao',
    nome: 'Aceleração que os olhos veem e o ouvido interno não',
    oQueOCorpoSente:
      'a sensação de estar sendo levado sem ter dado um passo, que começa como estranheza e ' +
      'vira mal-estar em poucos segundos',
    comoEProvocadoAqui:
      'o mundo inteiro desliza de lado, ganhando velocidade aos poucos, com quem observa ' +
      'parado. Deslocar o mundo sob a pessoa e deslocar a pessoa sobre o mundo produzem a mesma ' +
      'imagem na retina, e é justamente por serem indistinguíveis que a provocação vale',
    correcao:
      'o ponto de vista só se move quando a cabeça se move. Nada no ambiente desloca quem ' +
      'observa por conta própria, e deslocamento pedido é assunto do módulo de locomoção',
    conserto: 'projeto',
  },
  {
    id: 'engasgo',
    nome: 'Imagem que não chega no tempo dela',
    oQueOCorpoSente:
      'o solavanco de uma imagem que ficou parada e depois pulou para onde deveria estar, ' +
      'como se o mundo tivesse piscado',
    comoEProvocadoAqui:
      'trabalho inútil é queimado dentro do quadro, em rajadas, até o intervalo entre imagens ' +
      'passar do dobro do teto',
    correcao:
      'orçar pelo aparelho de maior cadência, medir o estouro isolado em vez da média e contar ' +
      'os engasgos ao longo da sessão inteira, que é o que este arquivo faz',
    conserto: 'projeto',
  },
  {
    id: 'atraso',
    nome: 'O que está na mão chegando depois da mão',
    oQueOCorpoSente:
      'a peça arrastando-se atrás do movimento, como se pesasse mais do que aparenta, e a ' +
      'correção de trajetória que vem em seguida e passa do ponto',
    comoEProvocadoAqui:
      'as leituras de apontamento são guardadas e entregues alguns quadros depois. O atraso ' +
      'entre o movimento da cabeça e a imagem correspondente NÃO é injetado aqui, porque não é ' +
      'nosso: quem o produz e quem o corrige é a plataforma',
    correcao:
      'consumir a leitura do quadro corrente, sem fila entre a fonte de entrada e quem a usa. ' +
      'Toda suavização acrescentada entre as duas cobra o preço em atraso',
    conserto: 'projeto',
  },
];

export function casoPorId(id: CasoDesconfortavel): Caso {
  const encontrado: Caso | undefined = CASOS.find((caso: Caso) => caso.id === id);
  if (encontrado === undefined) {
    throw new Error(`Caso de desconforto não declarado: ${id}`);
  }
  return encontrado;
}

/** Duração máxima de uma provocação, em segundos. */
export const DURACAO_MAXIMA_S: number = 4;

/** Aceleração do deslize do mundo, em metros por segundo ao quadrado. */
const ACELERACAO_M_S2: number = 0.25;

/** Trabalho inútil queimado por quadro na provocação de engasgo, em milissegundos. */
const CARGA_POR_QUADRO_MS: number = 26;

/** De quantos em quantos quadros a carga é aplicada. Rajada, e não constante. */
const PERIODO_DA_RAJADA: number = 6;

/** Quantos quadros de atraso a provocação de latência insere. */
const QUADROS_DE_ATRASO: number = 6;

/**
 * As três provocações, com correção automática ao fim do prazo.
 *
 * A demonstração vive na composição, e não dentro de camada alguma: nenhum
 * arquivo de interação, montagem ou sessão sabe que ela existe. Foi assim que a
 * latência pôde ser injetada sem tocar no agarre — a fila fica entre a leitura
 * das fontes e quem a consome, que é exatamente onde uma suavização mal pensada
 * também ficaria.
 */
export class Demonstracao {
  private readonly mundo: Object3D;
  private readonly fila: (readonly Ponteiro[])[] = [];
  private emCursoId: CasoDesconfortavel | undefined = undefined;
  private decorridoNoCasoS: number = 0;
  private velocidadeM_S: number = 0;
  private xOriginalM: number = 0;
  private quadrosNaRajada: number = 0;
  private giros: number = 0;

  constructor(mundo: Object3D) {
    this.mundo = mundo;
  }

  public provocar(id: CasoDesconfortavel): Caso {
    this.corrigir();
    this.emCursoId = id;
    this.decorridoNoCasoS = 0;
    this.velocidadeM_S = 0;
    this.xOriginalM = this.mundo.position.x;
    return casoPorId(id);
  }

  /** Desfaz a provocação e devolve o ambiente ao estado bom. */
  public corrigir(): void {
    if (this.emCursoId === 'aceleracao') {
      this.mundo.position.x = this.xOriginalM;
    }
    this.emCursoId = undefined;
    this.decorridoNoCasoS = 0;
    this.velocidadeM_S = 0;
    this.quadrosNaRajada = 0;
    this.fila.length = 0;
  }

  public emCurso(): CasoDesconfortavel | undefined {
    return this.emCursoId;
  }

  /**
   * Avança a provocação em curso pelo tempo do quadro e a encerra no prazo.
   *
   * O prazo não é zelo excessivo. Demonstração de desconforto que dependa de
   * alguém lembrar de desligá-la passa do ponto exatamente quando o efeito
   * começou a funcionar, que é o pior momento possível.
   */
  public avancar(deltaS: number): void {
    if (this.emCursoId === undefined) {
      return;
    }
    this.decorridoNoCasoS += deltaS;
    if (this.decorridoNoCasoS >= DURACAO_MAXIMA_S) {
      this.corrigir();
      return;
    }
    if (this.emCursoId === 'aceleracao') {
      this.velocidadeM_S += ACELERACAO_M_S2 * deltaS;
      this.mundo.position.x += this.velocidadeM_S * deltaS;
    }
  }

  /**
   * Queima tempo dentro do quadro quando a provocação de engasgo está em curso.
   *
   * O laço vazio é a forma mais crua possível de gastar tempo, e é essa a
   * intenção: o que se demonstra é o efeito do intervalo, e não uma causa
   * específica. Trabalho de verdade — mais triângulos, mais chamadas de desenho —
   * produziria o mesmo intervalo e um diagnóstico ambíguo por cima. O acumulador
   * existe para que o compilador não descarte o laço por não ter efeito.
   */
  public consumirCarga(): void {
    if (this.emCursoId !== 'engasgo') {
      return;
    }
    this.quadrosNaRajada = (this.quadrosNaRajada + 1) % PERIODO_DA_RAJADA;
    if (this.quadrosNaRajada !== 0) {
      return;
    }
    const fim: number = performance.now() + CARGA_POR_QUADRO_MS;
    while (performance.now() < fim) {
      this.giros += 1;
    }
  }

  /**
   * Atrasa as leituras de apontamento enquanto a provocação de latência dura.
   *
   * Fora dela a lista passa direto, sem cópia e sem fila — o custo de a
   * demonstração existir precisa ser zero quando ela está desligada, ou o
   * ambiente estaria pagando permanentemente pelo que se usa por quatro segundos.
   */
  public atrasar(ponteiros: readonly Ponteiro[]): readonly Ponteiro[] {
    if (this.emCursoId !== 'atraso') {
      return ponteiros;
    }
    this.fila.push(ponteiros);
    if (this.fila.length <= QUADROS_DE_ATRASO) {
      return [];
    }
    return this.fila.shift() ?? ponteiros;
  }

  public linhas(): string[] {
    const linhas: string[] = [];
    const atual: CasoDesconfortavel | undefined = this.emCursoId;
    linhas.push(
      atual === undefined
        ? 'Nenhuma provocação em curso: o ambiente está no caso bom.'
        : `Em curso: ${casoPorId(atual).nome}. Restam ` +
          `${(DURACAO_MAXIMA_S - this.decorridoNoCasoS).toFixed(1)} s até a correção automática.`,
    );
    linhas.push(
      `Toda provocação dura no máximo ${DURACAO_MAXIMA_S} s e se corrige sozinha. Aplique-as ` +
        'apenas em quem foi avisado, e nunca em quem está sozinho com o visor no rosto.',
    );
    linhas.push('');
    for (const caso of CASOS) {
      linhas.push(`${caso.nome} [conserto: ${caso.conserto}]`);
      linhas.push(`   o corpo sente — ${caso.oQueOCorpoSente}`);
      linhas.push(`   provocado aqui — ${caso.comoEProvocadoAqui}`);
      linhas.push(`   correção — ${caso.correcao}`);
    }
    return linhas;
  }
}

O arquivo tem duas metades que só fazem sentido juntas. Uma mede, e corrige uma conta antiga. A outra provoca, e existe porque requisito sem violação conhecida é frase de documento.

1.5.1 O envelope não tem memória, e quem usa tem

Cada quadro é um envelope de onze milissegundos, e o que não couber nele não fica para depois: some. A figura foi declarada no módulo do grafo de cena, antes de existir conteúdo pesado para gastá-la, e serviu bem por sete módulos. Aqui ela encontra o próprio limite.

O envelope trata custo como média, e o corpo não trata. A janela deslizante do orçamento guarda 120 quadros: dez segundos depois de um engasgo, o número já não o contém. Quem estava com o visor no rosto contém, e vai continuar contendo até a sessão acabar.

O que produz mal-estar é o estouro isolado — o engasgo entre dois quadros bons, a imagem que ficou parada e depois pulou para onde deveria estar. Uma sessão com média excelente e um solavanco a cada meio minuto é pior que uma sessão uniformemente medíocre, e nenhuma média diz isso.

O contador deste arquivo, então, não esquece nada. Conta desde o início da sessão, guarda o pior intervalo de todos e registra o instante de cada engasgo. O limiar adotado é o dobro do teto, que é a menor escolha defensável: dobro do teto significa que uma imagem não foi entregue.

1.5.2 As três provocações, e de quem é o conserto

A primeira faz o mundo deslizar de lado sob quem está parado, ganhando velocidade aos poucos. Os olhos informam movimento, o ouvido interno informa repouso, e o desconforto vem dessa discordância. Deslocar o mundo sob a pessoa e deslocar a pessoa sobre o mundo produzem a mesma imagem na retina, e é justamente por serem indistinguíveis que a provocação vale.

A segunda queima trabalho inútil dentro do quadro, em rajadas, até o intervalo passar do dobro do teto. O laço vazio é a forma mais crua possível de gastar tempo, e é essa a intenção: o que se demonstra é o efeito do intervalo, não uma causa específica. Trabalho de verdade produziria o mesmo intervalo com um diagnóstico ambíguo por cima.

A terceira é a que exige mais honestidade, e vale ler o comentário do arquivo antes do código. A latência que mais incomoda em ambiente imersivo é a que separa o movimento da cabeça da imagem correspondente — e essa não é nossa nem para injetar nem para consertar. Ela é da plataforma e do aparelho.

O que é nosso é o atraso do que penduramos na mão, e é ele que a provocação exibe. A fila entra entre a leitura das fontes de apontamento e quem as consome, que é exatamente o lugar onde uma suavização mal pensada também ficaria. A peça passa a arrastar-se atrás do movimento, e a correção de trajetória que vem em seguida passa do ponto.

1.5.3 O prazo que a demonstração carrega dentro de si

Toda provocação se corrige sozinha em poucos segundos, e o prazo está escrito em constante. Não é zelo excessivo, e a razão é desagradável de admitir.

Demonstração de desconforto que dependa de alguém lembrar de desligá-la passa do ponto exatamente quando o efeito começou a funcionar. Quem conduz está olhando para a turma, e quem sente está com o visor no rosto e sem meio fácil de sair.

Duas travas acompanham o prazo. Sair da sessão interrompe a provocação, porque um mundo deslizando na janela sem nada à vista que explique por quê é defeito, não demonstração. E encerrar o ambiente também interrompe, já que o laço está prestes a parar e o mundo ficaria congelado deslocado.

A realidade virtual passou de “quase funciona” a “mal e mal funciona”. O balanço é de 1999, e quem o escreveu havia posto químicos para encaixar moléculas com um braço mecânico de retorno de força, em Chapel Hill, num projeto perseguido por décadas. É a defesa mais útil contra o encantamento, e ela vem de dentro da área — recusar entusiasmo custa mais a quem tem motivo para tê-lo.

1.6 A evidência de que o corpo não sabe que é falso

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.

Por que a escala importa tanto, se a bancada continua sendo caixas e cilindros de material fosco? A resposta é o conteúdo conceitual deste módulo, e ela tem evidência experimental.

Em 2006, Mel Slater refez em ambiente virtual o experimento de obediência de Stanley Milgram, com um avatar visivelmente sintético no lugar da pessoa que recebia os choques. Os participantes sabiam de tudo. Ainda assim hesitaram, suaram e demonstraram desconforto medido.

O avatar não enganava ninguém, e não era esse o ponto. O que produziu a reação foi o ambiente responder no tempo certo, na escala certa e de forma coerente com o movimento de quem estava lá — que é exatamente a lista de coisas que os três arquivos deste módulo tratam.

Daí a ordem de prioridade do projeto ser esta, e não a intuitiva. Escala correta vem antes de material bonito. Quadro estável vem antes de sombra suave. Uma bancada de caixas cinzentas na altura certa segura mais gente no lugar que um mecanismo lindíssimo na altura do peito.

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

Duas linhas nos avisos do ciclo, três chamadas no laço, e nenhuma linha em camada alguma.

O módulo anterior fechou com o recibo da abstração de entrada: a lista dos arquivos que não mudaram. Este módulo tem recibo parecido, e ele é o motivo de a demonstração morar na composição.

Ao abrir a sessão, o mundo se assenta contra a origem concedida. Ao fechar, volta à altura de origem e a provocação em curso se corrige. São duas linhas ao lado das duas que o módulo anterior já havia posto ali.

No laço entram três chamadas. A carga da provocação é queimada antes do trabalho do quadro, e essa ordem não é livre: queimar tempo depois de desenhar atrasaria a imagem seguinte, deslocando o efeito em relação ao que a pessoa vê acontecer. A provocação avança pelo tempo do quadro, e o contador registra o intervalo real, antes do limite de salto.

A quarta mudança é a única que toca uma linha existente, e ela é curta. A leitura das fontes de apontamento passa pela fila de atraso antes de chegar à seleção. Desligada a provocação, a lista passa direto, sem cópia — o custo de a demonstração existir precisa ser zero quando ela não está em uso.

Nenhum arquivo de interação, montagem, seleção ou sessão sabe que a demonstração existe. Foi assim que a latência pôde ser injetada sem tocar no agarre, e é essa a prova de que o lugar escolhido para ela estava certo.

1.8 O que este módulo deixa pronto

A promessa escrita sete módulos atrás foi cobrada, e a bancada tem 90 cm medidos.

A Bancada nasce ancorada no piso quando o aparelho concede o piso, e desce sozinha quando ele concede apenas a altura da cabeça. Diz qual dos dois casos ocorreu, e diz o que o segundo custa. Confere três medidas contra o que declara, e nomeia como se confere cada uma no mundo físico.

A tarefa do domínio é cumprível de dentro do ambiente. As peças estão dentro do braço de quem trabalha, o posto foi aproximado por decisão de desenho, e o que continuar fora é declarado como tal em vez de ser empurrado até a borda em vão.

O conforto deixou de ser adjetivo. Há contagem de engasgos sem janela deslizante, há três provocações com correção automática, e há a distinção entre o que este projeto conserta e o que pertence à plataforma.

Vale registrar o que não entrou, porque a lista é grande e nada nela é pendência deste módulo. Não há locomoção: quem entra alcança o que está ao alcance do braço, e deslocamento pedido é o módulo seguinte, com teleporte, giro discreto e vinheta. Não há ancoragem contra superfície real, e a entrada em realidade aumentada continua abrindo a sessão sem registrar nada no ambiente físico.

E há um limite honesto sobre a verificação, o mesmo do módulo anterior, agora mais grave. O emulador cobre o ciclo de sessão e a geometria da cena. Escala corporal, conforto e a reação do corpo a um ambiente sintético não se conferem ali, e tratá-los como verificados porque nada reclamou seria o engano que o percurso inteiro tenta desmontar.