1 Implementação e integração de ambientes virtuais e aumentados — 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

Treze módulos deixaram treze camadas. Quantas delas ainda estão ligadas?

Não sabíamos. Essa é a resposta honesta, e é o que este trecho veio resolver. Cada módulo do percurso entregou a sua camada funcionando, com o seu botão e a sua folha de diagnóstico, e cada uma foi conferida no dia em que nasceu. Nenhum deles tinha como responder pela ligação dois módulos adiante: quem escreve a camada olha para a camada, e o que se perde de vista é justamente o fio.

Este trecho não estreia camada nenhuma. Ele acrescenta cinco arquivos que perguntam ao ambiente coisas sobre ele mesmo — se tudo continua alcançável a partir do mesmo endereço, se o painel está sendo lido de onde a pessoa está, e o que se sabe sobre as decisões, os números e os aparelhos. É o módulo em que o artefato deixa de ser uma soma de demonstrações e passa a ser uma coisa só.

Duas medidas explicam por que ele existe. Ao ligar a auditoria pela primeira vez, o catálogo declarava dezoito camadas e a composição registrava dezesseis: a sonda de capacidades e o contador de engasgos estavam ligados, mas alcançáveis só pela folha de diagnóstico, e isso é legítimo — são instrumentação. O que a auditoria não achou, e é o resultado que interessa, foi camada de tarefa em caminho morto. O percurso resistiu. Se não tivesse resistido, o lugar de descobrir seria aqui, e não na demonstração final.

1.2 Tarefa 1: Compor o artefato e rotear o regime

A degradação não termina na escolha, e foi um diálogo de permissão negado que ensinou isso.

Enunciado da tarefa

Um endereço, três regimes, e a decisão de qual abrir tomada pelo próprio ambiente a partir do que o aparelho declarou. A última camada costura o que veio antes num artefato só.

O que fica pronto é a composição completa, com a degradação percorrendo a ordem declarada até o regime em janela, que sempre abre. Cada peça construída ao longo do percurso precisa estar alcançável a partir daqui, sem caminho morto e sem trecho que só rodava isolado.

Resolvemos a tarefa em duas partes, e elas atacam dois defeitos que não se parecem. O primeiro é de inventário: a camada que existe no pacote e que ninguém ligou. O segundo é de execução: o regime que a consulta prometeu e a abertura recusou.

1.2.1 O caminho morto, e o que a auditoria não pega

O verificador de tipos aprova código que nada chama. É a categoria inteira de defeito que ele não vê.

Comece pelo defeito, porque ele é discreto. Uma camada foi escrita no módulo sete, foi conferida naquela semana, e no módulo nove a composição mudou de forma. A linha que a ligava saiu junto. O arquivo continua ali, compila, analisa limpo sob o modo estrito, e não está no artefato que se entrega. Nada acusa — nem o compilador, nem o empacotador, nem a folha de diagnóstico, que continua exibindo o que aquela camada informava porque a folha lê o objeto, e o objeto continua sendo construído.

Nosso composicao.ts compara duas coisas: uma lista declarada à mão, com as dezoito camadas que o artefato deve conter, e o que a composição registra enquanto liga cada uma. Camada declarada e nunca registrada é caminho morto, e sai nomeada, com o módulo de origem junto — porque a primeira pergunta que se faz diante de um defeito é de quem ele é.

src/bancada/app/composicao.ts
// ---------------------------------------------------------------------------
// O inventário da composição, e o defeito que ele existe para pegar.
//
// Catorze módulos construíram camadas. Cada uma nasceu com a sua demonstração,
// o seu botão e a sua folha de relatório, e cada uma funcionava no dia em que
// foi escrita. O módulo final não acrescenta camada nenhuma: ele responde a uma
// pergunta que nenhum dos anteriores tinha como responder sozinho — todas elas
// continuam alcançáveis a partir do mesmo endereço?
//
// A pergunta não é retórica. Duas coisas acontecem num percurso longo, e nenhuma
// das duas dá erro de compilação. A primeira é o CAMINHO MORTO: a camada existe
// no pacote, o verificador de tipos a aprova, e nada na composição a liga — ela
// foi escrita, foi esquecida, e o artefato entregue não a contém. A segunda é o
// TRECHO ISOLADO: a camada roda, mas só a partir de um botão da folha de
// diagnóstico, e ninguém que abra o ambiente para montar o mecanismo passa por
// ela. As duas produzem a mesma frase na arguição — "isso está implementado" —
// e nas duas a demonstração ao vivo não chega lá.
//
// O que este arquivo faz é comparar uma lista DECLARADA com o que a composição
// REGISTRA ao subir. Camada declarada e não registrada é caminho morto. Camada
// de tarefa que só se alcança pela folha de diagnóstico é trecho isolado.
//
// O limite é grande e precisa estar escrito: isto prova que a camada foi LIGADA,
// não que ela funciona. Um agarre ligado e quebrado passa nesta auditoria. O que
// se ganha é outra coisa — a lista de tudo o que o artefato deveria conter, num
// lugar só, conferida por máquina a cada carregamento em vez de por memória na
// véspera da entrega.
// ---------------------------------------------------------------------------

/** Cada camada construída ao longo do percurso, pelo nome que ela tem no pacote. */
export type CamadaId =
  | 'dominio'
  | 'regimes'
  | 'sonda'
  | 'cena'
  | 'conteudo'
  | 'orbita'
  | 'apontamento'
  | 'selecao'
  | 'manipulacao'
  | 'montagem'
  | 'sessao'
  | 'corpo'
  | 'conforto'
  | 'locomocao'
  | 'ancoragem'
  | 'marcador'
  | 'degradacao'
  | 'painel';

/**
 * Por onde se chega à camada a partir do endereço único.
 *
 * A distinção entre os três valores é o coração da auditoria. `tarefa` é a
 * camada que quem monta o mecanismo atravessa sem saber que ela existe; `gesto`
 * é a que espera um botão da própria cena; `folha` é a que só se alcança pela
 * página de diagnóstico. A terceira não é ilegítima — parte do percurso é
 * instrumentação, e instrumentação vive na folha —, mas camada de tarefa que
 * caiu para `folha` é o trecho isolado que este módulo veio caçar.
 */
export type Alcance = 'tarefa' | 'gesto' | 'folha';

export interface Camada {
  readonly id: CamadaId;
  readonly nome: string;
  /** O módulo em que ela passou a existir. Serve para achar o dono do defeito. */
  readonly moduloDeOrigem: number;
  readonly alcance: Alcance;
  /** O que quem confere a olho deve ver acontecer para acreditar que ela está viva. */
  readonly comoSeVe: string;
}

/**
 * O catálogo do que o artefato deve conter.
 *
 * A lista é escrita à mão, e isso é decisão, não preguiça. Derivá-la do sistema
 * de arquivos acharia todo arquivo que existe — inclusive o que existe por
 * engano — e a auditoria passaria a confirmar o pacote contra ele mesmo. O que
 * se quer conferir é o pacote contra a INTENÇÃO, e intenção não se descobre
 * listando diretório.
 */
export const CATALOGO: readonly Camada[] = [
  {
    id: 'dominio',
    nome: 'domínio delimitado',
    moduloDeOrigem: 1,
    alcance: 'tarefa',
    comoSeVe: 'as cinco peças e os encaixes existem, e a conferência de peça órfã roda ao carregar',
  },
  {
    id: 'regimes',
    nome: 'declaração dos três regimes',
    moduloDeOrigem: 1,
    alcance: 'folha',
    comoSeVe:
      'a tabela de regimes confronta o que o projeto declarou com o que o navegador informa',
  },
  {
    id: 'sonda',
    nome: 'sonda de capacidades',
    moduloDeOrigem: 1,
    alcance: 'folha',
    comoSeVe: 'a consulta responde antes da sessão, e a sondagem completa abre e fecha uma sessão',
  },
  {
    id: 'cena',
    nome: 'árvore da cena e laço',
    moduloDeOrigem: 2,
    alcance: 'tarefa',
    comoSeVe: 'a bancada aparece e o eixo gira contra o relógio, não contra a contagem de quadros',
  },
  {
    id: 'conteudo',
    nome: 'forma, superfície e ativo importado',
    moduloDeOrigem: 3,
    alcance: 'tarefa',
    comoSeVe:
      'as peças têm volume e material próprios, e a morsa chega pela rede com a cena já de pé',
  },
  {
    id: 'orbita',
    nome: 'regime em janela',
    moduloDeOrigem: 6,
    alcance: 'tarefa',
    comoSeVe: 'arrastar o cursor gira a câmera em torno da bancada, sem sessão nenhuma aberta',
  },
  {
    id: 'apontamento',
    nome: 'abstração de apontar',
    moduloDeOrigem: 7,
    alcance: 'tarefa',
    comoSeVe: 'a mesma origem e direção chegam da fonte ativa, seja ela cursor, controle ou toque',
  },
  {
    id: 'selecao',
    nome: 'seleção por lançamento de raio',
    moduloDeOrigem: 7,
    alcance: 'tarefa',
    comoSeVe: 'a peça sob a mira acende, e o tampo oclui o que está atrás dele',
  },
  {
    id: 'manipulacao',
    nome: 'agarre e transporte',
    moduloDeOrigem: 8,
    alcance: 'tarefa',
    comoSeVe:
      'a peça acompanha a mão com a orientação preservada e volta ao tampo quando solta longe',
  },
  {
    id: 'montagem',
    nome: 'encaixe e ordem da tarefa',
    moduloDeOrigem: 8,
    alcance: 'tarefa',
    comoSeVe:
      'a peça solta dentro da folga encaixa, e a solta fora de ordem é recusada pelo nome do que falta',
  },
  {
    id: 'sessao',
    nome: 'ciclo de sessão imersiva',
    moduloDeOrigem: 9,
    alcance: 'gesto',
    comoSeVe:
      'o botão abre a sessão, o espaço de referência obtido é dito, e sair pelo menu do sistema também fecha',
  },
  {
    id: 'corpo',
    nome: 'escala corporal e alcance do braço',
    moduloDeOrigem: 10,
    alcance: 'tarefa',
    comoSeVe:
      'dentro do visor a bancada tem altura de bancada, e as peças caem dentro do braço de quem trabalha',
  },
  {
    id: 'conforto',
    nome: 'contador de engasgos e provocações',
    moduloDeOrigem: 10,
    alcance: 'folha',
    comoSeVe:
      'as três provocações declaradas se corrigem sozinhas no prazo, e o pior intervalo da sessão fica registrado',
  },
  {
    id: 'locomocao',
    nome: 'salto, giro e borda física',
    moduloDeOrigem: 11,
    alcance: 'tarefa',
    comoSeVe:
      'o arco abre com o comando para a frente e o salto se confirma na soltura, com a peça na mão indo junto',
  },
  {
    id: 'ancoragem',
    nome: 'consulta de superfície, âncora e perda de rastreamento',
    moduloDeOrigem: 12,
    alcance: 'tarefa',
    comoSeVe:
      'na sessão aumentada o retículo encontra a mesa, o primeiro acionamento pousa a bancada, e a perda congela em vez de continuar',
  },
  {
    id: 'marcador',
    nome: 'registro por marcador impresso',
    moduloDeOrigem: 13,
    alcance: 'gesto',
    comoSeVe:
      'com a câmera ligada e o papel na mesa, a bancada nasce sobre ele — tremendo, com o tremor medido',
  },
  {
    id: 'degradacao',
    nome: 'ordem de preferência e mensagem honesta',
    moduloDeOrigem: 13,
    alcance: 'tarefa',
    comoSeVe:
      'ao carregar, o ambiente diz qual regime este aparelho alcança e o que falta nos que ele não alcança',
  },
  {
    id: 'painel',
    nome: 'painel diegético',
    moduloDeOrigem: 14,
    alcance: 'tarefa',
    comoSeVe:
      'o cartaz preso à bancada informa o passo, o aparelho e o custo, e continua legível de dentro do visor',
  },
];

export interface Auditoria {
  readonly declaradas: number;
  readonly registradas: number;
  /** Declaradas e nunca registradas: o caminho morto. */
  readonly mortas: readonly Camada[];
  /** Instrumentação, alcançável só pela folha de diagnóstico. */
  readonly naFolha: readonly Camada[];
  /** Registradas sem constar do catálogo: alguém ligou o que ninguém declarou. */
  readonly forasteiras: readonly CamadaId[];
}

/**
 * O registro que a composição alimenta enquanto liga cada camada.
 *
 * A chamada fica ao lado da linha que de fato compõe a camada, e não numa lista
 * no fim do arquivo. Lista no fim é a primeira coisa a divergir do que o código
 * faz, e uma auditoria que confere a lista contra a lista não confere nada.
 */
export class Composicao {
  private readonly ligadas: Set<CamadaId> = new Set<CamadaId>();

  registrar(id: CamadaId): void {
    this.ligadas.add(id);
  }

  registrada(id: CamadaId): boolean {
    return this.ligadas.has(id);
  }

  auditar(): Auditoria {
    const mortas: Camada[] = CATALOGO.filter((camada) => !this.ligadas.has(camada.id));
    const naFolha: Camada[] = CATALOGO.filter(
      (camada) => this.ligadas.has(camada.id) && camada.alcance === 'folha',
    );
    const conhecidas: Set<CamadaId> = new Set<CamadaId>(CATALOGO.map((camada) => camada.id));
    const forasteiras: CamadaId[] = [...this.ligadas].filter((id) => !conhecidas.has(id));
    return {
      declaradas: CATALOGO.length,
      registradas: this.ligadas.size,
      mortas,
      naFolha,
      forasteiras,
    };
  }

  linhas(): string[] {
    const auditoria: Auditoria = this.auditar();
    const linhas: string[] = [
      `Camadas declaradas: ${auditoria.declaradas}. Ligadas nesta composição: ${auditoria.registradas}.`,
      '',
    ];

    for (const camada of CATALOGO) {
      const estado: string = this.ligadas.has(camada.id) ? 'ligada' : 'NÃO LIGADA';
      linhas.push(
        `[${estado}] ${camada.nome} (módulo ${camada.moduloDeOrigem}, alcance: ${camada.alcance}) — ${camada.comoSeVe}.`,
      );
    }

    linhas.push('');
    if (auditoria.mortas.length === 0) {
      linhas.push('Nenhum caminho morto: toda camada declarada foi ligada por esta composição.');
    } else {
      linhas.push(
        'CAMINHO MORTO. As camadas abaixo existem no pacote e não foram ligadas. Elas não estão ' +
          'no artefato, embora o verificador de tipos as aprove:',
      );
      for (const camada of auditoria.mortas) {
        linhas.push(`  · ${camada.nome}, do módulo ${camada.moduloDeOrigem}.`);
      }
    }

    linhas.push(
      '',
      `Alcançáveis só pela folha de diagnóstico: ${auditoria.naFolha.length}. São instrumentação, ` +
        'e é onde instrumentação deve estar; a conferência a fazer é outra — que nenhuma camada de ' +
        'tarefa tenha caído para cá.',
    );

    if (auditoria.forasteiras.length > 0) {
      linhas.push(
        '',
        `Ligadas sem constar do catálogo: ${auditoria.forasteiras.join(', ')}. Ou a camada é nova e ` +
          'o catálogo ficou para trás, ou o nome foi digitado errado.',
      );
    }

    linhas.push(
      '',
      'O que esta auditoria NÃO diz: que as camadas funcionam. Ela confere ligação, não ' +
        'comportamento. Um agarre ligado e quebrado passa aqui inteiro, e é por isso que o que se ' +
        'vê acontecer está escrito ao lado de cada linha — é o roteiro da conferência a olho, que ' +
        'continua sendo de gente.',
    );
    return linhas;
  }
}

Duas decisões dentro dele merecem defesa. A lista é escrita à mão em vez de ser derivada da pasta de arquivos, e isso parece preguiça invertida — derivar seria automático. Seria automático e inútil: a varredura acharia todo arquivo que existe, inclusive o que existe por engano, e a auditoria passaria a conferir o pacote contra ele mesmo. O que se quer conferir é o pacote contra a intenção, e intenção não se descobre listando diretório.

A segunda é que o registro fica ao lado da linha que de fato liga a camada, e não numa lista no fim do arquivo de composição. Lista no fim é a primeira coisa a divergir do código, e uma auditoria que confere a lista contra a lista não confere coisa alguma.

O limite é grande e está escrito no topo do arquivo, porque quem lê a saída vai querer confiar mais nela do que ela merece. A auditoria prova ligação, não comportamento. Um agarre ligado e quebrado passa por ela inteiro. É por isso que cada linha do catálogo traz o que se deve ver acontecer para acreditar que aquela camada está viva — essa coluna é o roteiro da conferência a olho, e essa continua sendo de gente.

1.2.2 Entre escolher o regime e conseguir abri-lo

A consulta disse que sim. O diálogo de permissão disse que não. Quem tem razão?

O trecho anterior escolhe: dada a lista do que o aparelho respondeu, a ordem de preferência aponta o melhor regime disponível. Escolher é barato e acontece ao carregar. Abrir é outra coisa — pede gesto de quem usa, demora, e pode falhar depois de a escolha ter sido dada como certa.

Entre uma coisa e outra cabem quatro casos, e os quatro foram vistos no laboratório: a permissão de câmera negada no diálogo, a sessão imersiva recusada porque outra aba já a tinha, o visor que respondeu “suporto realidade aumentada” e devolveu erro ao pedido, e o aparelho que entrou em sessão e saiu sozinho. Nos quatro, a sonda tinha respondido certo. O que falhou foi a abertura.

src/bancada/app/roteamento.ts
// ---------------------------------------------------------------------------
// A diferença entre escolher e abrir.
//
// O módulo anterior escolheu: dada a lista do que o aparelho respondeu, a ordem
// de preferência diz qual regime é o melhor disponível aqui. Escolher é barato e
// acontece ao carregar. Abrir é outra coisa — pede gesto de quem usa, pode
// demorar, e PODE FALHAR depois de ter sido dada como certa.
//
// Entre a escolha e a abertura cabem quatro coisas verificadas em laboratório: a
// permissão de câmera negada no diálogo, a sessão imersiva recusada porque outra
// aba já a tinha, o visor que respondeu "suporto realidade aumentada" e devolveu
// erro ao pedido, e o aparelho que entrou em sessão e saiu sozinho por bateria.
// Nos quatro casos a sonda tinha respondido certo. O que falhou foi a abertura.
//
// Por isso a degradação não termina na escolha. Ela percorre a ordem declarada
// PARA BAIXO, tentando abrir, e cada recusa é anotada com o motivo que o
// navegador deu — até chegar ao regime em janela, que não se abre porque já está
// aberto desde o carregamento. Esse é o sentido preciso de "sempre abre": não é
// que ele nunca falhe, é que ele não depende de abertura alguma.
//
// O percurso inteiro é devolvido, e não só o destino. Quem apresenta o trabalho
// precisa poder dizer por que está no terceiro regime da lista, e "o aparelho
// não deixou" não é resposta enquanto não vier acompanhada do que ele respondeu.
// ---------------------------------------------------------------------------

import {
  ORDEM_DE_PREFERENCIA,
  type Escolha,
  type PosicaoDaOrdem,
  type RegimeExecutavel,
} from './degradacao';

/**
 * Como se abre um regime, e como se confirma que ele abriu.
 *
 * Os dois membros existem porque nem toda abertura falha por exceção. O registro
 * por marcador resolve a promessa mesmo quando a câmera não veio: ele trata a
 * recusa como estado, não como erro, e faz bem — quem negou a permissão não
 * cometeu um erro de programa. Sem `abriu()`, o roteamento acreditaria na
 * promessa cumprida e pararia num regime que não está de pé.
 */
export interface Abertura {
  abrir(): Promise<void>;
  abriu(): boolean;
  /** O que dizer quando `abriu()` for falso sem exceção nenhuma ter sido lançada. */
  motivoDaRecusa(): string;
}

export type ResultadoDaTentativa = 'aberto' | 'recusado' | 'fora de alcance' | 'já estava de pé';

export interface Tentativa {
  readonly regime: RegimeExecutavel;
  readonly nome: string;
  readonly resultado: ResultadoDaTentativa;
  /** Vazio quando o regime abriu. Nunca vazio quando não abriu. */
  readonly motivo: string;
}

export interface Percurso {
  readonly regimeAberto: RegimeExecutavel;
  readonly nome: string;
  readonly tentativas: readonly Tentativa[];
  /** Verdadeiro quando a abertura falhou onde a escolha tinha dado certo. */
  readonly degradouNaAbertura: boolean;
}

function posicaoDe(regime: RegimeExecutavel): number {
  return ORDEM_DE_PREFERENCIA.findIndex((item) => item.regime === regime);
}

/**
 * Traduz o que o navegador lançou numa frase que se lê no aparelho.
 *
 * Quem está de visor no rosto não abre console de depuração, e a mensagem crua
 * de uma promessa rejeitada costuma ser uma palavra só. A tradução acrescenta o
 * que fazer, porque a metade que falta numa mensagem de erro é sempre essa.
 */
function explicar(erro: unknown, posicao: PosicaoDaOrdem): string {
  const cru: string = erro instanceof Error ? erro.message : String(erro);
  if (cru.includes('NotAllowedError') || cru.includes('permission')) {
    return (
      `${cru} — a permissão foi negada, ou o pedido não veio de um toque. Toque no botão de novo ` +
      'e aceite o pedido do navegador.'
    );
  }
  if (cru.includes('InvalidStateError')) {
    return `${cru} — já há uma sessão aberta neste aparelho, possivelmente em outra aba.`;
  }
  return `${cru} — o que falta, segundo a ordem declarada, é ${posicao.oQueFalta}.`;
}

/**
 * Desce a ordem de preferência abrindo, e devolve o percurso inteiro.
 *
 * O laço começa na posição da escolha, e não no topo: os regimes acima dela já
 * foram descartados pela sonda, e tentar abrir o que o aparelho declarou não
 * suportar produziria um diálogo de permissão para um regime impossível. Eles
 * entram no percurso como `fora de alcance`, com o motivo que a ordem declarada
 * já trazia escrito.
 */
export async function abrirDescendo(
  escolha: Escolha,
  aberturas: ReadonlyMap<RegimeExecutavel, Abertura>,
): Promise<Percurso> {
  const tentativas: Tentativa[] = [];

  for (const preterido of escolha.preteridos) {
    tentativas.push({
      regime: preterido.regime,
      nome: preterido.nome,
      resultado: 'fora de alcance',
      motivo: `o que falta é ${preterido.oQueFalta}`,
    });
  }

  const inicio: number = posicaoDe(escolha.regime);
  for (let indice: number = inicio; indice < ORDEM_DE_PREFERENCIA.length; indice += 1) {
    const posicao: PosicaoDaOrdem | undefined = ORDEM_DE_PREFERENCIA[indice];
    if (posicao === undefined) {
      break;
    }

    const abertura: Abertura | undefined = aberturas.get(posicao.regime);
    if (abertura === undefined) {
      // Sem abertura registrada, o regime é o que já está de pé. É o caso da
      // janela, e é deliberado que ele não tenha uma: um regime que precisasse
      // ser aberto para servir de destino da degradação não serviria de destino.
      tentativas.push({
        regime: posicao.regime,
        nome: posicao.nome,
        resultado: 'já estava de pé',
        motivo: '',
      });
      return {
        regimeAberto: posicao.regime,
        nome: posicao.nome,
        tentativas,
        degradouNaAbertura: indice > inicio,
      };
    }

    try {
      await abertura.abrir();
    } catch (erro: unknown) {
      tentativas.push({
        regime: posicao.regime,
        nome: posicao.nome,
        resultado: 'recusado',
        motivo: explicar(erro, posicao),
      });
      continue;
    }

    if (!abertura.abriu()) {
      tentativas.push({
        regime: posicao.regime,
        nome: posicao.nome,
        resultado: 'recusado',
        motivo: abertura.motivoDaRecusa(),
      });
      continue;
    }

    tentativas.push({
      regime: posicao.regime,
      nome: posicao.nome,
      resultado: 'aberto',
      motivo: '',
    });
    return {
      regimeAberto: posicao.regime,
      nome: posicao.nome,
      tentativas,
      degradouNaAbertura: indice > inicio,
    };
  }

  // Inalcançável enquanto a janela for a última posição e não tiver abertura
  // registrada. O retorno existe porque a lista é editável, e um ambiente sem
  // destino de degradação precisa dizer isso em vez de devolver ausência.
  return {
    regimeAberto: 'janela',
    nome: 'realidade virtual não imersiva',
    tentativas,
    degradouNaAbertura: true,
  };
}

/** O percurso em texto, para a folha e para o diário. */
export function linhasDoPercurso(percurso: Percurso | undefined): string[] {
  if (percurso === undefined) {
    return [
      'Nenhuma abertura foi pedida ainda. O ambiente está no regime em janela, que sobe com a ' +
        'página; entrar num regime imersivo ou ligar a câmera exige um toque, e o navegador ' +
        'recusa qualquer um dos dois sem ele.',
    ];
  }

  const linhas: string[] = [`Regime em uso: ${percurso.nome}.`, ''];
  for (const tentativa of percurso.tentativas) {
    const cauda: string = tentativa.motivo === '' ? '' : ` — ${tentativa.motivo}`;
    linhas.push(`${tentativa.nome}: ${tentativa.resultado}${cauda}.`);
  }

  linhas.push('');
  if (percurso.degradouNaAbertura) {
    linhas.push(
      'A degradação aconteceu NA ABERTURA, e não na consulta: a sonda disse que este aparelho ' +
        'alcançava um regime acima, e a abertura foi recusada. É o caso mais confuso de todos ' +
        'para quem apresenta o trabalho, e por isso o motivo da recusa está escrito acima em vez ' +
        'de ser resumido como falta de suporte.',
    );
  } else {
    linhas.push(
      'A abertura correspondeu à consulta: o regime que o aparelho declarou alcançar foi o que ' +
        'de fato abriu.',
    );
  }
  return linhas;
}

Por isso a degradação continua descendo a ordem declarada, tentando abrir, até chegar ao regime em janela. Repare no mecanismo que faz o destino ser destino: a janela é o único regime sem abertura registrada, e o laço para no primeiro que não tem uma. É a ausência funcionando como decisão. Um regime que precisasse ser aberto para servir de destino da degradação não serviria de destino, e essa é a diferença entre “sempre abre” e “não depende de abrir”.

A segunda decisão do arquivo custou uma tarde de depuração. Nem toda abertura falha por exceção: o registro por marcador cumpre a promessa mesmo quando a câmera não veio, porque ele trata a recusa como estado e não como erro — e faz bem, já que quem negou a permissão não cometeu falha de programa. Sem o segundo membro da interface, o abriu(), o roteamento acreditaria na promessa cumprida e pararia num regime que não está de pé, com a tela mostrando a bancada em fundo cinza e ninguém sabendo por quê.

O que se devolve é o percurso inteiro, e não só o destino. Quem apresenta o trabalho precisa poder dizer por que está no terceiro regime da lista, e “o aparelho não deixou” não é resposta enquanto não vier com o que ele respondeu.

1.3 Tarefa 2: Colocar o painel dentro da cena

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

Enunciado da tarefa

A informação sobre o próprio ambiente passa a viver dentro dele: o passo atual da tarefa, o aparelho detectado, as capacidades concedidas e o custo do quadro. Painel preso à janela do navegador desaparece assim que a sessão imersiva começa, justamente quando ele faria mais falta.

A tarefa fecha quando o painel é legível nos três regimes, ancorado a algo que existe na cena. Ele fecha o arco aberto pela sonda de capacidades e transforma o relatório do início em instrumento de uso permanente.

O cartaz preso à bancada existe desde os primeiros módulos, e até aqui informava uma coisa só: o custo do quadro. Fazia sentido — ele nasceu no módulo em que o orçamento era o assunto. O que este trecho descobriu é que a ordem estava invertida.

1.3.1 A ordem de descarte

Seis linhas e meio metro de largura, para quem está com as mãos ocupadas.

A folha de diagnóstico da página tem rolagem e centenas de linhas. O painel tem seis linhas, e quem o lê está montando um mecanismo. É outra seleção, feita sob restrição, e a restrição é o que dá ao informe.ts a única decisão que ele toma.

src/bancada/ui/informe.ts
// ---------------------------------------------------------------------------
// O que cabe num cartaz de bancada.
//
// A folha de diagnóstico da página tem rolagem e centenas de linhas. O painel
// diegético tem seis linhas e meio metro de largura, e quem o lê está com as
// mãos ocupadas montando um mecanismo. Não é a mesma informação em corpo menor:
// é outra seleção, feita sob restrição, e a restrição é o que dá a este arquivo
// a única decisão que ele toma — a ORDEM DE DESCARTE.
//
// A ordem está escrita abaixo e é defensável linha a linha. O que ela não é: o
// resultado de encher o painel na ordem em que os números foram sendo escritos
// ao longo do percurso. Essa foi a primeira versão, e ela punha o custo do
// quadro no topo porque o orçamento era o assunto do módulo em que o painel
// nasceu. Quem estava montando o mecanismo lia dois números de milissegundos e
// não achava o nome da peça que faltava.
// ---------------------------------------------------------------------------

/**
 * O que cada fonte do ambiente entrega ao painel.
 *
 * Os campos chegam prontos. O painel não sabe de onde vêm os números, e este
 * arquivo não sabe como eles foram medidos — quem os apresenta não é quem os
 * produz, e essa separação é a mesma desde o módulo em que o painel nasceu.
 */
export interface FonteDoInforme {
  /** O passo atual da tarefa, em uma frase. É o motivo de alguém estar aqui. */
  readonly passo: string;
  /** O aparelho e o regime em uso, como o ambiente os detectou. */
  readonly aparelho: string;
  /** O que a sessão concedeu, e o que ela negou. Vazio fora de sessão. */
  readonly capacidades: readonly string[];
  /** O custo do quadro, já resumido pela camada que o mede. */
  readonly custo: readonly string[];
  /**
   * O aviso do instante: perda de rastreamento, degradação na abertura,
   * provocação de desconforto em curso. Passa à frente de tudo quando existe.
   */
  readonly aviso: string | undefined;
}

/**
 * Quantas linhas semânticas o painel recebe.
 *
 * O número não saiu de cálculo. Saiu de medir: com a altura de texto que se lê a
 * um metro e meio de distância dentro do visor — a distância de quem está em pé
 * diante da bancada —, cabem seis linhas na superfície de 62 por 34 centímetros.
 * A sétima entra e não se lê. O painel ainda quebra linha comprida em duas, e é
 * por isso que este número é o teto do que se ENTREGA, não do que se desenha.
 */
export const LINHAS_UTEIS: number = 6;

/**
 * Compõe o informe na ordem de prioridade, e corta o que não couber.
 *
 * A ordem, com a razão de cada posição:
 *
 * 1. O AVISO, quando existe. Ele descreve uma situação em que o ambiente está
 *    fazendo algo que não parece: congelou, degradou, está provocando um caso
 *    ruim de propósito. Sem ele no topo, quem lê conclui defeito.
 * 2. O PASSO da tarefa. É a razão de a pessoa estar ali, e é a única linha que
 *    ela consulta enquanto trabalha em vez de quando algo dá errado.
 * 3. A CAPACIDADE QUE FALTA, e só ela. A capacidade concedida não precisa ser
 *    dita: ela se manifesta funcionando. A negada explica um comportamento que
 *    de outro modo pareceria defeito, e essa assimetria é a razão de este bloco
 *    vir antes do custo.
 * 4. O CUSTO do quadro. Assunto de quem constrói, não de quem monta — mas é o
 *    número que some quando se entra no visor, e o visor é onde ele importa.
 * 5. O APARELHO. Último porque é o que menos muda: quem está com o visor no
 *    rosto já sabe qual visor é. Ele existe para a foto da apresentação e para
 *    o registro de em que aparelho a medida foi feita.
 */
export function compor(fonte: FonteDoInforme): readonly string[] {
  const linhas: string[] = [];

  if (fonte.aviso !== undefined) {
    linhas.push(fonte.aviso);
  }
  linhas.push(fonte.passo);
  for (const capacidade of fonte.capacidades) {
    linhas.push(capacidade);
  }
  for (const linha of fonte.custo) {
    linhas.push(linha);
  }
  linhas.push(fonte.aparelho);

  if (linhas.length <= LINHAS_UTEIS) {
    return linhas;
  }
  // O corte é anunciado, e não silencioso. Painel que corta sem dizer ensina que
  // aquilo é tudo o que existe, e a folha de diagnóstico deixa de ser procurada
  // justamente por quem mais precisaria dela.
  const cortadas: number = linhas.length - (LINHAS_UTEIS - 1);
  return [...linhas.slice(0, LINHAS_UTEIS - 1), `(+${cortadas} na folha de diagnóstico)`];
}

/** O passo atual em uma frase, a partir do que a montagem informa. */
export function frasePasso(
  encaixadas: number,
  total: number,
  proxima: string | undefined,
): string {
  if (encaixadas >= total) {
    return `Montagem concluída: ${encaixadas} de ${total}. O mecanismo está girando.`;
  }
  if (proxima === undefined) {
    return `Encaixadas ${encaixadas} de ${total}. Nenhuma peça está liberada — desencaixe uma para seguir.`;
  }
  return `Passo ${encaixadas + 1} de ${total}: agora vai a ${proxima}.`;
}

A primeira versão do informe punha o custo do quadro no topo, e a razão era só de hábito: o orçamento era o assunto do módulo em que o painel nasceu. Quem estava montando lia dois números de milissegundos e não achava o nome da peça que faltava. A ordem que ficou é outra — o aviso do instante, o passo da tarefa, a capacidade que falta, o custo, e o aparelho por último.

Duas posições dessa lista são discutíveis, e por isso estão justificadas no arquivo. O aviso vem antes de tudo porque ele descreve situações em que o ambiente faz algo que não parece: congelou por perda de referência, degradou na abertura, está provocando um caso ruim de propósito. Sem ele no topo, quem lê conclui defeito.

A outra é a assimetria entre capacidade concedida e capacidade negada. Só a negada aparece. A concedida não precisa ser dita — ela se manifesta funcionando —, enquanto a negada explica um comportamento que de outro modo pareceria erro de programa. É a mesma lógica da mensagem honesta do trecho anterior, aplicada a um espaço vinte vezes menor.

E o corte é anunciado. Quando o informe não cabe, a última linha diz quantas ficaram na folha, em vez de simplesmente truncar. Painel que corta em silêncio ensina que aquilo é tudo o que existe, e a folha de diagnóstico deixa de ser procurada por quem mais precisaria dela.

1.3.2 Encarar sem se soltar da bancada

O cartaz plano visto de lado é uma linha.

O painel nasceu no regime em janela, onde a câmera orbita a bancada e sempre olha para ela. Nos outros regimes isso deixa de valer, e o primeiro ensaio com o visor produziu três defeitos de uma vez: quem contorna a bancada perde o painel inteiro, texto de vinte e quatro pixels a três metros não se lê, e linha comprida saía pela borda direita sem aviso.

src/bancada/ui/painel.ts
// ---------------------------------------------------------------------------
// A primeira pedra do painel diegético.
//
// No módulo anterior o relatório saía em página comum, e a razão declarada era
// que ainda não havia mundo onde pendurá-lo. Agora há. O painel passa a ser um
// objeto da cena, preso à bancada, lido de dentro do ambiente — que é a única
// forma de um número chegar a quem está com o visor no rosto.
//
// A implementação é a mais simples que sustenta o requisito: uma superfície
// plana com uma textura desenhada por nós, redesenhada algumas vezes por
// segundo. Duas razões para não redesenhar a cada quadro. A primeira é custo:
// enviar textura para a placa é das operações mais caras do quadro, e um painel
// que estoura o orçamento enquanto informa o orçamento seria uma piada de mau
// gosto. A segunda é legibilidade: número que muda sessenta vezes por segundo
// não se lê, borra.
//
// ---------------------------------------------------------------------------
// O que o módulo final acrescentou, e por quê.
//
// O painel nasceu no regime em janela, onde a câmera orbita a bancada e sempre
// olha para ela. Nos outros dois regimes isso deixa de valer, e três defeitos
// apareceram no primeiro ensaio com o visor:
//
// 1. O cartaz plano visto de lado é uma linha. Quem contorna a bancada perde o
//    painel inteiro. A correção é girá-lo em torno do próprio eixo vertical para
//    encarar quem lê — e SÓ em torno dele: inclinar o cartaz para acompanhar a
//    cabeça de quem se abaixa faz um objeto preso à mesa parecer solto no ar, e
//    a única coisa que o painel não pode perder é o pertencimento à bancada.
// 2. Texto de 24 pixels a três metros não se lê. O painel não pode crescer sem
//    deixar de ser um cartaz de bancada, e não pode encolher a distância sem
//    mentir sobre a escala — então ele MEDE a própria legibilidade e diz quando
//    ela caiu, em vez de fingir que está sendo lido.
// 3. Linha comprida saía pela borda direita sem aviso. Agora ela quebra, e o que
//    não couber é anunciado como corte em vez de sumir.
//
// A quarta correção é o `chamar()`: no celular, em sessão aumentada, a bancada
// pousa onde a mesa está e o painel pode ficar de costas, atrás dela. Trazê-lo
// para a borda mais próxima de quem lê é gesto explícito, e não automático —
// painel que se move sozinho é a coisa que mais rápido deixa de parecer objeto.
// ---------------------------------------------------------------------------

import {
  CanvasTexture,
  LinearFilter,
  Mesh,
  MeshBasicMaterial,
  Object3D,
  PlaneGeometry,
  Vector3,
} from 'three';

/** Largura do painel em metros. Um cartaz de bancada, não um outdoor. */
const LARGURA_M: number = 0.62;
const ALTURA_M: number = 0.34;

/** Resolução da textura, em pixels. Múltiplo da proporção física acima. */
const LARGURA_PX: number = 620;
const ALTURA_PX: number = 340;

/** Intervalo mínimo entre redesenhos, em segundos. */
const INTERVALO_DE_REDESENHO: number = 0.25;

/** Corpo do texto das linhas, em pixels da textura. */
const CORPO_PX: number = 24;
/** Altura de linha, em pixels da textura. */
const ENTRELINHA_PX: number = 34;
/** Margem lateral, em pixels da textura. Vale dos dois lados. */
const MARGEM_PX: number = 24;

/**
 * Abaixo de quantos minutos de arco o texto deixa de ser lido.
 *
 * É DECISÃO DE PROJETO, não norma citada. O valor saiu do único ensaio que se
 * pôde fazer sem depender do rodízio dos três visores: no desktop, afastando o
 * ponto de vista até o texto do painel parar de ser lido por três pessoas
 * diferentes com a mesma fonte e o mesmo corpo. Deu por volta de vinte minutos
 * de arco, e vinte é o que está aqui.
 *
 * O ensaio dentro do visor CONTINUA A FAZER. Ele pode mover este número, porque
 * a densidade de pixels do visor e a distorção da lente não entram na conta do
 * ângulo — e é por isso que o painel informa o valor medido ao lado do limiar,
 * em vez de devolver apenas "legível" ou "ilegível".
 */
export const LIMIAR_DE_LEITURA_MINUTOS: number = 20;

/** O que o painel responde sobre a própria leitura, no instante da consulta. */
export interface Legibilidade {
  readonly distanciaM: number;
  readonly alturaAparenteMinutos: number;
  readonly legivel: boolean;
  /** Quantas linhas o último desenho não conseguiu mostrar. */
  readonly linhasCortadas: number;
}

export interface Painel {
  readonly no: Mesh;
  /** Entrega o texto do painel. O redesenho só acontece quando vale a pena. */
  atualizar(linhas: readonly string[], decorrido: number): void;
  /**
   * Gira o painel em torno do eixo vertical para encarar quem lê. Chamada a
   * cada quadro; a conta é de três linhas e não entra no orçamento de forma
   * mensurável.
   */
  encarar(observador: Object3D): void;
  /** Mede a própria leitura a partir de onde o observador está agora. */
  legibilidade(observador: Object3D): Legibilidade;
  /** Traz o painel para a borda da bancada voltada a quem lê. Gesto explícito. */
  chamar(observador: Object3D): void;
  /** Devolve o painel ao lugar de origem sobre o tampo. */
  devolver(): void;
  chamado(): boolean;
}

export function montarPainel(titulo: string): Painel {
  const tela: HTMLCanvasElement = document.createElement('canvas');
  tela.width = LARGURA_PX;
  tela.height = ALTURA_PX;

  const contexto: CanvasRenderingContext2D | null = tela.getContext('2d');
  if (contexto === null) {
    throw new Error('Este navegador não fornece contexto de desenho para o painel.');
  }
  // A cópia em constante não é adorno: a checagem acima estreita o tipo aqui, e
  // esse estreitamento não sobrevive à entrada na função de desenho.
  const pincel: CanvasRenderingContext2D = contexto;

  const textura: CanvasTexture = new CanvasTexture(tela);
  // Sem os níveis intermediários de detalhe, texto lido de longe cintila; com o
  // filtro linear ele apenas borra, que é o defeito menos ruim dos dois.
  textura.minFilter = LinearFilter;
  textura.generateMipmaps = false;

  const no: Mesh = new Mesh(
    new PlaneGeometry(LARGURA_M, ALTURA_M),
    new MeshBasicMaterial({ map: textura }),
  );
  no.name = 'painel';
  // Material sem iluminação de propósito: painel que escurece quando a luz muda
  // de lugar deixa de ser instrumento de leitura.
  no.position.y = ALTURA_M / 2;

  const posicaoDeOrigem: Vector3 = no.position.clone();

  let ultimoDesenho: number = Number.NEGATIVE_INFINITY;
  let ultimoTexto: string = '';
  let cortadas: number = 0;
  let chamado: boolean = false;

  const doPainel: Vector3 = new Vector3();
  const doObservador: Vector3 = new Vector3();

  /**
   * Quebra a linha que não cabe na largura útil.
   *
   * A medida é feita com a régua do próprio contexto de desenho, e não por
   * contagem de caracteres: a fonte é proporcional, e "iiii" e "MMMM" têm o
   * mesmo número de letras e larguras que diferem em três vezes. Contagem de
   * caracteres é o atalho que faz o painel cortar cedo demais em umas linhas e
   * transbordar em outras.
   */
  function quebrar(linha: string, larguraUtil: number): string[] {
    if (pincel.measureText(linha).width <= larguraUtil) {
      return [linha];
    }
    const partes: string[] = [];
    let atual: string = '';
    for (const palavra of linha.split(' ')) {
      const tentativa: string = atual === '' ? palavra : `${atual} ${palavra}`;
      if (pincel.measureText(tentativa).width <= larguraUtil) {
        atual = tentativa;
        continue;
      }
      if (atual !== '') {
        partes.push(atual);
      }
      atual = palavra;
    }
    if (atual !== '') {
      partes.push(atual);
    }
    return partes;
  }

  function desenhar(linhas: readonly string[]): void {
    pincel.fillStyle = '#11131a';
    pincel.fillRect(0, 0, LARGURA_PX, ALTURA_PX);
    pincel.fillStyle = '#f2f4fa';
    pincel.font = `bold ${CORPO_PX + 6}px system-ui, sans-serif`;
    pincel.fillText(titulo, MARGEM_PX, 52);

    pincel.font = `${CORPO_PX}px system-ui, sans-serif`;
    const larguraUtil: number = LARGURA_PX - MARGEM_PX * 2;

    const quebradas: string[] = [];
    for (const linha of linhas) {
      quebradas.push(...quebrar(linha, larguraUtil));
    }

    let linhaY: number = 100;
    let escritas: number = 0;
    for (const linha of quebradas) {
      if (linhaY > ALTURA_PX - MARGEM_PX) {
        break;
      }
      pincel.fillText(linha, MARGEM_PX, linhaY);
      linhaY += ENTRELINHA_PX;
      escritas += 1;
    }
    cortadas = quebradas.length - escritas;
    textura.needsUpdate = true;
  }

  desenhar([]);

  function atualizar(linhas: readonly string[], decorrido: number): void {
    const texto: string = linhas.join('\n');
    if (decorrido - ultimoDesenho < INTERVALO_DE_REDESENHO || texto === ultimoTexto) {
      return;
    }
    ultimoDesenho = decorrido;
    ultimoTexto = texto;
    desenhar(linhas);
  }

  function distanciaAte(observador: Object3D): number {
    no.getWorldPosition(doPainel);
    observador.getWorldPosition(doObservador);
    return doPainel.distanceTo(doObservador);
  }

  function encarar(observador: Object3D): void {
    no.getWorldPosition(doPainel);
    observador.getWorldPosition(doObservador);
    // Só o componente horizontal entra na conta. Zerar a altura ANTES de mirar é
    // o que mantém o cartaz de pé: mirando o ponto inteiro, ele se inclinaria
    // para acompanhar quem se abaixa, e um objeto preso à mesa não faz isso.
    doObservador.y = doPainel.y;
    no.lookAt(doObservador);
  }

  // diagrama:adiado quem exercita e o regime imersivo, pela composicao do ambiente
  function legibilidade(observador: Object3D): Legibilidade {
    const distancia: number = distanciaAte(observador);
    // Altura física de uma letra: a proporção que ela ocupa na textura, aplicada
    // à altura real da superfície.
    const alturaDaLetraM: number = (CORPO_PX / ALTURA_PX) * ALTURA_M;
    // Ângulo subtendido, em minutos de arco. A distância mínima evita a divisão
    // por zero de quem encosta o rosto no painel dentro do visor — que acontece.
    const radianos: number = 2 * Math.atan(alturaDaLetraM / (2 * Math.max(distancia, 0.05)));
    const minutos: number = (radianos * 180 * 60) / Math.PI;
    return {
      distanciaM: distancia,
      alturaAparenteMinutos: minutos,
      legivel: minutos >= LIMIAR_DE_LEITURA_MINUTOS,
      linhasCortadas: cortadas,
    };
  }

  // diagrama:adiado quem exercita e o regime imersivo, pela composicao do ambiente
  function chamar(observador: Object3D): void {
    // O painel anda no espaço do próprio pai — o suporte preso ao tampo —, e é
    // por isso que a bancada continua sendo o dono dele depois de chamado. Um
    // painel que se soltasse do suporte para ir até quem lê deixaria de ser
    // diegético no instante em que fosse mais útil.
    const pai: Object3D | null = no.parent;
    if (pai === null) {
      return;
    }
    observador.getWorldPosition(doObservador);
    pai.worldToLocal(doObservador);
    doObservador.y = posicaoDeOrigem.y;
    // Meio metro à frente de quem lê, na direção em que ele está, e não em cima
    // dele: colado no rosto o cartaz fica perto demais para ser lido e atrapalha
    // a mão que trabalha.
    const direcao: Vector3 = doObservador.clone().setY(0);
    if (direcao.lengthSq() < 1e-6) {
      return;
    }
    direcao.normalize().multiplyScalar(0.45);
    no.position.set(direcao.x, posicaoDeOrigem.y, direcao.z);
    chamado = true;
  }

  function devolver(): void {
    no.position.copy(posicaoDeOrigem);
    chamado = false;
  }

  return {
    no,
    atualizar,
    encarar,
    legibilidade,
    chamar,
    devolver,
    chamado(): boolean {
      return chamado;
    },
  };
}

/** A medida da leitura em texto, para a folha e para o diário. */
// diagrama:adiado quem exercita e o regime imersivo, pela composicao do ambiente
export function linhasDaLegibilidade(medida: Legibilidade): string[] {
  const linhas: string[] = [
    `Distância de quem lê até o painel: ${medida.distanciaM.toFixed(2)} m.`,
    `Altura aparente da letra: ${medida.alturaAparenteMinutos.toFixed(1)} minutos de arco ` +
      `(limiar adotado: ${LIMIAR_DE_LEITURA_MINUTOS}).`,
    medida.legivel
      ? 'Acima do limiar: o texto está sendo lido desta distância.'
      : 'ABAIXO do limiar: daqui o painel é um borrão. Aproxime-se, ou chame o painel para a ' +
        'borda da bancada — ele não cresce, porque crescer mentiria sobre a escala do mundo.',
  ];
  if (medida.linhasCortadas > 0) {
    linhas.push(
      `${medida.linhasCortadas} linha(s) não couberam no último desenho e foram anunciadas como ` +
        'corte. O texto inteiro está na folha de diagnóstico.',
    );
  }
  linhas.push(
    '',
    'O limiar é decisão de projeto aferida no desktop, e não norma citada. O ensaio dentro do ' +
      'visor continua a fazer: densidade de pixels e distorção da lente não entram na conta do ' +
      'ângulo, e podem mover o número.',
  );
  return linhas;
}

A correção do primeiro é girar o cartaz para encarar quem lê, e só em torno do eixo vertical. Zerar a altura antes de mirar é a linha que mantém o painel de pé: mirando o ponto inteiro, ele se inclinaria para acompanhar a cabeça de quem se abaixa, e um objeto preso à mesa não faz isso. A única coisa que este painel não pode perder é o pertencimento à bancada.

A do segundo é a que gostamos mais, e ela não é uma correção. O painel não pode crescer sem deixar de ser um cartaz de bancada, e não pode encolher a distância sem mentir sobre a escala do mundo. Então ele mede a própria legibilidade: calcula a altura aparente da letra em minutos de arco, compara com o limiar adotado, e diz quando ela caiu — em vez de fingir que está sendo lido.

O limiar é de vinte minutos de arco, e é decisão de projeto, não norma citada. Saiu do único ensaio que se pôde fazer sem depender do rodízio dos três visores: no desktop, afastando o ponto de vista até três pessoas pararem de ler a mesma linha. O ensaio dentro do visor continua a fazer — a densidade de pixels e a distorção da lente não entram na conta do ângulo, e podem mover o número. É por isso que a folha informa o valor medido ao lado do limiar, e não apenas o veredito.

O chamar() é a quarta correção, e a única que é gesto de quem usa. No celular, em sessão aumentada, a bancada pousa onde a mesa está e o painel pode ficar de costas, atrás dela. Trazê-lo para a borda mais próxima resolve, e não acontece sozinho: painel que se move por conta própria é a coisa que mais rápido deixa de parecer objeto. Mesmo chamado, ele continua pendurado no suporte do tampo — soltá-lo da bancada para segui-lo pela sala o faria deixar de ser diegético justamente quando é mais útil.

1.4 Tarefa 3: Documentar as decisões, os números e os aparelhos

Duas das sete grandezas deste projeto continuam sem medida, e elas aparecem assim.

Enunciado da tarefa

Um ambiente que funciona sem que se saiba dizer por que funciona não sustenta nada. O que precisa ficar escrito é o que ninguém reconstitui depois olhando o código.

O que fica pronto é o registro das decisões tomadas e das descartadas, dos números medidos com a máquina em que foram obtidos, das tolerâncias escolhidas com o que se testou antes, e da lista de aparelhos em que o ambiente foi de fato aberto, com o que funcionou em cada um. Afirmar que um ambiente de três regimes funciona sem dizer onde é afirmação vazia.

1.4.1 O número que não saiu

A tentação é completar a tabela.

O código diz o que o ambiente faz. Não diz por que a folga do encaixe é de quatro centímetros e não de dois, o que se experimentou antes de fixá-la, em que máquina os milissegundos foram obtidos, nem quais aparelhos de fato abriram este endereço. Essas quatro coisas somem no dia em que o semestre acaba, e somem primeiro da cabeça de quem as decidiu.

src/bancada/relatorio/decisoes.ts
// ---------------------------------------------------------------------------
// O que ninguém reconstitui depois olhando o código.
//
// O código diz o que o ambiente faz. Não diz por que a folga do encaixe é de
// quatro centímetros e não de dois, o que se experimentou antes de fixá-la, em
// que máquina o número de milissegundos foi obtido, nem quais aparelhos de fato
// abriram este endereço. Essas quatro coisas somem no dia em que o semestre
// acaba, e somem primeiro da cabeça de quem as decidiu.
//
// Este arquivo é o registro delas, e ele é código pela mesma razão que o painel
// é objeto da cena: documento que vive fora do artefato é documento que
// envelhece sem que ninguém perceba. Aqui ele envelhece junto — quem trocar a
// tolerância e não trocar a linha correspondente vê as duas lado a lado.
//
// Três coisas que o registro deliberadamente NÃO faz.
//
// Não estima. Número que não foi medido aparece como não medido, com o que
// falta para medi-lo. A tentação de completar a tabela com um valor plausível é
// exatamente o que torna a tabela inútil — basta um valor inventado para que
// nenhum dos outros possa ser confiado sem conferência.
//
// Não guarda só a decisão vencedora. A alternativa descartada e a razão do
// descarte valem mais na arguição do que a escolha em si: quem só sabe o que
// escolheu não sabe o que escolheu.
//
// Não inventa a lista de aparelhos. Ela nasce VAZIA e é preenchida por quem
// abriu o endereço no aparelho. Afirmar que um ambiente de três regimes funciona
// sem dizer onde é afirmação vazia, e a lista escrita de memória na véspera da
// entrega é a versão pior dessa mesma afirmação.
// ---------------------------------------------------------------------------

/** Uma decisão de projeto, com o caminho que não foi tomado. */
export interface Decisao {
  readonly assunto: string;
  readonly decidido: string;
  readonly porQue: string;
  /** O que foi considerado e descartado, e por quê. Nunca vazio. */
  readonly descartado: string;
  /** O que muda se ela for revertida. É o custo de mexer nela depois. */
  readonly seRevertida: string;
}

/**
 * Um número medido, com a origem da medida.
 *
 * O campo `maquina` é o que separa dado de boato. "Doze milissegundos" não é
 * informação: doze milissegundos no desktop de laboratório com placa integrada é
 * outra coisa que doze milissegundos num visor. O tipo obriga a dizer onde.
 */
export interface NumeroMedido {
  readonly grandeza: string;
  /** Ausente quando ainda não foi medido. A ausência é o dado, nesse caso. */
  readonly valor: string | undefined;
  readonly maquina: string;
  /** Como se refaz a medida. Sem isto, o número não é conferível. */
  readonly comoSeObteve: string;
}

/** Uma tolerância adotada, com o que se experimentou antes de fixá-la. */
export interface Tolerancia {
  readonly nome: string;
  readonly valor: string;
  readonly ensaiadoAntes: string;
  readonly oQueODesvioProduz: string;
}

/** Um aparelho em que o endereço foi de fato aberto. Registrado por quem abriu. */
export interface AparelhoTestado {
  readonly aparelho: string;
  readonly regimeAberto: string;
  readonly oQueFuncionou: string;
  readonly oQueNaoFuncionou: string;
  readonly quemAbriu: string;
}

export const DECISOES: readonly Decisao[] = [
  {
    assunto: 'ferramental',
    decidido: 'biblioteca 3D para renderização e ciclo de sessão; interação, manipulação, locomoção e ancoragem escritas à mão',
    porQue:
      'a abstração de apontar com três back-ends é o conteúdo desta disciplina, e é justamente o que uma biblioteca de atalhos XR entrega pronta',
    descartado:
      'engine completa, que resolveria os três regimes em poucas semanas e esconderia exatamente o que se veio aprender; e reconstruir o pipeline gráfico, que é escopo de outra disciplina e consumiria o semestre antes de chegar em realidade aumentada',
    seRevertida:
      'com engine, o percurso perde os módulos de interação e ancoragem; sem biblioteca alguma, perde os de imersão em diante',
  },
  {
    assunto: 'distribuição',
    decidido: 'um endereço servido por HTTPS, aberto no navegador do próprio aparelho',
    porQue:
      'a turma inteira executa no mesmo instante, no aparelho que tem à mão, sem instalar nada',
    descartado:
      'aplicativo nativo instalado no visor, que dá acesso a mais capacidades e consome uma aula inteira de instalação para três aparelhos',
    seRevertida:
      'o celular do estudante sai do alcance do ambiente, e com ele a maior parte da turma',
  },
  {
    assunto: 'ordem de preferência dos regimes',
    decidido: 'realidade aumentada com sessão, imersivo, marcador impresso, janela',
    porQue:
      'a bancada é uma oficina de montagem, e montar sobre a mesa real de quem trabalha vale mais que montar numa sala inventada',
    descartado:
      'pôr o imersivo em primeiro, que traz escala corporal e alcance de braço e é o que mais impressiona na demonstração',
    seRevertida:
      'o artefato passa a privilegiar a sala inventada, e a ancoragem contra superfície real vira acessório',
  },
  {
    assunto: 'registro por marcador impresso',
    decidido: 'implementado à mão, como segunda ancoragem, com qualidade inferior declarada',
    porQue:
      'parte relevante dos celulares da turma não entra em sessão aumentada, e o marcador é o único caminho que alcança todo mundo',
    descartado:
      'depender só da sessão aumentada do aparelho, que teria dado um registro melhor para metade da turma e tela em branco para a outra metade',
    seRevertida: 'o parque heterogêneo volta a dividir a turma entre quem vê e quem não vê',
  },
  {
    assunto: 'painel de informação',
    decidido: 'objeto da cena, preso à bancada',
    porQue:
      'painel preso à janela do navegador desaparece quando a sessão imersiva começa, que é quando ele faria mais falta',
    descartado:
      'sobreposição em HTML sobre a superfície de desenho, que é mais fácil de escrever, se lê melhor no desktop e some no visor',
    seRevertida: 'o ambiente perde toda informação de estado dentro dos dois regimes imersivos',
  },
  {
    assunto: 'aviso de perda de rastreamento',
    decidido: 'preso à cabeça, e não à cena — única exceção do percurso à regra do cartaz diegético',
    porQue:
      'quando a referência se perde, a posição da cena é justamente o que não se conhece; um aviso preso a ela apareceria no lugar errado ou não apareceria',
    descartado: 'manter a coerência diegética e aceitar que o aviso mais urgente fosse o menos visível',
    seRevertida: 'a perda de rastreamento volta a parecer travamento do programa',
  },
];

export const NUMEROS: readonly NumeroMedido[] = [
  {
    grandeza: 'lado impresso do marcador fiducial',
    valor: '15 cm de lado do quadrado preto, sem a margem branca',
    maquina: 'impressora comum, papel A4',
    comoSeObteve:
      'a constante está no código e gera o desenho; confere-se com régua sobre o papel impresso, antes do primeiro teste, e o ajuste automático à página tem de estar desligado',
  },
  {
    grandeza: 'recuo do posto de trabalho diante da bancada',
    valor: '0,55 m',
    maquina: 'visor com espaço de piso concedido',
    comoSeObteve:
      'medido pelo alcance do braço: no valor anterior, de 1,15 m, nenhuma peça do tampo caía dentro do raio de quem trabalha',
  },
  {
    grandeza: 'altura de olhos adotada quando o piso real não é concedido',
    valor: '1,6 m',
    maquina: 'qualquer aparelho que conceda só a altura da cabeça',
    comoSeObteve:
      'valor adotado no assentamento do mundo; confere-se comparando a altura do tampo virtual com a de uma bancada real de oficina',
  },
  {
    grandeza: 'campo de visão horizontal da câmera do celular',
    valor: '60 graus — ESTIMATIVA declarada, não medida',
    maquina: 'celular do estudante, parque heterogêneo',
    comoSeObteve:
      'o navegador não informa a distância focal; medir é possível e barato, fotografando o marcador a distância conhecida, e continua a fazer — o erro entra quase todo na profundidade',
  },
  {
    grandeza: 'limiar de leitura do painel',
    valor: '20 minutos de arco de altura de letra',
    maquina: 'desktop de laboratório, com placa integrada',
    comoSeObteve:
      'afastando o ponto de vista até três pessoas pararem de ler a mesma linha; o ensaio dentro do visor continua a fazer, e pode mover o número',
  },
  {
    grandeza: 'orçamento de cena que a placa integrada sustenta',
    valor: undefined,
    maquina: 'desktop de laboratório, com placa integrada',
    comoSeObteve:
      'a instrumentação de custo do quadro já está no ambiente e informa chamadas de desenho e triângulos; falta rodar a série de medidas com a cena cheia e anotar o teto observado',
  },
  {
    grandeza: 'taxa de quadros sob carga no visor',
    valor: undefined,
    maquina: 'visor',
    comoSeObteve:
      'decisão declarada de não fixar taxa-alvo: conforto aqui é tratado qualitativamente, e o contador de engasgos mede o que a instrumentação alcança; a metade que falta é o relato de quem experimentou',
  },
];

export const TOLERANCIAS: readonly Tolerancia[] = [
  {
    nome: 'folga linear do encaixe',
    valor: '4 cm',
    ensaiadoAntes:
      'três ensaios com folgas menores e maiores; com folga generosa, a peça grudava em quem só passou perto do encaixe',
    oQueODesvioProduz:
      'folga apertada demais faz a peça certa ser recusada e quem monta conclui que errou a ordem; folga larga demais faz a peça errada encaixar',
  },
  {
    nome: 'folga angular do encaixe',
    valor: '20 graus',
    ensaiadoAntes:
      'mesma série de ensaios, com a peça solta girada de propósito fora do eixo do encaixe',
    oQueODesvioProduz:
      'apertada, exige de quem usa uma precisão de pulso que o controle de seis graus não entrega; larga, a peça encaixa visivelmente torta e a animação do mecanismo denuncia',
  },
];

const CHAVE_DE_ARMAZENAMENTO: string = 'bancada.aparelhos-testados';

/**
 * O registro dos aparelhos, que nasce vazio e é preenchido por quem abriu.
 *
 * A persistência é local ao navegador, e essa limitação é grande o bastante para
 * estar escrita aqui e ser repetida a quem lê a folha: o registro feito no
 * celular fica no celular. É por isso que existe a exportação em texto — o
 * destino final do registro é o repositório do projeto, escrito por gente, e o
 * armazenamento local serve só para o registro sobreviver ao recarregamento da
 * página enquanto o ensaio acontece.
 */
export class RegistroDeAparelhos {
  private testados: AparelhoTestado[] = [];

  constructor() {
    this.testados = this.ler();
  }

  private ler(): AparelhoTestado[] {
    try {
      const cru: string | null = window.localStorage.getItem(CHAVE_DE_ARMAZENAMENTO);
      if (cru === null) {
        return [];
      }
      const lido: unknown = JSON.parse(cru);
      return Array.isArray(lido) ? (lido as AparelhoTestado[]) : [];
    } catch {
      // Armazenamento local pode estar bloqueado — navegação privada, política
      // do aparelho, cota estourada. Registro perdido é ruim; página que não
      // sobe por causa dele é pior, e a lista vazia diz a verdade.
      return [];
    }
  }

  private gravar(): void {
    try {
      window.localStorage.setItem(CHAVE_DE_ARMAZENAMENTO, JSON.stringify(this.testados));
    } catch {
      // Mesmo motivo do `ler`. O registro segue valendo nesta sessão.
    }
  }

  registrar(aparelho: AparelhoTestado): void {
    this.testados.push(aparelho);
    this.gravar();
  }

  lista(): readonly AparelhoTestado[] {
    return this.testados;
  }

  esquecer(): void {
    this.testados = [];
    this.gravar();
  }

  /**
   * O registro em texto corrido, pronto para ser colado no repositório.
   *
   * A exportação existe porque o armazenamento local não viaja. Sem ela, o
   * registro morre no aparelho em que foi feito, e a lista de aparelhos volta a
   * ser escrita de memória — que é exatamente o que este arquivo veio impedir.
   */
  exportar(): string {
    const linhas: string[] = ['# Registro do projeto — Bancada', ''];

    linhas.push('## Decisões tomadas, e as descartadas', '');
    for (const decisao of DECISOES) {
      linhas.push(
        `- **${decisao.assunto}**: ${decisao.decidido}. Por quê: ${decisao.porQue}. ` +
          `Descartado: ${decisao.descartado}. Se revertida: ${decisao.seRevertida}.`,
      );
    }

    linhas.push('', '## Números, com a máquina em que foram obtidos', '');
    for (const numero of NUMEROS) {
      const valor: string = numero.valor ?? 'NÃO MEDIDO';
      linhas.push(
        `- **${numero.grandeza}**: ${valor} (${numero.maquina}). Como se obtém: ${numero.comoSeObteve}.`,
      );
    }

    linhas.push('', '## Tolerâncias, e o que se ensaiou antes', '');
    for (const tolerancia of TOLERANCIAS) {
      linhas.push(
        `- **${tolerancia.nome}**: ${tolerancia.valor}. Ensaiado antes: ${tolerancia.ensaiadoAntes}. ` +
          `O desvio produz: ${tolerancia.oQueODesvioProduz}.`,
      );
    }

    linhas.push('', '## Aparelhos em que o endereço foi de fato aberto', '');
    if (this.testados.length === 0) {
      linhas.push(
        '- Nenhum registrado ainda. A lista nasce vazia de propósito: ela só vale preenchida por ' +
          'quem abriu.',
      );
    } else {
      for (const testado of this.testados) {
        linhas.push(
          `- **${testado.aparelho}** — regime aberto: ${testado.regimeAberto}. ` +
            `Funcionou: ${testado.oQueFuncionou}. Não funcionou: ${testado.oQueNaoFuncionou}. ` +
            `Registrado por: ${testado.quemAbriu}.`,
        );
      }
    }

    return linhas.join('\n');
  }

  linhas(): string[] {
    const linhas: string[] = [];

    linhas.push('DECISÕES TOMADAS, E O QUE FOI DESCARTADO', '');
    for (const decisao of DECISOES) {
      linhas.push(`${decisao.assunto}: ${decisao.decidido}.`);
      linhas.push(`  por quê: ${decisao.porQue}.`);
      linhas.push(`  descartado: ${decisao.descartado}.`);
      linhas.push(`  se revertida: ${decisao.seRevertida}.`);
      linhas.push('');
    }

    linhas.push('NÚMEROS, COM A MÁQUINA EM QUE FORAM OBTIDOS', '');
    for (const numero of NUMEROS) {
      linhas.push(`${numero.grandeza}: ${numero.valor ?? 'NÃO MEDIDO'} — ${numero.maquina}.`);
      linhas.push(`  como se obtém: ${numero.comoSeObteve}.`);
    }
    const naoMedidos: number = NUMEROS.filter((numero) => numero.valor === undefined).length;
    linhas.push(
      '',
      `${naoMedidos} das ${NUMEROS.length} grandezas continuam sem medida, e aparecem assim de ` +
        'propósito. Preenchê-las com valor plausível tornaria as outras não confiáveis.',
      '',
    );

    linhas.push('TOLERÂNCIAS, E O QUE SE ENSAIOU ANTES DE FIXÁ-LAS', '');
    for (const tolerancia of TOLERANCIAS) {
      linhas.push(`${tolerancia.nome}: ${tolerancia.valor}.`);
      linhas.push(`  ensaiado antes: ${tolerancia.ensaiadoAntes}.`);
      linhas.push(`  o desvio produz: ${tolerancia.oQueODesvioProduz}.`);
    }

    linhas.push('', 'APARELHOS EM QUE O ENDEREÇO FOI DE FATO ABERTO', '');
    if (this.testados.length === 0) {
      linhas.push(
        'Nenhum registrado ainda. A lista nasce vazia, e é a única parte deste registro que o ' +
          'código não pode preencher: ela depende de alguém abrir o endereço num aparelho e dizer ' +
          'o que aconteceu.',
      );
    } else {
      for (const testado of this.testados) {
        linhas.push(
          `${testado.aparelho} — ${testado.regimeAberto}. Funcionou: ${testado.oQueFuncionou}. ` +
            `Não funcionou: ${testado.oQueNaoFuncionou}. Registro de ${testado.quemAbriu}.`,
        );
      }
    }

    linhas.push(
      '',
      'O registro de aparelhos fica no armazenamento local DESTE navegador: o que foi anotado no ' +
        'celular ficou no celular. O destino dele é o repositório do projeto, e é para isso que ' +
        'existe a exportação em texto.',
    );
    return linhas;
  }
}

O registro é código pela mesma razão que o painel é objeto da cena: documento que vive fora do artefato envelhece sem que ninguém perceba. Aqui ele envelhece junto — quem trocar a tolerância e não trocar a linha correspondente vê as duas lado a lado, no mesmo arquivo.

Três coisas ele deliberadamente não faz, e a primeira é a que dói. Ele não estima. Duas das sete grandezas listadas aparecem como não medidas: o orçamento de cena que a placa integrada sustenta, e a taxa de quadros sob carga no visor. Seria fácil escrever um número plausível em cada uma, e ninguém perceberia. Bastaria isso para que nenhum dos outros cinco pudesse ser confiado sem conferência, e uma tabela que precisa ser conferida inteira não serve para nada.

A segunda é que a alternativa descartada fica registrada ao lado da vencedora. Na arguição, ela vale mais: quem só sabe o que escolheu não sabe o que escolheu. Cada decisão do arquivo carrega o que foi considerado, por que caiu, e o que muda se a escolha for revertida — que é o custo de mexer nela daqui a dois semestres.

A terceira é a lista de aparelhos, e ela nasce vazia. Não há como o código preenchê-la: ela depende de alguém abrir o endereço num aparelho e dizer o que aconteceu. Guardamos os registros no armazenamento local do navegador, e essa limitação é grande a ponto de estar dita em três lugares — o que foi anotado no celular ficou no celular. É por isso que existe a exportação em texto: o destino do registro é o repositório do projeto, escrito por gente, e o armazenamento local só serve para o registro sobreviver ao recarregamento enquanto o ensaio acontece.

1.4.2 Avaliar pela tarefa, e não pela impressão

A pergunta “ficou bom?” não tem resposta.

A que tem é outra: alguém consegue montar o mecanismo, e onde essa pessoa trava? Trocar a primeira pela segunda é o que separa avaliação de impressão, e é também o que impede o julgamento mais comum sobre trabalho de realidade virtual — o de que ficou bonito.

src/bancada/relatorio/uso.ts
// ---------------------------------------------------------------------------
// Avaliar o ambiente pela tarefa que ele suporta.
//
// A pergunta "o ambiente ficou bom?" não tem resposta. A pergunta que tem é
// outra: alguém consegue montar o mecanismo, e onde essa pessoa trava? Trocar a
// primeira pela segunda é o que separa avaliação de impressão, e é também o que
// impede o julgamento mais comum sobre trabalho de realidade virtual — o de que
// ficou bonito.
//
// A análise abaixo decompõe a tarefa em demandas, e não em passos. Passos já
// existem: são as cinco peças e a ordem entre elas, e o código os conhece. O que
// o código não conhece é o que cada passo COBRA de quem monta — julgar distância
// no olho, girar o pulso com precisão, lembrar a ordem — e é essa coluna que
// explica por que a mesma tarefa é fácil num regime e penosa noutro.
//
// A segunda metade do arquivo é o registro de observação, e ele nasce vazio pelo
// mesmo motivo que a lista de aparelhos: o que uma pessoa faz diante do ambiente
// não se deduz do código. Ele mora em memória, e só na sessão em curso — de
// propósito. A observação se transcreve para o registro do projeto no instante
// em que acontece, com a tutoria ainda na sala; um arquivo que se acumulasse
// sozinho pareceria arquivo, e ninguém transcreveria nada.
// ---------------------------------------------------------------------------

import type { RegimeExecutavel } from '../app/degradacao';

/** O que a demanda encontra em cada um dos quatro regimes que o ambiente abre. */
export type PorRegime = Readonly<Record<RegimeExecutavel, string>>;

export interface Demanda {
  readonly nome: string;
  /** O que ela cobra de quem monta, em uma frase. */
  readonly cobra: string;
  /** Em que passo da montagem ela aparece com mais força. */
  readonly ondeAparece: string;
  readonly porRegime: PorRegime;
}

/**
 * As quatro demandas da tarefa de montagem.
 *
 * São quatro e não dez porque a lista foi cortada pelo que o ambiente de fato
 * diferencia entre regimes. Leitura de texto, por exemplo, é demanda real da
 * tarefa e não está aqui: ela é igual nos quatro regimes, e uma linha igual nas
 * quatro colunas não ajuda a decidir nada.
 */
export const DEMANDAS: readonly Demanda[] = [
  {
    nome: 'julgar profundidade',
    cobra: 'saber, no olho, se a peça está sobre o encaixe ou meio palmo à frente dele',
    ondeAparece: 'no instante de soltar a peça sobre o encaixe',
    porRegime: {
      'immersive-ar': 'a mesa real dá a referência de profundidade, e a bancada pousa sobre ela',
      'immersive-vr': 'a estereoscopia resolve, e o contorno da folga confirma',
      marcador: 'a profundidade é o eixo em que o erro de registro se concentra, e ela oscila',
      janela: 'não há estereoscopia; resolve-se orbitando a câmera, que é um gesto a mais',
    },
  },
  {
    nome: 'orientar a peça',
    cobra: 'girar o pulso até a peça entrar no eixo do encaixe, dentro da folga angular',
    ondeAparece: 'na tampa, que é a única peça sem simetria de revolução',
    porRegime: {
      'immersive-ar': 'o controle de seis graus entrega a orientação da mão direto',
      'immersive-vr': 'igual ao aumentado: é a mesma fonte de apontamento',
      marcador: 'não há orientação de mão; o giro sai da tecla, e o gesto vira comando',
      janela: 'o giro sai da tecla, e é o ponto em que o regime base mais se afasta dos outros',
    },
  },
  {
    nome: 'lembrar a ordem',
    cobra: 'saber que a tampa depende das duas engrenagens, e elas do eixo',
    ondeAparece: 'na primeira recusa por ordem',
    porRegime: {
      'immersive-ar': 'o painel preso à bancada informa o passo, e ele acompanha a mesa',
      'immersive-vr': 'o mesmo painel, com o cuidado de não ficar longe demais para ser lido',
      marcador: 'o painel treme junto com o registro, e o passo é o que se lê pior',
      janela: 'painel e folha de diagnóstico convivem na mesma tela; é onde a ordem é mais clara',
    },
  },
  {
    nome: 'alcançar a peça',
    cobra: 'estender o braço até a peça sem sair do lugar',
    ondeAparece: 'nas peças da prateleira do fundo',
    porRegime: {
      'immersive-ar': 'o alcance é o do braço real, e o recuo do posto foi medido por causa disto',
      'immersive-vr': 'igual, com o salto disponível para o que estiver fora do braço',
      marcador: 'não há braço: tudo se alcança pelo toque na tela, e a demanda desaparece',
      janela: 'não há braço: tudo se alcança pelo cursor, e a demanda desaparece',
    },
  },
];

/** O que uma pessoa fez diante do ambiente. Só isto conta como avaliação de uso. */
export interface Observacao {
  readonly quem: string;
  readonly regime: RegimeExecutavel;
  /** Em que ponto ela travou, ou o que a fez desistir. Vazio quando concluiu. */
  readonly ondeTravou: string;
  /** As palavras dela, e não o resumo de quem observou. */
  readonly relato: string;
  readonly concluiu: boolean;
}

/**
 * O registro de observação da sessão em curso.
 *
 * Sem persistência, e isso é decisão. A observação vale transcrita para o
 * registro do projeto enquanto a pessoa ainda está na sala e pode ser
 * perguntada; um arquivo que se acumulasse sozinho daria a impressão de que a
 * transcrição pode esperar, e ela não pode — o que se perde primeiro é
 * exatamente o que a pessoa disse, que é a parte que a instrumentação não mede.
 */
export class RegistroDeUso {
  private readonly observacoes: Observacao[] = [];

  registrar(observacao: Observacao): void {
    this.observacoes.push(observacao);
  }

  lista(): readonly Observacao[] {
    return this.observacoes;
  }

  /** Quantas pessoas concluíram a montagem, e quantas travaram. É o número da avaliação. */
  taxaDeConclusao(): string {
    if (this.observacoes.length === 0) {
      return 'Ainda sem observação registrada nesta sessão.';
    }
    const concluidas: number = this.observacoes.filter((item) => item.concluiu).length;
    return `${concluidas} de ${this.observacoes.length} concluíram a montagem.`;
  }

  linhas(): string[] {
    const linhas: string[] = [
      'ANÁLISE DA TAREFA: O QUE A MONTAGEM COBRA DE QUEM MONTA',
      '',
    ];

    for (const demanda of DEMANDAS) {
      linhas.push(`${demanda.nome} — ${demanda.cobra}. Aparece ${demanda.ondeAparece}.`);
      linhas.push(`  em realidade aumentada com sessão: ${demanda.porRegime['immersive-ar']}.`);
      linhas.push(`  em realidade virtual imersiva: ${demanda.porRegime['immersive-vr']}.`);
      linhas.push(`  por marcador impresso: ${demanda.porRegime.marcador}.`);
      linhas.push(`  em janela: ${demanda.porRegime.janela}.`);
      linhas.push('');
    }

    linhas.push(
      'Duas demandas desaparecem nos regimes sem corpo, e desaparecer não é o mesmo que ficar ' +
        'mais fácil: a tarefa passa a ser outra. Comparar a montagem feita em janela com a feita ' +
        'no visor é comparar duas atividades diferentes, e quem apresentar os dois resultados lado ' +
        'a lado precisa dizer isso antes de mostrar o número.',
      '',
      'OBSERVAÇÃO DE USO',
      '',
      this.taxaDeConclusao(),
      '',
    );

    if (this.observacoes.length === 0) {
      linhas.push(
        'Nenhuma observação nesta sessão. O que o ambiente mede sozinho — quadros, engasgos, ' +
          'recusas por ordem — não responde se alguém consegue montar o mecanismo; responde quanto ' +
          'custa desenhá-lo enquanto tentam.',
      );
      return linhas;
    }

    for (const observacao of this.observacoes) {
      const desfecho: string = observacao.concluiu
        ? 'concluiu'
        : `travou ${observacao.ondeTravou}`;
      linhas.push(`${observacao.quem}, em ${observacao.regime}: ${desfecho}. "${observacao.relato}"`);
    }

    linhas.push(
      '',
      'As observações desta lista somem ao recarregar a página. Transcreva-as para o registro do ' +
        'projeto enquanto quem experimentou ainda está por perto e pode ser perguntado.',
    );
    return linhas;
  }
}

A análise decompõe a tarefa em demandas, e não em passos. Passos já existem: são as cinco peças e a ordem entre elas, e o código as conhece. O que ele não conhece é o que cada passo cobra de quem monta — julgar profundidade no olho, girar o pulso dentro da folga angular, lembrar que a tampa depende das duas engrenagens, estender o braço até a prateleira do fundo.

São quatro demandas, e não dez, porque a lista foi cortada pelo que o ambiente de fato diferencia entre regimes. Ler texto é demanda real da tarefa e ficou de fora: é igual nos quatro regimes, e linha igual nas quatro colunas não ajuda a decidir nada.

E aqui a escada rolante parada cobra a última parcela, pelo lado que faltava. Duas das quatro demandas desaparecem nos regimes sem corpo: em janela e por marcador, não há braço a estender nem pulso a girar — tudo se alcança pelo cursor ou pelo toque. Desaparecer não é ficar mais fácil. A tarefa passa a ser outra, e comparar a montagem feita em janela com a feita no visor é comparar duas atividades diferentes. Quem apresentar os dois resultados lado a lado precisa dizer isso antes de mostrar o número.

O registro de observação não persiste, e é decisão. A observação vale transcrita enquanto a pessoa ainda está na sala e pode ser perguntada; um arquivo que se acumulasse sozinho daria a impressão de que a transcrição pode esperar. O que se perde primeiro é exatamente o que a pessoa disse, que é a metade que a instrumentação não mede.

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

Cinco arquivos novos, e três linhas dentro do laço.

Fora das pastas novas, o que mudou foi pouco e é fácil de auditar. O laço ganhou a chamada que gira o painel para encarar quem lê, e ela vem antes do redesenho por um detalhe: a legibilidade é medida contra a posição deste quadro, e medi-la depois daria a distância do anterior. As duas chamadas antigas do painel, que despejavam as cinco linhas do orçamento, foram trocadas pelo informe composto.

A segunda mudança está no ramo do congelamento por perda de rastreamento. O painel continua sendo atualizado com a cena parada, e isso é deliberado — ele é a única coisa que ainda pode explicar por que nada se move. O giro para encarar também continua, porque quem procura a explicação vai contornar a mesa.

A terceira é o registro das camadas, espalhado por dez pontos da composição, cada um na linha em que a camada é de fato ligada. É a única mudança do módulo que aparece em lugares distantes uns dos outros, e a proximidade é o que lhe dá valor.

A abertura pelo melhor regime mora num botão, e não no carregamento. A razão é a mesma do módulo anterior e não mudou: o navegador recusa sessão pedida sem gesto de quem usa, e ligar a câmera sem que ninguém tenha pedido acenderia a luz do aparelho por conta própria. O que a decisão automática faz continua sendo dizer qual botão apertar, e por quê.

1.6 O que este módulo deixa pronto

O critério de quem trabalhou décadas na área e recusou o entusiasmo.

O artefato está inteiro: um endereço, quatro regimes, a degradação percorrendo a ordem declarada tanto na consulta quanto na abertura, o painel diegético informando o passo da tarefa dentro dos três ambientes, e o registro do que se decidiu, do que se mediu e do que continua sem medida.

Em Chapel Hill, Frederick Brooks pôs químicos para encaixar moléculas com um braço mecânico de retorno de força montado sobre a bancada, num projeto que perseguiu por décadas. No balanço que escreveu em 1999, disse que a realidade virtual havia passado de “quase funciona” a “mal e mal funciona”. É de quem tinha todo motivo para o entusiasmo e o recusou — e é por isso que serve de critério aqui, no fim do percurso, melhor do que serviria qualquer advertência nossa.

O que esse critério cobra na apresentação é concreto. A demonstração que abre pelo regime mais espetacular depende do aparelho mais capaz, que é o que falha na hora. Comece pelo regime em janela, que sempre abre e já mostra a cena inteira, a tarefa inteira e o custo medido; depois troque de aparelho sem trocar de endereço. E responda às três perguntas que aparecem em toda arguição: como o sistema decide onde o objeto pousa sobre uma superfície real, o que ele faz quando o rastreamento se perde, e por que o mesmo código roda em aparelhos que declaram capacidades diferentes.

A quarta pergunta é específica de cada projeto, e sai da documentação que o próprio grupo escreveu. É a única que não se pode treinar antes.