1 Registro por marcador e degradação graciosa — 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

Quantos aparelhos da turma conseguem executar o que o trecho anterior construiu? Três.

O laboratório tem 3 visores para 40 estudantes. O trecho anterior entregou realidade aumentada de verdade — a bancada pousada na mesa de fórmica, presa ali enquanto se anda em volta — e entregou isso para quem estiver com um dos três aparelhos na cabeça. Os outros trinta e sete abrem o mesmo endereço e não veem nada acontecer.

Este trecho resolve dois problemas que são o mesmo problema visto de dois lados. O primeiro é técnico: como registrar a cena contra o mundo real num celular comum, que não abre sessão aumentada. O segundo é de projeto: como fazer um endereço só servir aparelhos de capacidades desiguais sem excluir os menos capazes nem mentir sobre o que eles entregam.

Entram seis arquivos, quatro deles numa pasta que já existia. Fora das pastas novas mudaram três coisas, e uma delas é uma linha só — a superfície de desenho passa a admitir transparência. A camada de interação ficou intocada pela quarta vez seguida.

1.2 O cartógrafo se aposenta

Uma figura que atravessou onze módulos morre neste trecho, e o lugar de dizer isso é antes de o aluno tropeçar nela.

O aparelho desenha a planta da sala enquanto atravessa a sala, e corrige o desenho toda vez que reconhece um canto por onde já passou. Essa frase explicou o rastreamento desde o módulo de dispositivos. Ela explicou por que a bancada fica onde foi posta, por que a correção acumulada cresce em centímetros quando alguém dá uma volta em torno da mesa, e por que perder a referência tem cara de perder um desenho.

Aqui ela deixa de valer, e não por metade. No registro por marcador não há cartógrafo algum. Não há planta, não há canto reconhecido, não há mapa acumulado. O que existe é um gabarito conhecido de antemão — um desenho que o ambiente já sabia como era antes de a câmera ser ligada.

Essa diferença explica, sozinha, a diferença de qualidade entre os dois registros, e ela cabe numa frase. Um deles constrói conhecimento sobre o ambiente e o corrige ao longo do tempo; o outro reconhece uma forma numa imagem plana e recomeça do zero a cada quadro. Tudo o mais decorre daí.

Decorre a estabilidade: o registro por superfície acumula, o por marcador não acumula nada, e a bancada treme. Decorre a permanência: o papel sai de quadro e o registro acaba, porque não sobrou mapa a que recorrer. E decorre a escala, que é o ponto mais estranho dos três — no registro por superfície ela vem dos sensores do aparelho, e aqui ela vem de um número que alguém digitou.

1.3 Tarefa 1: Registrar a cena por marcador impresso

O registro mais antigo da área, e o único deste percurso que depende de uma régua.

Enunciado da tarefa

Nem todo aparelho oferece sessão sobre o mundo real. A alternativa é mais antiga e mais frágil: um padrão impresso, reconhecido pela câmera, servindo de origem para o mundo virtual.

O que fica pronto é esse caminho funcionando com acesso à câmera e detecção do padrão, com a medida física do marcador registrada. É ela que determina a escala do mundo virtual, e trocar o papel por outro de tamanho diferente muda tudo o que se vê.

1.3.1 A constante que não é de software

O arquivo do padrão abre com um número de 15 cm, e ele não descreve nada dentro do programa. Descreve um pedaço de papel que está sobre uma mesa, do lado de fora.

Por que um número digitado decide a escala do mundo inteiro? Porque o detector devolve um quadrilátero medido em pixels, e pixel não tem tamanho. Um marcador pequeno e perto projeta na câmera exatamente o mesmo quadrilátero que um marcador grande e longe — as duas cenas são indistinguíveis na imagem. A única coisa que desfaz o empate é saber quantos metros aquele quadrado tem no papel.

O erro previsível é o mais barato de cometer e o mais caro de diagnosticar. Manda-se imprimir, a caixa de diálogo oferece o ajuste automático à página, e o desenho sai alguns por cento menor. Ninguém percebe: o papel parece certo, a detecção funciona, a bancada aparece.

E aparece com o tamanho errado, na mesma proporção. O sintoma que chega a quem olha é uma oficina grande demais para a mesa, e o suspeito imediato é a modelagem. Já vi três horas irem embora nessa caça.

src/bancada/anchoring/padrao.ts
// ---------------------------------------------------------------------------
// O padrão impresso: a origem do mundo virtual quando não há mapa da sala.
//
// Este é o registro mais antigo da área e o mais frágil dos dois que o projeto
// tem. Ele não pergunta nada ao aparelho sobre o ambiente — não há planta do
// cômodo, não há superfície detectada, não há âncora. O que existe é um desenho
// conhecido de antemão, e a pergunta que o ambiente faz a cada quadro é apenas
// esta: onde está aquele desenho na imagem que a câmera está entregando?
//
// A diferença com o módulo anterior é conceitual, e não de qualidade de código.
// Lá o aparelho desenhava a planta da sala enquanto a atravessava, e corrigia o
// desenho toda vez que reconhecia um canto por onde já havia passado. Aqui não
// há cartógrafo algum: há um gabarito, e o mundo virtual nasce dele. Onde o
// gabarito não aparece na imagem, o registro simplesmente não existe — não há
// mapa a que recorrer, e é por isso que a bancada some quando o papel sai de
// quadro.
//
// A CONSTANTE MAIS IMPORTANTE DESTE MÓDULO ESTÁ ABAIXO, e ela não é de software.
// O lado impresso do marcador, em metros, é o que fixa a escala do mundo
// virtual: o detector devolve o quadrilátero em pixels, e a única forma de
// converter pixel em metro é saber quantos metros aquele quadrado tem no papel.
// Imprimir o mesmo arquivo em outro tamanho — reduzido para caber na margem,
// ampliado para ficar mais visível — muda TUDO o que se vê, e o sintoma engana:
// a bancada aparece grande ou pequena demais, e quem olha conclui que a
// modelagem está errada.
// ---------------------------------------------------------------------------

/**
 * O lado do quadrado preto do marcador, em metros, tal como ele sai da
 * impressora.
 *
 * Quinze centímetros é decisão de projeto com três razões declaradas. Cabe numa
 * folha comum com margem folgada, o que evita a impressão ajustada à página, que
 * é a causa número um de escala errada. É grande o bastante para que a câmera de
 * um celular comum ainda distinga as células internas a um metro e meio de
 * distância, que é a distância de quem trabalha na bancada. E é pequeno o
 * bastante para caber sobre a mesa junto do que mais estiver ali.
 *
 * Trocar este número sem trocar o papel, ou trocar o papel sem trocar este
 * número, produz um erro de escala proporcional e silencioso. A conferência é
 * com régua, antes do primeiro teste, e está no relatório do regime.
 */
export const LADO_IMPRESSO_M: number = 0.15;

/** Células por lado do desenho, contando a moldura preta. */
export const CELULAS_POR_LADO: number = 6;

/** Células por lado da área que carrega o código, dentro da moldura. */
export const CELULAS_DE_CODIGO: number = CELULAS_POR_LADO - 2;

/**
 * O código do marcador, célula a célula, com 1 para claro e 0 para escuro.
 *
 * A moldura preta em volta não está aqui: ela é fixa, vale para qualquer código
 * e é o que o detector procura primeiro. O que esta matriz guarda é só o miolo.
 *
 * O desenho foi escolhido para ser ASSIMÉTRICO sob rotação — as quatro voltas de
 * noventa graus produzem quatro leituras diferentes. É essa assimetria que
 * permite descobrir qual lado do papel está para cima, e sem ela a bancada
 * nasceria girada um quarto de volta com a mesma frequência com que nasce certa.
 * A conferência está na função logo abaixo, e ela roda ao carregar.
 */
export const CODIGO: readonly (readonly number[])[] = [
  [1, 0, 0, 0],
  [1, 1, 0, 1],
  [0, 1, 1, 1],
  [0, 0, 1, 0],
];

/** Gira uma matriz quadrada um quarto de volta no sentido horário. */
export function girarUmQuarto(
  celulas: readonly (readonly number[])[],
): readonly (readonly number[])[] {
  const lado: number = celulas.length;
  const girada: number[][] = [];
  for (let linha: number = 0; linha < lado; linha += 1) {
    const nova: number[] = [];
    for (let coluna: number = 0; coluna < lado; coluna += 1) {
      // A linha nova lê a coluna antiga de baixo para cima. É a rotação horária
      // escrita como troca de índices, sem trigonometria nenhuma.
      const origem: readonly number[] | undefined = celulas[lado - 1 - coluna];
      nova.push(origem === undefined ? 0 : (origem[linha] ?? 0));
    }
    girada.push(nova);
  }
  return girada;
}

/** As quatro leituras do mesmo papel, uma por orientação possível. */
export function orientacoesDoCodigo(): readonly (readonly (readonly number[])[])[] {
  const orientacoes: (readonly (readonly number[])[])[] = [CODIGO];
  for (let volta: number = 1; volta < 4; volta += 1) {
    const anterior: readonly (readonly number[])[] | undefined = orientacoes[volta - 1];
    if (anterior !== undefined) {
      orientacoes.push(girarUmQuarto(anterior));
    }
  }
  return orientacoes;
}

function comoTexto(celulas: readonly (readonly number[])[]): string {
  return celulas.map((linha) => linha.join('')).join('|');
}

/**
 * Confere que o código escolhido distingue as quatro orientações.
 *
 * Um código simétrico não dá erro em lugar nenhum: ele é detectado, decodificado
 * e aceito. O que ele faz é entregar uma orientação sorteada entre as que
 * casarem, e a bancada nasce girada de vez em quando. Sintoma intermitente, sem
 * mensagem, e o suspeito errado é sempre a montagem da cena.
 */
export function inconsistenciasDoPadrao(): string[] {
  const problemas: string[] = [];
  const vistas: Set<string> = new Set<string>();
  for (const orientacao of orientacoesDoCodigo()) {
    const chave: string = comoTexto(orientacao);
    if (vistas.has(chave)) {
      problemas.push(
        'O código do marcador se repete sob rotação, e por isso não determina qual lado do ' +
          'papel está para cima. A bancada vai nascer girada em parte das detecções.',
      );
      break;
    }
    vistas.add(chave);
  }

  const claras: number = CODIGO.flat().filter((bit) => bit === 1).length;
  if (claras === 0 || claras === CELULAS_DE_CODIGO * CELULAS_DE_CODIGO) {
    problemas.push(
      'O miolo do código é de uma cor só, e um miolo uniforme é indistinguível de um borrão ' +
        'escuro ou de um reflexo branco no papel.',
    );
  }
  return problemas;
}

/**
 * Desenha o marcador para impressão, em tamanho físico correto.
 *
 * O desenho é gerado aqui, e não é um arquivo trazido de fora, por uma razão que
 * o módulo inteiro persegue: arquivo de imagem separado do código é a via pela
 * qual o papel e a constante divergem. Gerando o desenho a partir da mesma
 * constante que o detector usa, o que sai da impressora e o que o ambiente
 * espera nascem juntos — resta apenas a conferência com régua, que nenhuma linha
 * de código faz por ninguém.
 *
 * A margem branca em volta não é enfeite. O detector procura uma região escura
 * cercada de claro, e um marcador cortado rente ao preto, colado sobre uma mesa
 * escura, deixa de ter fronteira detectável.
 */
export function desenharParaImpressao(pixelsPorLado: number): HTMLCanvasElement {
  const margemEmCelulas: number = 1;
  const total: number = CELULAS_POR_LADO + margemEmCelulas * 2;
  const tela: HTMLCanvasElement = document.createElement('canvas');
  tela.width = pixelsPorLado;
  tela.height = pixelsPorLado;

  const pincel: CanvasRenderingContext2D | null = tela.getContext('2d');
  if (pincel === null) {
    throw new Error('Este navegador não fornece contexto de desenho para o marcador.');
  }

  const celula: number = pixelsPorLado / total;
  pincel.fillStyle = '#ffffff';
  pincel.fillRect(0, 0, pixelsPorLado, pixelsPorLado);

  // A moldura sai inteira preta, e o miolo claro é pintado por cima. Desenhar a
  // moldura como quatro retângulos deixaria costuras de um pixel nos cantos, e
  // uma costura clara na moldura é exatamente o que quebra a detecção.
  pincel.fillStyle = '#000000';
  pincel.fillRect(
    margemEmCelulas * celula,
    margemEmCelulas * celula,
    CELULAS_POR_LADO * celula,
    CELULAS_POR_LADO * celula,
  );

  pincel.fillStyle = '#ffffff';
  for (let linha: number = 0; linha < CELULAS_DE_CODIGO; linha += 1) {
    for (let coluna: number = 0; coluna < CELULAS_DE_CODIGO; coluna += 1) {
      const valor: number = CODIGO[linha]?.[coluna] ?? 0;
      if (valor === 1) {
        pincel.fillRect(
          (margemEmCelulas + 1 + coluna) * celula,
          (margemEmCelulas + 1 + linha) * celula,
          celula,
          celula,
        );
      }
    }
  }

  return tela;
}

/** O que se confere com a régua antes do primeiro teste. */
export function linhasDoPadrao(): string[] {
  return [
    `Lado impresso declarado: ${(LADO_IMPRESSO_M * 100).toFixed(1)} cm de preto a preto, ` +
      'sem contar a margem branca.',
    'Confira com régua o quadrado preto do papel que está sobre a mesa. Divergência de dez por ' +
      'cento aqui aparece como dez por cento de erro no tamanho da bancada, e o suspeito errado ' +
      'é sempre a modelagem.',
    'Ao imprimir, desligue o ajuste automático à página. É ele que reduz o desenho em alguns por ' +
      'cento sem avisar, e é a causa mais comum de escala errada nesta demonstração.',
    `Grade: ${CELULAS_POR_LADO} por ${CELULAS_POR_LADO} células, sendo a moldura externa preta e ` +
      `${CELULAS_DE_CODIGO} por ${CELULAS_DE_CODIGO} células de código no miolo.`,
    'O código distingue as quatro orientações do papel, e é isso que diz qual lado está para ' +
      'cima. Código simétrico entregaria a bancada girada em parte das detecções, sem erro algum.',
  ];
}

Por isso o desenho do marcador é gerado pelo próprio código, a partir da mesma constante que o detector usa, em vez de ser um arquivo de imagem trazido de fora. Arquivo separado é a via pela qual o papel e a constante divergem em silêncio. Gerando os dois da mesma fonte, sobra uma conferência só, e ela não tem substituto: régua sobre o quadrado preto, antes do primeiro teste.

1.3.2 Achar o quadrado, sem ninguém para perguntar

No trecho anterior o aparelho respondia. A consulta de interseção era da plataforma, e o projeto lia o resultado. Aqui não há a quem perguntar: o navegador entrega uma sequência de imagens, e mais nada.

A cadeia tem cinco etapas, e cada uma existe porque a anterior deixou um problema aberto. A imagem é reduzida a 320 pixels de largura, porque analisar a resolução cheia não cabe no envelope do quadro. Claro e escuro são separados por um limiar calculado da própria imagem, porque limiar fixo acerta na sala em que foi escolhido e falha na do lado. Os pixels escuros vizinhos são agrupados, porque a moldura do marcador é uma mancha contínua.

src/bancada/anchoring/deteccao.ts
// ---------------------------------------------------------------------------
// Achar o papel na imagem — o passo que a plataforma NÃO faz por nós.
//
// No módulo anterior o aparelho respondia. A consulta de interseção era da
// plataforma, e o projeto apenas lia o resultado. Aqui não há a quem perguntar:
// o navegador entrega uma sequência de imagens da câmera, e mais nada. Tudo o
// que existe entre uma imagem de trezentos e vinte pixels de largura e uma pose
// em metros está escrito neste arquivo e no vizinho.
//
// A cadeia tem cinco etapas, e cada uma existe porque a anterior deixou um
// problema aberto. Reduz-se a imagem, porque analisar a resolução cheia da
// câmera não cabe no envelope do quadro. Separa-se claro de escuro por um limiar
// calculado da própria imagem, porque um limiar fixo funciona na sala em que foi
// escolhido e falha na do lado. Agrupam-se os pixels escuros vizinhos, porque a
// moldura do marcador é uma mancha escura contínua. Reduz-se cada mancha a
// quatro cantos, porque é disso que a pose precisa. E lê-se o miolo, porque uma
// mancha quadrada qualquer — a tampa de uma caixa, um tablet apoiado — passaria
// por marcador sem essa conferência.
//
// O detector é deliberadamente simples, e as limitações dele são conteúdo. Ele
// perde o marcador em contraluz, em foco ruim e em ângulo muito rasante; ele não
// corrige distorção de lente; e ele encontra um marcador por quadro, o primeiro
// que casar. Nada disso é defeito a esconder: é o custo de reconhecer um
// gabarito numa imagem plana, contra o de perguntar a sensores que medem o
// ambiente.
// ---------------------------------------------------------------------------

import { CELULAS_DE_CODIGO, CELULAS_POR_LADO, orientacoesDoCodigo } from './padrao';
import { aplicarHomografia, resolverHomografia, type PontoNaImagem } from './pose';

/** Largura da imagem analisada, em pixels. A altura acompanha a proporção. */
export const LARGURA_DE_ANALISE_PX: number = 320;

/** Área mínima de uma mancha, em fração da imagem, para valer a pena examiná-la. */
const AREA_MINIMA: number = 0.004;

/** Área máxima. Acima disso a mancha é sombra de cômodo, não papel sobre a mesa. */
const AREA_MAXIMA: number = 0.6;

/** Quanto a mancha precisa preencher do próprio quadrilátero para passar por quadrada. */
const PREENCHIMENTO_MINIMO: number = 0.7;

/** Por que não houve detecção neste quadro. Nenhum destes casos é erro de código. */
export type MotivoDaFalta =
  | 'a câmera ainda não entregou imagem'
  | 'nenhuma mancha escura com tamanho de marcador'
  | 'nenhuma mancha com formato de quadrilátero'
  | 'a moldura não fechou em nenhuma candidata'
  | 'o código lido não é o do marcador declarado';

/** O que uma detecção bem-sucedida devolve. */
export interface Deteccao {
  /**
   * Os quatro cantos do quadrado preto, em pixels da imagem analisada, na ordem
   * que corresponde ao papel de pé: começando pelo canto que o código identifica
   * como superior esquerdo e seguindo no sentido horário.
   */
  readonly cantos: readonly PontoNaImagem[];
  readonly larguraPx: number;
  readonly alturaPx: number;
  /** Quantos quartos de volta o papel estava girado em relação ao esperado. */
  readonly quartosDeVolta: number;
  /** O limiar de separação calculado neste quadro. Sobe e desce com a luz. */
  readonly limiar: number;
}

export interface Detector {
  /** Analisa um quadro da câmera. Devolve ausência quando não há marcador. */
  detectar(fonte: CanvasImageSource, larguraFonte: number, alturaFonte: number): Deteccao | undefined;
  /** Por que a última análise não devolveu marcador. */
  motivo(): MotivoDaFalta;
  /** Quanto a última análise custou, em milissegundos. */
  custoMs(): number;
}

interface Mancha {
  readonly pixels: number;
  readonly borda: PontoNaImagem[];
}

/**
 * Calcula o limiar que melhor separa claro de escuro nesta imagem.
 *
 * O método é o de Otsu: percorre-se cada limiar possível e escolhe-se o que deixa
 * os dois grupos resultantes o mais distantes um do outro. Ele custa uma
 * passagem pelo histograma, não pela imagem, e é a razão de o detector funcionar
 * na sala clara e na sala com a persiana fechada sem ninguém ajustar nada.
 *
 * Um limiar fixo seria mais curto de escrever e é o erro clássico daqui: ele
 * acerta na iluminação em que foi calibrado, e a demonstração falha no
 * laboratório porque a lâmpada é outra.
 */
function limiarDeOtsu(histograma: readonly number[], total: number): number {
  let somaTotal: number = 0;
  for (let valor: number = 0; valor < 256; valor += 1) {
    somaTotal += valor * (histograma[valor] ?? 0);
  }

  let somaEscura: number = 0;
  let pesoEscuro: number = 0;
  let melhorVariancia: number = -1;
  let melhorLimiar: number = 128;

  for (let valor: number = 0; valor < 256; valor += 1) {
    pesoEscuro += histograma[valor] ?? 0;
    if (pesoEscuro === 0) {
      continue;
    }
    const pesoClaro: number = total - pesoEscuro;
    if (pesoClaro === 0) {
      break;
    }
    somaEscura += valor * (histograma[valor] ?? 0);
    const mediaEscura: number = somaEscura / pesoEscuro;
    const mediaClara: number = (somaTotal - somaEscura) / pesoClaro;
    const variancia: number =
      pesoEscuro * pesoClaro * (mediaEscura - mediaClara) * (mediaEscura - mediaClara);
    if (variancia > melhorVariancia) {
      melhorVariancia = variancia;
      melhorLimiar = valor;
    }
  }
  return melhorLimiar;
}

/**
 * Agrupa pixels escuros vizinhos em manchas e guarda a borda de cada uma.
 *
 * O agrupamento é por vizinhança de quatro, com uma pilha explícita em vez de
 * recursão: uma mancha grande tem dezenas de milhares de pixels, e a recursão
 * estoura a pilha de chamadas do navegador com uma mensagem que não menciona
 * imagem nenhuma.
 *
 * Só a borda é guardada, e não a mancha inteira. É a borda que interessa aos
 * cantos, e guardar o miolo multiplicaria por vinte a memória usada por quadro.
 */
function acharManchas(
  escuro: Uint8Array,
  largura: number,
  altura: number,
  areaMinima: number,
  areaMaxima: number,
): Mancha[] {
  const visitado: Uint8Array = new Uint8Array(largura * altura);
  const manchas: Mancha[] = [];
  const pilha: number[] = [];

  for (let inicio: number = 0; inicio < escuro.length; inicio += 1) {
    if (escuro[inicio] === 0 || visitado[inicio] === 1) {
      continue;
    }

    pilha.length = 0;
    pilha.push(inicio);
    visitado[inicio] = 1;
    let pixels: number = 0;
    const borda: PontoNaImagem[] = [];
    let tocaAMargem: boolean = false;

    while (pilha.length > 0) {
      const indice: number | undefined = pilha.pop();
      if (indice === undefined) {
        break;
      }
      pixels += 1;
      const x: number = indice % largura;
      const y: number = Math.floor(indice / largura);
      if (x === 0 || y === 0 || x === largura - 1 || y === altura - 1) {
        tocaAMargem = true;
      }

      let naBorda: boolean = false;
      const vizinhos: readonly number[] = [
        y > 0 ? indice - largura : -1,
        y < altura - 1 ? indice + largura : -1,
        x > 0 ? indice - 1 : -1,
        x < largura - 1 ? indice + 1 : -1,
      ];
      for (const vizinho of vizinhos) {
        if (vizinho < 0) {
          naBorda = true;
          continue;
        }
        if (escuro[vizinho] === 0) {
          naBorda = true;
          continue;
        }
        if (visitado[vizinho] === 0) {
          visitado[vizinho] = 1;
          pilha.push(vizinho);
        }
      }
      if (naBorda) {
        borda.push({ x, y });
      }
    }

    const fracao: number = pixels / (largura * altura);
    // Mancha encostada na margem da imagem está cortada, e um quadrado cortado
    // não tem quatro cantos. Descartá-la evita uma pose confiante e errada, que
    // é pior que nenhuma pose.
    if (!tocaAMargem && fracao >= areaMinima && fracao <= areaMaxima) {
      manchas.push({ pixels, borda });
    }
  }
  return manchas;
}

/** Casco convexo pelo método da cadeia monótona, para reduzir a borda a poucos pontos. */
function cascoConvexo(pontos: readonly PontoNaImagem[]): PontoNaImagem[] {
  if (pontos.length < 4) {
    return [...pontos];
  }
  const ordenados: PontoNaImagem[] = [...pontos].sort((a, b) =>
    a.x === b.x ? a.y - b.y : a.x - b.x,
  );

  const cruz: (o: PontoNaImagem, a: PontoNaImagem, b: PontoNaImagem) => number = (o, a, b) =>
    (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);

  const meia: (lista: readonly PontoNaImagem[]) => PontoNaImagem[] = (lista) => {
    const saida: PontoNaImagem[] = [];
    for (const ponto of lista) {
      while (saida.length >= 2) {
        const ultimo: PontoNaImagem | undefined = saida[saida.length - 1];
        const penultimo: PontoNaImagem | undefined = saida[saida.length - 2];
        if (ultimo === undefined || penultimo === undefined) {
          break;
        }
        if (cruz(penultimo, ultimo, ponto) > 0) {
          break;
        }
        saida.pop();
      }
      saida.push(ponto);
    }
    saida.pop();
    return saida;
  };

  return [...meia(ordenados), ...meia([...ordenados].reverse())];
}

/**
 * Reduz o casco a quatro cantos.
 *
 * O caminho é o par mais distante primeiro: dois cantos opostos de um quadrado
 * são o segmento mais longo dentro dele. Com esses dois fixados, os outros dois
 * são os pontos que mais se afastam da reta entre eles, um de cada lado.
 *
 * Procurar os quatro pontos extremos nos eixos da imagem seria mais curto e
 * falha justamente no caso interessante: com o papel girado quarenta e cinco
 * graus, dois desses extremos caem no mesmo canto.
 */
function quatroCantos(casco: readonly PontoNaImagem[]): PontoNaImagem[] | undefined {
  if (casco.length < 4) {
    return undefined;
  }

  let a: PontoNaImagem | undefined = undefined;
  let b: PontoNaImagem | undefined = undefined;
  let maior: number = -1;
  for (let i: number = 0; i < casco.length; i += 1) {
    for (let j: number = i + 1; j < casco.length; j += 1) {
      const p: PontoNaImagem | undefined = casco[i];
      const q: PontoNaImagem | undefined = casco[j];
      if (p === undefined || q === undefined) {
        continue;
      }
      const distancia: number = Math.hypot(p.x - q.x, p.y - q.y);
      if (distancia > maior) {
        maior = distancia;
        a = p;
        b = q;
      }
    }
  }
  if (a === undefined || b === undefined) {
    return undefined;
  }

  let esquerda: PontoNaImagem | undefined = undefined;
  let direita: PontoNaImagem | undefined = undefined;
  let maiorEsquerda: number = 0;
  let maiorDireita: number = 0;
  for (const ponto of casco) {
    // O sinal do produto vetorial diz de que lado da reta o ponto está, e o
    // valor absoluto dele é proporcional à distância até ela. Dois usos de uma
    // conta só, e é por isso que ela aparece aqui em vez de uma fórmula de
    // distância ponto-reta.
    const lado: number = (b.x - a.x) * (ponto.y - a.y) - (b.y - a.y) * (ponto.x - a.x);
    if (lado > maiorDireita) {
      maiorDireita = lado;
      direita = ponto;
    }
    if (-lado > maiorEsquerda) {
      maiorEsquerda = -lado;
      esquerda = ponto;
    }
  }
  if (esquerda === undefined || direita === undefined) {
    return undefined;
  }

  // O sentido de percurso precisa ser sempre o mesmo, e não é de graça: os dois
  // cantos laterais saem em ordem que depende de qual extremo do diâmetro veio
  // primeiro. Um quadrilátero percorrido ao contrário produz uma leitura
  // espelhada da grade, e uma grade espelhada não casa com nenhuma das quatro
  // rotações do código — o marcador some sem que nada acuse motivo.
  const percurso: PontoNaImagem[] = [a, direita, b, esquerda];
  return areaComSinal(percurso) >= 0 ? percurso : [a, esquerda, b, direita];
}

/** Área com sinal pela fórmula do laço. O sinal é o sentido do percurso. */
function areaComSinal(cantos: readonly PontoNaImagem[]): number {
  let soma: number = 0;
  for (let i: number = 0; i < cantos.length; i += 1) {
    const atual: PontoNaImagem | undefined = cantos[i];
    const proximo: PontoNaImagem | undefined = cantos[(i + 1) % cantos.length];
    if (atual === undefined || proximo === undefined) {
      continue;
    }
    soma += atual.x * proximo.y - proximo.x * atual.y;
  }
  return soma / 2;
}

/** Área do quadrilátero pela fórmula do laço, para conferir o preenchimento. */
function areaDoQuadrilatero(cantos: readonly PontoNaImagem[]): number {
  let soma: number = 0;
  for (let i: number = 0; i < cantos.length; i += 1) {
    const atual: PontoNaImagem | undefined = cantos[i];
    const proximo: PontoNaImagem | undefined = cantos[(i + 1) % cantos.length];
    if (atual === undefined || proximo === undefined) {
      continue;
    }
    soma += atual.x * proximo.y - proximo.x * atual.y;
  }
  return Math.abs(soma) / 2;
}

/** Média de cinza de uma pequena vizinhança, para não decidir uma célula por um pixel só. */
function cinzaEm(
  cinza: Uint8Array,
  largura: number,
  altura: number,
  x: number,
  y: number,
): number {
  let soma: number = 0;
  let conta: number = 0;
  for (let dy: number = -1; dy <= 1; dy += 1) {
    for (let dx: number = -1; dx <= 1; dx += 1) {
      const px: number = Math.round(x) + dx;
      const py: number = Math.round(y) + dy;
      if (px < 0 || py < 0 || px >= largura || py >= altura) {
        continue;
      }
      soma += cinza[py * largura + px] ?? 0;
      conta += 1;
    }
  }
  return conta === 0 ? 255 : soma / conta;
}

export function montarDetector(): Detector {
  const tela: HTMLCanvasElement = document.createElement('canvas');
  const pincel: CanvasRenderingContext2D | null = tela.getContext('2d', {
    willReadFrequently: true,
  });
  if (pincel === null) {
    throw new Error('Este navegador não fornece contexto de desenho para a análise da imagem.');
  }

  let motivoAtual: MotivoDaFalta = 'a câmera ainda não entregou imagem';
  let custo: number = 0;

  return {
    detectar(
      fonte: CanvasImageSource,
      larguraFonte: number,
      alturaFonte: number,
    ): Deteccao | undefined {
      const inicio: number = performance.now();
      if (larguraFonte === 0 || alturaFonte === 0) {
        motivoAtual = 'a câmera ainda não entregou imagem';
        custo = performance.now() - inicio;
        return undefined;
      }

      const largura: number = LARGURA_DE_ANALISE_PX;
      const altura: number = Math.max(1, Math.round((alturaFonte / larguraFonte) * largura));
      if (tela.width !== largura || tela.height !== altura) {
        tela.width = largura;
        tela.height = altura;
      }
      pincel.drawImage(fonte, 0, 0, largura, altura);
      const dados: ImageData = pincel.getImageData(0, 0, largura, altura);

      const total: number = largura * altura;
      const cinza: Uint8Array = new Uint8Array(total);
      const histograma: number[] = new Array<number>(256).fill(0);
      for (let i: number = 0; i < total; i += 1) {
        // Os pesos são os da luminância percebida: o olho é muito mais sensível
        // ao verde que ao azul, e a média simples dos três canais apaga contraste
        // justamente em papel impresso sob luz amarelada.
        const valor: number = Math.round(
          0.299 * (dados.data[i * 4] ?? 0) +
            0.587 * (dados.data[i * 4 + 1] ?? 0) +
            0.114 * (dados.data[i * 4 + 2] ?? 0),
        );
        cinza[i] = valor;
        histograma[valor] = (histograma[valor] ?? 0) + 1;
      }

      const limiar: number = limiarDeOtsu(histograma, total);
      const escuro: Uint8Array = new Uint8Array(total);
      for (let i: number = 0; i < total; i += 1) {
        escuro[i] = (cinza[i] ?? 255) < limiar ? 1 : 0;
      }

      const manchas: Mancha[] = acharManchas(escuro, largura, altura, AREA_MINIMA, AREA_MAXIMA);
      if (manchas.length === 0) {
        motivoAtual = 'nenhuma mancha escura com tamanho de marcador';
        custo = performance.now() - inicio;
        return undefined;
      }

      // Da maior para a menor: com dois marcadores em quadro, ou com um cartaz
      // escuro ao fundo, a candidata mais promissora é a que ocupa mais imagem.
      manchas.sort((esquerda, direita) => direita.pixels - esquerda.pixels);

      let houveQuadrilatero: boolean = false;
      let houveMoldura: boolean = false;

      for (const mancha of manchas) {
        const cantos: PontoNaImagem[] | undefined = quatroCantos(cascoConvexo(mancha.borda));
        if (cantos === undefined) {
          continue;
        }
        const area: number = areaDoQuadrilatero(cantos);
        if (area <= 0 || mancha.pixels / area < PREENCHIMENTO_MINIMO) {
          // A mancha tem quatro extremos, mas não preenche o quadrilátero que
          // eles definem: é um contorno em L, uma sombra irregular, um cabo
          // escuro sobre a mesa.
          continue;
        }
        houveQuadrilatero = true;

        const homografia: Float64Array | undefined = resolverHomografia(
          [
            { x: 0, y: 0 },
            { x: 1, y: 0 },
            { x: 1, y: 1 },
            { x: 0, y: 1 },
          ],
          cantos,
        );
        if (homografia === undefined) {
          continue;
        }

        // A grade é amostrada pelo centro de cada célula, e a homografia é o que
        // permite fazer isso com o papel em perspectiva: uma célula distante
        // ocupa menos pixels que uma próxima, e uma grade regular na imagem
        // amostraria o lugar errado.
        const grade: number[][] = [];
        for (let linha: number = 0; linha < CELULAS_POR_LADO; linha += 1) {
          const valores: number[] = [];
          for (let coluna: number = 0; coluna < CELULAS_POR_LADO; coluna += 1) {
            const ponto: PontoNaImagem = aplicarHomografia(
              homografia,
              (coluna + 0.5) / CELULAS_POR_LADO,
              (linha + 0.5) / CELULAS_POR_LADO,
            );
            valores.push(cinzaEm(cinza, largura, altura, ponto.x, ponto.y) < limiar ? 0 : 1);
          }
          grade.push(valores);
        }

        let molduraFechada: boolean = true;
        for (let i: number = 0; i < CELULAS_POR_LADO; i += 1) {
          const primeira: number[] | undefined = grade[0];
          const ultima: number[] | undefined = grade[CELULAS_POR_LADO - 1];
          const linhaI: number[] | undefined = grade[i];
          if (
            primeira?.[i] !== 0 ||
            ultima?.[i] !== 0 ||
            linhaI?.[0] !== 0 ||
            linhaI?.[CELULAS_POR_LADO - 1] !== 0
          ) {
            molduraFechada = false;
            break;
          }
        }
        if (!molduraFechada) {
          continue;
        }
        houveMoldura = true;

        const miolo: number[][] = [];
        for (let linha: number = 1; linha <= CELULAS_DE_CODIGO; linha += 1) {
          const valores: number[] = [];
          for (let coluna: number = 1; coluna <= CELULAS_DE_CODIGO; coluna += 1) {
            valores.push(grade[linha]?.[coluna] ?? 0);
          }
          miolo.push(valores);
        }

        const orientacoes: readonly (readonly (readonly number[])[])[] = orientacoesDoCodigo();
        const lido: string = miolo.map((linha) => linha.join('')).join('|');
        for (let volta: number = 0; volta < orientacoes.length; volta += 1) {
          const esperada: readonly (readonly number[])[] | undefined = orientacoes[volta];
          if (esperada === undefined) {
            continue;
          }
          if (esperada.map((linha) => linha.join('')).join('|') !== lido) {
            continue;
          }
          // A ordem dos cantos é girada de volta pelo mesmo tanto que o papel
          // estava girado. Sem isto a pose sai correta em posição e errada em
          // orientação, e a bancada nasce de lado sobre um marcador que o
          // detector reconheceu perfeitamente.
          const ordenados: PontoNaImagem[] = [];
          for (let i: number = 0; i < 4; i += 1) {
            const canto: PontoNaImagem | undefined = cantos[(i + volta) % 4];
            if (canto !== undefined) {
              ordenados.push(canto);
            }
          }
          custo = performance.now() - inicio;
          return {
            cantos: ordenados,
            larguraPx: largura,
            alturaPx: altura,
            quartosDeVolta: volta,
            limiar,
          };
        }
      }

      motivoAtual = !houveQuadrilatero
        ? 'nenhuma mancha com formato de quadrilátero'
        : !houveMoldura
          ? 'a moldura não fechou em nenhuma candidata'
          : 'o código lido não é o do marcador declarado';
      custo = performance.now() - inicio;
      return undefined;
    },

    motivo(): MotivoDaFalta {
      return motivoAtual;
    },

    custoMs(): number {
      return custo;
    },
  };
}

As duas últimas etapas são as que separam um detector de um contador de manchas. Cada mancha é reduzida a quatro cantos, porque é disso que a pose precisa; e o miolo é lido, porque uma mancha quadrada qualquer — a tampa de uma caixa, um aparelho apoiado de bruços — passaria por marcador sem essa conferência.

A redução a quatro cantos merece um parágrafo, porque a solução óbvia falha de um jeito difícil de ver. O caminho curto é procurar os pontos extremos nos eixos da imagem: o mais à esquerda, o mais acima, e assim por diante. Funciona com o papel de frente. Com ele girado 45 graus, dois desses extremos caem no mesmo canto, e o quadrilátero degenera num triângulo.

O caminho adotado é outro: acha-se primeiro o par de pontos mais distantes entre si, que num quadrado são dois cantos opostos. Fixados esses dois, os outros são os que mais se afastam da reta entre eles, um de cada lado. Não há orientação privilegiada, e o papel pode estar como quiser.

Há uma armadilha a mais, e ela custou uma tarde. Os quatro cantos precisam sair sempre no mesmo sentido de percurso. Percorridos ao contrário, a leitura da grade sai espelhada — e grade espelhada não casa com nenhuma das quatro rotações do código. O marcador some, e nada acusa motivo.

1.3.3 De quatro cantos a metros

A travessia entre o quadrilátero em pixels e uma pose em metros é o coração matemático deste trecho, e ela pede uma segunda informação que a imagem também não traz: a distância focal da câmera.

O navegador não a informa. Aqui o projeto estima, e a estimativa está declarada no arquivo com a consequência escrita ao lado: assume-se um campo de visão horizontal de 60 graus, que é a faixa comum das câmeras traseiras de celular. É chute educado, e não medida, e o texto diz isso com essa palavra.

src/bancada/anchoring/pose.ts
// ---------------------------------------------------------------------------
// De quatro cantos em pixels até uma pose em metros.
//
// O detector devolve onde o papel está na imagem: quatro pares de coordenadas,
// medidos em pixels. O que a cena precisa é outra coisa — a que distância da
// câmera aquele papel está, e com que inclinação. Este arquivo faz a travessia,
// e ela é o coração matemático do módulo.
//
// A travessia é possível por causa de uma informação que não veio da imagem: o
// lado do quadrado em metros. Sem ela o problema não tem solução única, e a
// razão é geométrica, não numérica — um marcador pequeno e perto projeta na
// câmera exatamente o mesmo quadrilátero que um marcador grande e longe. É essa
// ambiguidade que a medida física resolve, e é por isso que trocar o papel muda
// tudo o que se vê.
//
// A segunda informação que não vem da imagem é a distância focal da câmera, e
// aqui o projeto FAZ UMA ESTIMATIVA — declarada logo abaixo, com a consequência
// escrita ao lado. É a diferença de fundo entre este registro e o do módulo
// anterior: lá o aparelho respondia com o que os sensores dele mediram; aqui o
// ambiente deduz de uma imagem plana, com um parâmetro chutado. O resultado
// oscila, e a oscilação é medida em vez de disfarçada.
// ---------------------------------------------------------------------------

import { Matrix4, Vector3 } from 'three';

/**
 * Campo de visão horizontal assumido para a câmera traseira, em graus.
 *
 * A interface de mídia do navegador não informa a distância focal, e nem toda
 * câmera responde à consulta de capacidades com algo aproveitável. Sessenta
 * graus é a faixa comum das câmeras traseiras de celular, e adotá-lo é uma
 * ESTIMATIVA — não uma medida.
 *
 * A consequência é direta e vale dizer ao aluno: o erro entra quase todo na
 * distância. Uma câmera de campo mais largo que o assumido faz a bancada nascer
 * mais perto do que está, e uma de campo mais estreito, mais longe. O tamanho
 * relativo e a inclinação sobrevivem bem; a profundidade é a vítima.
 *
 * Quem quiser medir em vez de estimar tem um caminho barato: fotografar o
 * marcador a uma distância conhecida, medir quantos pixels o lado ocupa e
 * resolver a mesma conta ao contrário.
 */
export const CAMPO_HORIZONTAL_ASSUMIDO_GRAUS: number = 60;

/** Um ponto na imagem, em pixels, com origem no canto superior esquerdo. */
export interface PontoNaImagem {
  readonly x: number;
  readonly y: number;
}

/** A pose do marcador em relação à câmera, já na convenção da cena. */
export interface PoseDoMarcador {
  /** Matriz do papel no espaço da câmera, com a normal dele apontando para cima. */
  readonly matriz: Matrix4;
  /** Distância da câmera ao centro do papel, em metros. */
  readonly distanciaM: number;
  /**
   * Quanto a reconstrução deixou de fechar, em pixels.
   *
   * É o erro de reprojeção: reprojetam-se os quatro cantos do modelo com a pose
   * encontrada e mede-se o quanto eles caem longe dos cantos detectados. Valor
   * alto com detecção válida denuncia a estimativa de distância focal, e não o
   * detector.
   */
  readonly erroDeReprojecaoPx: number;
}

/**
 * Resolve a homografia que leva quatro pontos de origem a quatro de destino.
 *
 * São oito incógnitas — a nona entrada da matriz é fixada em um, porque a
 * transformação é definida a menos de escala. Cada par de pontos dá duas
 * equações, e quatro pares fecham o sistema exatamente. A eliminação abaixo é a
 * de Gauss com troca de linha pelo maior pivô, que é o que impede um pivô quase
 * nulo de estourar o resultado quando o quadrilátero é quase degenerado.
 */
export function resolverHomografia(
  origem: readonly PontoNaImagem[],
  destino: readonly PontoNaImagem[],
): Float64Array | undefined {
  if (origem.length !== 4 || destino.length !== 4) {
    return undefined;
  }

  const sistema: number[][] = [];
  for (let i: number = 0; i < 4; i += 1) {
    const de: PontoNaImagem | undefined = origem[i];
    const para: PontoNaImagem | undefined = destino[i];
    if (de === undefined || para === undefined) {
      return undefined;
    }
    sistema.push([de.x, de.y, 1, 0, 0, 0, -para.x * de.x, -para.x * de.y, para.x]);
    sistema.push([0, 0, 0, de.x, de.y, 1, -para.y * de.x, -para.y * de.y, para.y]);
  }

  for (let coluna: number = 0; coluna < 8; coluna += 1) {
    let melhor: number = coluna;
    for (let linha: number = coluna + 1; linha < 8; linha += 1) {
      const candidata: number[] | undefined = sistema[linha];
      const atual: number[] | undefined = sistema[melhor];
      if (candidata === undefined || atual === undefined) {
        return undefined;
      }
      if (Math.abs(candidata[coluna] ?? 0) > Math.abs(atual[coluna] ?? 0)) {
        melhor = linha;
      }
    }

    const pivoLinha: number[] | undefined = sistema[melhor];
    const linhaAtual: number[] | undefined = sistema[coluna];
    if (pivoLinha === undefined || linhaAtual === undefined) {
      return undefined;
    }
    sistema[melhor] = linhaAtual;
    sistema[coluna] = pivoLinha;

    const pivo: number = pivoLinha[coluna] ?? 0;
    // Pivô nulo aqui significa quadrilátero degenerado — três cantos alinhados,
    // ou dois no mesmo lugar. Não há pose a extrair, e devolver ausência é a
    // resposta certa: o detector tenta de novo no quadro seguinte.
    if (Math.abs(pivo) < 1e-12) {
      return undefined;
    }
    for (let k: number = coluna; k < 9; k += 1) {
      pivoLinha[k] = (pivoLinha[k] ?? 0) / pivo;
    }
    for (let linha: number = 0; linha < 8; linha += 1) {
      if (linha === coluna) {
        continue;
      }
      const alvo: number[] | undefined = sistema[linha];
      if (alvo === undefined) {
        return undefined;
      }
      const fator: number = alvo[coluna] ?? 0;
      if (fator === 0) {
        continue;
      }
      for (let k: number = coluna; k < 9; k += 1) {
        alvo[k] = (alvo[k] ?? 0) - fator * (pivoLinha[k] ?? 0);
      }
    }
  }

  const h: Float64Array = new Float64Array(9);
  for (let i: number = 0; i < 8; i += 1) {
    h[i] = sistema[i]?.[8] ?? 0;
  }
  h[8] = 1;
  return h;
}

/** Leva um ponto pela homografia, desfazendo a divisão projetiva no fim. */
export function aplicarHomografia(h: Float64Array, x: number, y: number): PontoNaImagem {
  const w: number = (h[6] ?? 0) * x + (h[7] ?? 0) * y + (h[8] ?? 1);
  if (Math.abs(w) < 1e-12) {
    return { x: 0, y: 0 };
  }
  return {
    x: ((h[0] ?? 0) * x + (h[1] ?? 0) * y + (h[2] ?? 0)) / w,
    y: ((h[3] ?? 0) * x + (h[4] ?? 0) * y + (h[5] ?? 0)) / w,
  };
}

/** A distância focal em pixels que decorre do campo de visão assumido. */
export function focalAssumidaPx(larguraPx: number): number {
  const meioCampo: number = (CAMPO_HORIZONTAL_ASSUMIDO_GRAUS * Math.PI) / 360;
  return larguraPx / 2 / Math.tan(meioCampo);
}

/**
 * Extrai a pose do papel a partir dos quatro cantos detectados.
 *
 * O caminho é o clássico: monta-se a homografia do plano do marcador para a
 * imagem, retira-se dela o efeito da lente e o que sobra são duas colunas da
 * rotação e a translação, todas a menos de uma escala comum. A escala sai do
 * fato de que as colunas de uma rotação têm comprimento um — e é aí que o
 * tamanho impresso entra na conta, porque as coordenadas do modelo estão em
 * metros.
 *
 * As duas colunas medidas nunca saem exatamente perpendiculares: ruído de
 * detecção, distorção da lente e a distância focal estimada garantem isso. A
 * ortonormalização abaixo é o conserto honesto — ela toma a base medida e
 * devolve a rotação mais próxima dela, em vez de fingir que a medida veio limpa.
 */
export function poseDoQuadrilatero(
  cantos: readonly PontoNaImagem[],
  ladoM: number,
  larguraPx: number,
  alturaPx: number,
): PoseDoMarcador | undefined {
  const meio: number = ladoM / 2;
  // Os quatro cantos do papel em metros, no plano do próprio papel, na mesma
  // ordem em que o detector entrega os cantos da imagem.
  const modelo: readonly PontoNaImagem[] = [
    { x: -meio, y: -meio },
    { x: meio, y: -meio },
    { x: meio, y: meio },
    { x: -meio, y: meio },
  ];

  const h: Float64Array | undefined = resolverHomografia(modelo, cantos);
  if (h === undefined) {
    return undefined;
  }

  const f: number = focalAssumidaPx(larguraPx);
  const cx: number = larguraPx / 2;
  const cy: number = alturaPx / 2;

  // Desfazer o efeito da lente é dividir pela focal e tirar o centro óptico. A
  // inversa da matriz de câmera é simples o bastante para ser escrita à mão, e
  // escrevê-la assim deixa visível o que ela faz.
  const c1: Vector3 = new Vector3(
    ((h[0] ?? 0) - cx * (h[6] ?? 0)) / f,
    ((h[3] ?? 0) - cy * (h[6] ?? 0)) / f,
    h[6] ?? 0,
  );
  const c2: Vector3 = new Vector3(
    ((h[1] ?? 0) - cx * (h[7] ?? 0)) / f,
    ((h[4] ?? 0) - cy * (h[7] ?? 0)) / f,
    h[7] ?? 0,
  );
  const c3: Vector3 = new Vector3(
    ((h[2] ?? 0) - cx * (h[8] ?? 0)) / f,
    ((h[5] ?? 0) - cy * (h[8] ?? 0)) / f,
    h[8] ?? 0,
  );

  const norma: number = (c1.length() + c2.length()) / 2;
  if (norma < 1e-9) {
    return undefined;
  }
  const escala: number = 1 / norma;
  c1.multiplyScalar(escala);
  c2.multiplyScalar(escala);
  c3.multiplyScalar(escala);

  // O papel tem de estar à frente da câmera. A homografia é indiferente ao
  // sinal, e a solução espelhada é numericamente tão boa quanto a certa: sem
  // esta verificação, a bancada nasce atrás de quem olha em parte dos quadros.
  if (c3.z < 0) {
    c1.negate();
    c2.negate();
    c3.negate();
  }

  // Ortonormalização: o primeiro eixo é mantido, o segundo perde o que tinha do
  // primeiro, e o terceiro sai do produto vetorial. É Gram-Schmidt, e o resíduo
  // que ele descarta é justamente o erro de medida.
  const eixoX: Vector3 = c1.clone().normalize();
  const eixoY: Vector3 = c2.clone().addScaledVector(eixoX, -eixoX.dot(c2)).normalize();
  const eixoZ: Vector3 = new Vector3().crossVectors(eixoX, eixoY);

  // Da convenção da visão computacional para a da cena: lá o eixo vertical da
  // imagem aponta para baixo e a câmera olha para a frente; aqui o vertical
  // aponta para cima e a câmera olha para trás. A conversão é uma meia-volta em
  // torno do eixo horizontal, e ela cabe em três trocas de sinal.
  const paraCena: (v: Vector3) => Vector3 = (v: Vector3): Vector3 =>
    new Vector3(v.x, -v.y, -v.z);
  const bx: Vector3 = paraCena(eixoX);
  const by: Vector3 = paraCena(eixoY);
  const bz: Vector3 = paraCena(eixoZ);
  const posicao: Vector3 = paraCena(c3);

  // O papel está deitado, e o que se apoia nele fica de pé. A base acima tem a
  // normal do papel no terceiro eixo; a cena espera o que aponta para cima no
  // segundo. A troca abaixo é essa meia rotação, feita por reordenação de
  // colunas em vez de multiplicação de matrizes.
  const matriz: Matrix4 = new Matrix4().makeBasis(bx, bz, by.clone().negate());
  matriz.setPosition(posicao);

  return {
    matriz,
    distanciaM: posicao.length(),
    erroDeReprojecaoPx: erroDeReprojecao(h, modelo, cantos),
  };
}

/** Reprojeta os cantos do modelo e mede o quanto eles caem longe dos detectados. */
function erroDeReprojecao(
  h: Float64Array,
  modelo: readonly PontoNaImagem[],
  cantos: readonly PontoNaImagem[],
): number {
  let soma: number = 0;
  for (let i: number = 0; i < modelo.length; i += 1) {
    const alvo: PontoNaImagem | undefined = cantos[i];
    const fonte: PontoNaImagem | undefined = modelo[i];
    if (alvo === undefined || fonte === undefined) {
      continue;
    }
    const projetado: PontoNaImagem = aplicarHomografia(h, fonte.x, fonte.y);
    soma += Math.hypot(projetado.x - alvo.x, projetado.y - alvo.y);
  }
  return soma / modelo.length;
}

/** O que a estimativa de câmera assume, e o que ela custa. */
export function linhasDaPose(larguraDeAnalisePx: number): string[] {
  return [
    `Campo de visão horizontal assumido: ${CAMPO_HORIZONTAL_ASSUMIDO_GRAUS} graus, o que dá ` +
      `${focalAssumidaPx(larguraDeAnalisePx).toFixed(0)} pixels de distância focal na imagem de ` +
      `${larguraDeAnalisePx} pixels de largura que o detector analisa.`,
    'Isto é estimativa, e não medida: o navegador não informa a distância focal da câmera. O erro ' +
      'entra quase todo na profundidade — câmera mais aberta que a assumida traz a bancada para ' +
      'perto, mais fechada a afasta —, enquanto inclinação e proporção sobrevivem bem.',
    'O erro de reprojeção informado a cada detecção é a medida de quanto a reconstrução deixou de ' +
      'fechar. Valor alto com o papel bem visível acusa a estimativa acima, e não o detector.',
  ];
}

Vale saber onde o erro dessa estimativa vai parar, porque ele não se espalha por igual. Ele entra quase todo na profundidade: câmera mais aberta que a assumida traz a bancada para perto, mais fechada a afasta. Inclinação e proporção sobrevivem bem. Quem quiser medir em vez de estimar tem um caminho barato — fotografar o marcador a uma distância conhecida e resolver a mesma conta ao contrário.

O resto do arquivo é geometria projetiva sem mistério. Monta-se a homografia que leva o plano do papel à imagem, retira-se dela o efeito da lente, e o que sobra são duas colunas da rotação e a translação, todas a menos de uma escala comum. A escala sai do fato de que as colunas de uma rotação têm comprimento um, e é aí que os 15 cm entram na conta.

Duas cautelas ficaram gravadas no código. A primeira é o teste de sinal: a homografia é indiferente a ele, e a solução espelhada é numericamente tão boa quanto a certa — sem o teste, a bancada nasce atrás de quem olha em parte dos quadros. A segunda é a ortonormalização, que é o conserto honesto de uma medida suja: as duas colunas nunca saem perpendiculares, e o que se faz é tomar a rotação mais próxima da base medida em vez de fingir que ela veio limpa.

E há o erro de reprojeção, que o arquivo calcula e o painel exibe. Ele mede o quanto a reconstrução deixou de fechar, reprojetando os cantos do modelo e comparando com os detectados. Valor alto com o papel bem visível acusa a estimativa de câmera, e não o detector. É o número que evita a suspeita errada.

1.3.4 Quem compõe, agora, somos nós

No trecho anterior a composição sobre o mundo custou uma linha: apagava-se o fundo da cena, e o compositor do aparelho punha a imagem da câmera atrás. Quem compunha era a plataforma, dentro da sessão, e o projeto nem via o vídeo.

Aqui não há sessão, não há compositor e não há plataforma a quem delegar. A implementação é tão franca que chega a parecer trapaça: o elemento de vídeo fica atrás da superfície de desenho, na própria página, e a superfície fica transparente. São dois retângulos empilhados, e o mundo real é o de baixo.

src/bancada/anchoring/camera.ts
// ---------------------------------------------------------------------------
// A câmera como fundo — e a composição feita por nós, pela primeira vez.
//
// No módulo anterior a composição sobre o mundo custou uma linha: apagava-se o
// fundo da cena, e o compositor do aparelho punha a imagem da câmera atrás.
// Quem compunha era a plataforma, dentro da sessão, e o projeto nem via o vídeo.
//
// Aqui não há sessão, não há compositor e não há plataforma a quem delegar. A
// composição passa a ser nossa, e a implementação é tão franca que chega a
// parecer trapaça: o elemento de vídeo fica ATRÁS da superfície de desenho, na
// própria página, e a superfície fica transparente. São dois retângulos
// empilhados, e o mundo real é o de baixo.
//
// Essa franqueza é conteúdo. A pergunta que o aluno leva do módulo anterior —
// contra o que o objeto é registrado? — ganha aqui uma segunda pergunta, e as
// duas juntas separam realidade aumentada de vídeo com desenho por cima: QUEM
// compõe, e contra o QUE se registra. Empilhar vídeo e desenho é composição
// legítima; o que faria disto papel de parede seria o desenho não se localizar
// em relação ao que a câmera está vendo. A localização vem dos dois arquivos
// vizinhos, e é ela que sustenta a diferença.
//
// Uma restrição de plataforma vale a nota, porque parece defeito: em muitos
// aparelhos de mão, um vídeo só toca sem gesto de quem usa se estiver mudo e
// marcado para tocar dentro da página. Sem esses dois atributos a imagem
// simplesmente não anda, sem erro nenhum no console.
// ---------------------------------------------------------------------------

/** Em que estado o acesso à câmera está agora. */
export type EstadoDaCamera =
  | 'desligada'
  | 'pedindo permissão'
  | 'ligada'
  | 'permissão negada'
  | 'sem câmera disponível'
  | 'câmera ocupada por outro programa'
  | 'sem acesso a câmera neste contexto';

export interface CameraDeFundo {
  /** Pede a câmera traseira e a põe atrás da superfície de desenho. */
  ligar(): Promise<void>;
  /** Encerra as trilhas e devolve a página ao que era. */
  desligar(): void;
  estado(): EstadoDaCamera;
  /** O elemento de vídeo, quando ele já tem imagem para ser analisada. */
  imagem(): HTMLVideoElement | undefined;
  larguraDaImagem(): number;
  alturaDaImagem(): number;
  linhas(): string[];
}

/**
 * Diz se este aparelho tem alguma câmera, antes de pedir permissão para usá-la.
 *
 * A consulta de dispositivos responde sem permissão alguma — o que ela esconde,
 * nesse caso, é o NOME de cada um, e não a existência deles. É o bastante para a
 * decisão de qual regime abrir, e é o que evita pedir permissão de câmera a um
 * computador de mesa que não tem nenhuma: o pedido apareceria, seria recusado
 * pelo próprio navegador, e a mensagem resultante culparia quem não tem culpa.
 */
export async function haCameraDisponivel(): Promise<boolean> {
  if (navigator.mediaDevices === undefined) {
    return false;
  }
  try {
    const dispositivos: readonly MediaDeviceInfo[] = await navigator.mediaDevices.enumerateDevices();
    return dispositivos.some((dispositivo) => dispositivo.kind === 'videoinput');
  } catch {
    // Navegador que recusa a consulta não permite concluir ausência de câmera.
    // Assumir que há é o erro menos ruim: no pior caso o pedido de permissão
    // falha adiante, e ali a mensagem já sabe distinguir os motivos.
    return true;
  }
}

/**
 * Instala a câmera atrás do canvas informado.
 *
 * O canvas é envolvido por um contêiner criado em tempo de execução, e não
 * declarado na página. A razão é de propriedade: o empilhamento existe só
 * enquanto este regime está ligado, e deixá-lo escrito na folha de estilo faria
 * o regime em janela carregar, para sempre, a marca de um regime que ele não
 * usa.
 */
export function montarCameraDeFundo(canvas: HTMLCanvasElement): CameraDeFundo {
  const conteiner: HTMLDivElement = document.createElement('div');
  conteiner.style.position = 'relative';
  const pai: HTMLElement | null = canvas.parentElement;
  if (pai !== null) {
    pai.insertBefore(conteiner, canvas);
    conteiner.appendChild(canvas);
  }

  const video: HTMLVideoElement = document.createElement('video');
  video.playsInline = true;
  video.muted = true;
  video.autoplay = true;
  video.style.position = 'absolute';
  video.style.inset = '0';
  video.style.width = '100%';
  video.style.height = '100%';
  // A imagem preenche o retângulo e o excesso é cortado. Esticar em vez de
  // cortar deformaria o que a câmera vê, e o marcador deformado deixa de casar
  // com o quadrado que o detector procura.
  video.style.objectFit = 'cover';
  video.style.borderRadius = getComputedStyle(canvas).borderRadius;
  video.style.display = 'none';
  conteiner.insertBefore(video, canvas);

  const fundoOriginalDoCanvas: string = canvas.style.background;

  let trilhas: MediaStream | undefined = undefined;
  let estadoAtual: EstadoDaCamera = 'desligada';
  let detalheDaFalha: string = '';

  function classificar(erro: unknown): EstadoDaCamera {
    if (!(erro instanceof Error)) {
      return 'sem câmera disponível';
    }
    detalheDaFalha = erro.message;
    switch (erro.name) {
      case 'NotAllowedError':
      case 'SecurityError':
        return 'permissão negada';
      case 'NotFoundError':
      case 'OverconstrainedError':
        return 'sem câmera disponível';
      case 'NotReadableError':
        return 'câmera ocupada por outro programa';
      default:
        return 'sem câmera disponível';
    }
  }

  return {
    async ligar(): Promise<void> {
      if (estadoAtual === 'ligada') {
        return;
      }
      // A interface de mídia não é exposta fora de contexto seguro, e o sintoma
      // é idêntico ao de um aparelho sem câmera. É o mesmo engano que a sessão
      // imersiva já trouxe no módulo do ciclo, e a mensagem precisa nomear o
      // endereço em vez de acusar o aparelho.
      if (!window.isSecureContext || navigator.mediaDevices === undefined) {
        estadoAtual = 'sem acesso a câmera neste contexto';
        detalheDaFalha =
          'a interface de mídia não é exposta fora de contexto seguro; a causa é a URL, não o aparelho';
        return;
      }

      estadoAtual = 'pedindo permissão';
      try {
        // A traseira é pedida como preferência, e não como exigência. Exigi-la
        // faz o pedido inteiro ser recusado no computador de mesa, que só tem a
        // frontal — e ali a demonstração ainda funciona, com o marcador virado
        // para a tela.
        trilhas = await navigator.mediaDevices.getUserMedia({
          video: { facingMode: { ideal: 'environment' } },
          audio: false,
        });
        video.srcObject = trilhas;
        await video.play();
        video.style.display = 'block';
        // A superfície de desenho precisa deixar ver o que está atrás dela. O
        // fundo opaco declarado na folha de estilo é do regime em janela, e
        // esquecê-lo aqui deixa a câmera ligada e invisível — que é o defeito
        // mais desconcertante deste arquivo, porque tudo o mais funciona.
        canvas.style.background = 'transparent';
        estadoAtual = 'ligada';
        detalheDaFalha = '';
      } catch (erro: unknown) {
        estadoAtual = classificar(erro);
      }
    },

    desligar(): void {
      if (trilhas !== undefined) {
        // Parar cada trilha é o que apaga a luz indicadora do aparelho. Soltar
        // apenas a referência deixa a câmera ligada, e quem usa vê o aviso de
        // gravação continuar aceso depois de sair do regime.
        for (const trilha of trilhas.getTracks()) {
          trilha.stop();
        }
        trilhas = undefined;
      }
      video.srcObject = null;
      video.style.display = 'none';
      canvas.style.background = fundoOriginalDoCanvas;
      estadoAtual = 'desligada';
    },

    estado(): EstadoDaCamera {
      return estadoAtual;
    },

    imagem(): HTMLVideoElement | undefined {
      // Antes do primeiro quadro o vídeo tem largura zero, e analisar uma imagem
      // de largura zero devolve uma tela em branco — sem erro, e com a conclusão
      // errada de que o marcador não está sendo encontrado.
      return estadoAtual === 'ligada' && video.videoWidth > 0 ? video : undefined;
    },

    larguraDaImagem(): number {
      return video.videoWidth;
    },

    alturaDaImagem(): number {
      return video.videoHeight;
    },

    linhas(): string[] {
      const linhas: string[] = [`Câmera: ${estadoAtual}.`];
      if (estadoAtual === 'ligada') {
        linhas.push(
          `Imagem recebida: ${video.videoWidth} por ${video.videoHeight} pixels.`,
          'A imagem está ATRÁS da superfície de desenho, e a superfície ficou transparente. A ' +
            'composição, neste regime, é nossa: são dois retângulos empilhados na página.',
        );
        return linhas;
      }
      if (detalheDaFalha !== '') {
        linhas.push(`Detalhe informado pelo navegador: ${detalheDaFalha}.`);
      }
      switch (estadoAtual) {
        case 'permissão negada':
          linhas.push(
            'Quem recusa a permissão é quem usa, e a recusa fica gravada para este endereço. ' +
              'Reverter é no cadeado da barra de endereços, e não no ambiente.',
          );
          break;
        case 'sem câmera disponível':
          linhas.push(
            'Sem câmera não há este regime, e o que resta neste aparelho é a oficina em janela — ' +
              'que monta a bancada inteira, e não é prêmio de consolação.',
          );
          break;
        case 'câmera ocupada por outro programa':
          linhas.push(
            'Outro programa está com a câmera. Feche a chamada de vídeo aberta e tente de novo: ' +
              'em parte dos aparelhos, uma só aplicação tem acesso por vez.',
          );
          break;
        case 'sem acesso a câmera neste contexto':
          linhas.push(
            'Sirva a página por HTTPS e aceite o aviso do certificado neste aparelho. O aparelho ' +
              'tem câmera; o que falta é o endereço.',
          );
          break;
        default:
          break;
      }
      return linhas;
    },
  };
}

Essa franqueza é conteúdo, e ela refina a pergunta que o trecho anterior deixou. Lá a pergunta era contra o que o objeto é registrado. Aqui aparece uma segunda, e as duas juntas separam realidade aumentada de desenho sobre vídeo: quem compõe, e contra o que se registra. Empilhar vídeo e desenho é composição legítima. O que faria disto papel de parede seria o desenho não se localizar em relação ao que a câmera vê.

Uma linha fora das pastas novas foi paga por este arquivo, e ela vale a menção porque o defeito que ela evita é cruel. A superfície de desenho passou a admitir transparência. Sem isso, a câmera liga, a luz do aparelho acende, a detecção funciona — e a tela mostra apenas a cena sobre o fundo cinza de sempre.

1.3.5 O alinhamento invisível

Falta a conta que separa um registro que gruda de um que escorrega, e ela não está em nenhum dos arquivos de ancoragem: está no regime.

A câmera virtual precisa ter o mesmo campo de visão da física. Com o campo errado, tudo funciona — o marcador é detectado, a pose é calculada — e a bancada aparece deslocada de um jeito que piora quando alguém se aproxima. O suspeito errado é sempre a detecção, que está impecável.

A conta tem uma sutileza a mais neste ambiente. A imagem preenche a superfície de desenho por recorte, e não por deformação, logo parte do que a câmera vê fica fora da tela. O campo de visão que a câmera virtual precisa ter é o do que sobrou, e não o da câmera inteira.

src/bancada/modes/marcado.ts
// ---------------------------------------------------------------------------
// O quarto regime: realidade aumentada onde a sessão aumentada não existe.
//
// Ele não está na declaração dos três regimes do primeiro módulo, e essa
// ausência é honesta em vez de descuidada: aquele arquivo declarou os três modos
// que a interface de realidade estendida oferece, e este não é um deles. O que
// há aqui é uma câmera, uma imagem e um papel — nenhuma sessão, nenhum espaço de
// referência concedido, nenhum mapa do cômodo.
//
// A comparação com o módulo anterior é o conteúdo do módulo inteiro, e ela cabe
// em quatro diferenças que o aluno consegue observar em cinco minutos.
//
// A origem do mundo virtual, no registro por superfície, é um lugar da sala que
// o aparelho encontrou; aqui é um pedaço de papel que alguém pôs sobre a mesa.
// A escala, lá, vem dos sensores que medem o ambiente; aqui vem de um número
// declarado em código, que precisa bater com a régua. A permanência, lá, é da
// âncora, que a plataforma corrige sozinha; aqui não há permanência alguma —
// tira-se o papel de quadro e o registro acaba. E a estabilidade, lá, é do mapa
// acumulado; aqui é quadro a quadro, e a bancada treme.
//
// O tremor não é defeito a suavizar. Ele é a evidência mais direta de que os
// dois registros não são a mesma coisa com aparelhos diferentes, e por isso ele
// é MEDIDO neste arquivo em vez de filtrado. Um filtro que o escondesse deixaria
// a demonstração mais bonita e apagaria justamente o que ela veio mostrar.
// ---------------------------------------------------------------------------

import { Color, Matrix4, Object3D, PerspectiveCamera, Scene, Vector3, type Texture } from 'three';

import {
  LARGURA_DE_ANALISE_PX,
  montarDetector,
  type Deteccao,
  type Detector,
  type MotivoDaFalta,
} from '../anchoring/deteccao';
import { assentarMatrizNoEspacoLocal } from '../anchoring/espaco';
import { LADO_IMPRESSO_M, linhasDoPadrao } from '../anchoring/padrao';
import {
  CAMPO_HORIZONTAL_ASSUMIDO_GRAUS,
  linhasDaPose,
  poseDoQuadrilatero,
  type PoseDoMarcador,
} from '../anchoring/pose';
import type { CameraDeFundo } from '../anchoring/camera';

/** Por quantos segundos a bancada fica onde estava depois de o papel sair de quadro. */
const PACIENCIA_SEM_MARCADOR_S: number = 1.5;

/** Em que situação o registro por marcador está agora. */
export type EstadoDoMarcado =
  | 'desligado'
  | 'procurando o marcador'
  | 'registrado'
  | 'marcador fora de quadro';

export interface RegimeMarcado {
  /** Liga a câmera, apaga o fundo e alinha a câmera virtual à física. */
  entrar(): Promise<void>;
  sair(): void;
  ligado(): boolean;
  estado(): EstadoDoMarcado;
  /** Analisa um quadro da câmera e reposiciona a bancada. */
  atualizar(decorridoS: number): void;
  linhas(): string[];
}

/**
 * Alinha o campo de visão da câmera virtual ao da física, depois do recorte.
 *
 * Esta é a conta que separa um registro que gruda de um que escorrega, e ela é
 * invisível: com o campo de visão errado, o marcador é detectado corretamente, a
 * pose é calculada corretamente, e a bancada aparece deslocada de um jeito que
 * piora quando se aproxima. O suspeito errado é sempre a detecção.
 *
 * A imagem preenche a superfície de desenho por recorte, e não por deformação —
 * logo parte do que a câmera vê fica fora da tela. O campo de visão que a câmera
 * virtual precisa ter é o do que SOBROU, e não o da câmera inteira.
 */
function alinharCampoDeVisao(
  camera: PerspectiveCamera,
  canvas: HTMLCanvasElement,
  larguraDoVideo: number,
  alturaDoVideo: number,
  campoHorizontalGraus: number,
): void {
  if (larguraDoVideo === 0 || alturaDoVideo === 0) {
    return;
  }
  const focalEmPixelsDoVideo: number =
    larguraDoVideo / 2 / Math.tan((campoHorizontalGraus * Math.PI) / 360);
  const larguraDaTela: number = Math.max(1, canvas.clientWidth);
  const alturaDaTela: number = Math.max(1, canvas.clientHeight);
  const ampliacao: number = Math.max(
    larguraDaTela / larguraDoVideo,
    alturaDaTela / alturaDoVideo,
  );
  const alturaVisivelEmPixelsDoVideo: number = alturaDaTela / ampliacao;
  camera.fov =
    (2 * Math.atan(alturaVisivelEmPixelsDoVideo / 2 / focalEmPixelsDoVideo) * 180) / Math.PI;
  camera.updateProjectionMatrix();
}

export function montarRegimeMarcado(
  sala: Scene,
  pousa: Object3D,
  camera: PerspectiveCamera,
  canvas: HTMLCanvasElement,
  fundo: CameraDeFundo,
): RegimeMarcado {
  const detector: Detector = montarDetector();
  const fundoOriginal: Color | Texture | null = sala.background;
  const posicaoDeOrigem: Vector3 = pousa.position.clone();
  // O campo de visão da janela é do enquadramento da oficina, e este regime o
  // substitui pelo da câmera física. Guardá-lo aqui é o que permite devolvê-lo
  // ao sair: sem isto, sair do marcador deixa a órbita com o enquadramento da
  // lente do celular, e ninguém liga uma coisa à outra.
  const campoDaJanelaGraus: number = camera.fov;

  let ativo: boolean = false;
  let estadoAtual: EstadoDoMarcado = 'desligado';
  let campoAlinhado: boolean = false;
  let motivoDaFalta: MotivoDaFalta = 'a câmera ainda não entregou imagem';

  let quadrosAnalisados: number = 0;
  let quadrosComMarcador: number = 0;
  let instanteDaUltimaDeteccao: number = Number.NEGATIVE_INFINITY;
  let distanciaM: number = 0;
  let erroDeReprojecaoPx: number = 0;

  // O tremor, medido em vez de escondido: a distância que a bancada anda entre
  // duas detecções seguidas. Numa cena parada, com a câmera apoiada, ele deveria
  // ser zero — e não é.
  const posicaoAnterior: Vector3 = new Vector3();
  let temPosicaoAnterior: boolean = false;
  let tremorMedioM: number = 0;
  let tremorMaximoM: number = 0;

  const matrizDeMundo: Matrix4 = new Matrix4();

  function aplicar(pose: PoseDoMarcador): void {
    // A pose sai no espaço da câmera, e a câmera pode não estar na origem: no
    // regime em janela ela orbita, e o regime marcado é entrado a partir dele. A
    // multiplicação abaixo leva a pose ao espaço do mundo antes da conversão
    // para o espaço da raiz, que é a mesma do módulo anterior.
    camera.updateWorldMatrix(true, false);
    matrizDeMundo.multiplyMatrices(camera.matrixWorld, pose.matriz);

    if (temPosicaoAnterior) {
      const salto: number = posicaoAnterior.distanceTo(
        new Vector3().setFromMatrixPosition(matrizDeMundo),
      );
      // Média corrida com peso pequeno no valor novo: o interesse é no nível do
      // tremor, e não no valor de um quadro. O máximo é guardado à parte, porque
      // é ele que o olho registra.
      tremorMedioM = tremorMedioM * 0.9 + salto * 0.1;
      tremorMaximoM = Math.max(tremorMaximoM, salto);
    }
    posicaoAnterior.setFromMatrixPosition(matrizDeMundo);
    temPosicaoAnterior = true;

    assentarMatrizNoEspacoLocal(matrizDeMundo, sala, pousa);
    distanciaM = pose.distanciaM;
    erroDeReprojecaoPx = pose.erroDeReprojecaoPx;
  }

  return {
    async entrar(): Promise<void> {
      if (ativo) {
        return;
      }
      await fundo.ligar();
      if (fundo.estado() !== 'ligada') {
        // Sem câmera não há regime, e o ambiente não finge que há. Quem informa
        // o motivo é a própria camada de câmera, que sabe distinguir permissão
        // negada de aparelho sem câmera de endereço inseguro.
        return;
      }
      // O fundo apagado é a mesma linha do regime aumentado, e é a única coisa
      // que os dois têm em comum: lá o que aparece atrás é a composição do
      // aparelho, aqui é um elemento de vídeo da própria página.
      sala.background = null;
      ativo = true;
      campoAlinhado = false;
      estadoAtual = 'procurando o marcador';
      quadrosAnalisados = 0;
      quadrosComMarcador = 0;
      tremorMedioM = 0;
      tremorMaximoM = 0;
      temPosicaoAnterior = false;
    },

    sair(): void {
      if (!ativo) {
        return;
      }
      fundo.desligar();
      sala.background = fundoOriginal;
      camera.fov = campoDaJanelaGraus;
      camera.updateProjectionMatrix();
      pousa.position.copy(posicaoDeOrigem);
      pousa.quaternion.identity();
      ativo = false;
      estadoAtual = 'desligado';
      instanteDaUltimaDeteccao = Number.NEGATIVE_INFINITY;
    },

    ligado(): boolean {
      return ativo;
    },

    estado(): EstadoDoMarcado {
      return estadoAtual;
    },

    atualizar(decorridoS: number): void {
      if (!ativo) {
        return;
      }
      const imagem: HTMLVideoElement | undefined = fundo.imagem();
      if (imagem === undefined) {
        return;
      }

      if (!campoAlinhado) {
        alinharCampoDeVisao(
          camera,
          canvas,
          fundo.larguraDaImagem(),
          fundo.alturaDaImagem(),
          CAMPO_HORIZONTAL_ASSUMIDO_GRAUS,
        );
        campoAlinhado = true;
      }

      quadrosAnalisados += 1;
      const deteccao: Deteccao | undefined = detector.detectar(
        imagem,
        fundo.larguraDaImagem(),
        fundo.alturaDaImagem(),
      );

      if (deteccao === undefined) {
        motivoDaFalta = detector.motivo();
        // A decisão sob perda é a mesma do módulo anterior pelo nome e outra
        // pelo conteúdo: a bancada fica onde estava. Lá havia um mapa a que
        // recorrer enquanto a pose não voltava; aqui não há nada — o que se
        // preserva é a última leitura, e ela envelhece de imediato. Fazer a
        // bancada sumir seria mais sincero e é pedagogicamente pior: o papel sai
        // de quadro toda vez que alguém aproxima o aparelho para olhar de perto.
        if (decorridoS - instanteDaUltimaDeteccao > PACIENCIA_SEM_MARCADOR_S) {
          estadoAtual = 'marcador fora de quadro';
        }
        return;
      }

      quadrosComMarcador += 1;
      instanteDaUltimaDeteccao = decorridoS;
      estadoAtual = 'registrado';

      const pose: PoseDoMarcador | undefined = poseDoQuadrilatero(
        deteccao.cantos,
        LADO_IMPRESSO_M,
        deteccao.larguraPx,
        deteccao.alturaPx,
      );
      if (pose !== undefined) {
        aplicar(pose);
      }
    },

    linhas(): string[] {
      const linhas: string[] = [`Registro por marcador: ${estadoAtual}.`];
      if (!ativo) {
        linhas.push(
          'Este regime existe para o aparelho que não abre sessão aumentada. Ele registra a ' +
            'bancada contra um papel impresso, e não contra o ambiente — e a diferença entre os ' +
            'dois é o assunto deste trecho, não um detalhe de implementação.',
        );
        return [...linhas, '', ...linhasDoPadrao(), '', ...linhasDaPose(LARGURA_DE_ANALISE_PX)];
      }

      const proporcao: number =
        quadrosAnalisados === 0 ? 0 : (quadrosComMarcador / quadrosAnalisados) * 100;
      linhas.push(
        `Quadros com o marcador encontrado: ${proporcao.toFixed(0)} por cento de ` +
          `${quadrosAnalisados} analisados.`,
        `Custo da análise no último quadro: ${detector.custoMs().toFixed(1)} ms.`,
      );

      if (estadoAtual === 'registrado') {
        linhas.push(
          `Distância estimada da câmera ao papel: ${distanciaM.toFixed(2)} m.`,
          `Erro de reprojeção: ${erroDeReprojecaoPx.toFixed(1)} pixels.`,
          `Tremor entre quadros: ${(tremorMedioM * 1000).toFixed(1)} mm em média, com pico de ` +
            `${(tremorMaximoM * 1000).toFixed(0)} mm.`,
          'Esse tremor é a diferença entre os dois registros escrita como número. Ele não é ' +
            'filtrado de propósito: um filtro deixaria a demonstração mais bonita e apagaria ' +
            'justamente o que ela veio mostrar.',
        );
      } else {
        linhas.push(
          `Por que não há registro agora: ${motivoDaFalta}.`,
          'A bancada ficou onde a última leitura a deixou. Não há mapa do cômodo a que recorrer ' +
            'neste regime: fora de quadro o papel, acabou a referência.',
        );
      }

      return [...linhas, '', ...linhasDoPadrao(), '', ...linhasDaPose(LARGURA_DE_ANALISE_PX)];
    },
  };
}

Este arquivo carrega a decisão de projeto mais discutível do trecho, e ela está escrita como afirmação: o tremor não é filtrado. Uma média corrida sobre as últimas poses deixaria a bancada visivelmente mais firme, e o custo seria de três linhas.

O que se compraria com essas três linhas é uma demonstração mais bonita que apaga o que ela veio mostrar. O tremor é a evidência mais direta de que os dois registros não são a mesma coisa com aparelhos diferentes. Ele é medido, em milímetros, com média e pico, e o número vai para o painel ao lado da distância estimada e do erro de reprojeção.

O ensaio que fecha a tarefa leva segundos. Apoie o aparelho numa superfície firme, com o papel parado, e leia o tremor. Com tudo imóvel ele deveria ser zero. Não é — e não ser é o resultado.

1.4 Tarefa 2: Escolher o melhor regime disponível e dizer a verdade

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

Enunciado da tarefa

O ambiente passa a decidir sozinho qual regime abrir, a partir do que a sonda encontrou no aparelho. A ordem de preferência é decisão declarada, e ela vale para o parque heterogêneo de aparelhos que abrirá o mesmo endereço.

A tarefa fecha quando nenhum aparelho recebe tela em branco. Ao que não suporta um regime cabe uma mensagem honesta sobre o que falta e sobre o que ele ainda consegue fazer, e a diferença de qualidade entre os dois registros merece ser explicada, jamais disfarçada.

1.4.1 A ordem é decisão, e não cálculo

A função que escolhe o regime tem seis linhas. O arquivo que a contém tem duzentas, e a desproporção é deliberada: o que ela decide é grave, e decisão grave escrita em poucas linhas legíveis é auditável por quem discorda.

src/bancada/app/degradacao.ts
// ---------------------------------------------------------------------------
// A escada rolante parada.
//
// Escada rolante parada ainda é escada; ambiente que só existe com o visor no
// rosto não é nada sem ele. A frase abriu o percurso e cobra aqui a última
// parcela: com três visores para uma turma de quarenta, um ambiente que só
// funcione neles transforma a disciplina numa fila de espera.
//
// Este arquivo faz uma coisa só, e ela é uma DECISÃO DE PROJETO, não um cálculo.
// Dada a lista do que o aparelho respondeu à sonda, ele escolhe qual regime
// abrir, por uma ordem de preferência declarada — com a razão de cada posição
// escrita ao lado. Nenhuma ordem é obviamente correta quando os aparelhos
// diferem em várias dimensões ao mesmo tempo, e é por isso que a razão fica
// gravada: outro projeto escolheria outra, e teria de defendê-la igual.
//
// A segunda metade do arquivo é a que se costuma esquecer. Escolher o melhor
// regime disponível resolve o aparelho capaz; o aparelho que não tem nada é
// quem produz a tela em branco. Para ele, o ambiente deve uma mensagem que diga
// duas coisas em vez de uma: o que falta, e o que ele AINDA CONSEGUE fazer. A
// segunda metade é a que impede a leitura de que o ambiente não funciona ali.
// ---------------------------------------------------------------------------

import type { EstadoDaCamera } from '../anchoring/camera';

/** Os quatro regimes que o ambiente sabe abrir, incluindo o de degradação. */
export type RegimeExecutavel = 'immersive-ar' | 'immersive-vr' | 'marcador' | 'janela';

/** O que o aparelho respondeu, reunido para a decisão. */
export interface CapacidadesDoAparelho {
  readonly modosSuportados: readonly string[];
  readonly temCamera: boolean;
  readonly contextoSeguro: boolean;
}

/** Uma posição da ordem de preferência, com a razão de estar onde está. */
export interface PosicaoDaOrdem {
  readonly regime: RegimeExecutavel;
  readonly nome: string;
  /** Por que este regime vem antes do seguinte. */
  readonly porQueVemAntes: string;
  /** O que falta no aparelho quando ele não alcança este regime. */
  readonly oQueFalta: string;
  /** O que o aparelho ainda consegue fazer sem ele. */
  readonly oQueAindaDa: string;
}

/**
 * A ordem de preferência, do mais capaz ao mais acessível.
 *
 * A primeira posição é do regime aumentado, e não do imersivo, e essa é a
 * escolha discutível da lista. O argumento é o do artefato: a bancada é uma
 * oficina de montagem, e montar sobre a mesa real de quem trabalha vale mais que
 * montar numa sala inventada. Um projeto cujo ambiente fosse um passeio por um
 * lugar impossível ordenaria ao contrário, com razão.
 *
 * A terceira posição é a que existe por causa da turma. O registro por marcador
 * é pior que os dois de cima em toda dimensão mensurável, e ainda assim vem
 * antes da janela: ele é realidade aumentada de verdade — o objeto se localiza
 * em relação ao mundo captado pela câmera — e alcança o celular de quase todo
 * mundo.
 *
 * A última posição não é sobra. O regime em janela monta a bancada inteira, e é
 * onde a disciplina começou; ele é o destino da degradação, e destino pela
 * metade vira tela preta.
 */
export const ORDEM_DE_PREFERENCIA: readonly PosicaoDaOrdem[] = [
  {
    regime: 'immersive-ar',
    nome: 'realidade aumentada com sessão do aparelho',
    porQueVemAntes:
      'é o único regime em que a bancada é montada sobre a mesa real de quem trabalha, com o ' +
      'registro corrigido pelo mapa que o aparelho mantém do cômodo',
    oQueFalta: 'sessão aumentada, que este navegador não oferece neste aparelho',
    oQueAindaDa:
      'montar a bancada inteira, com as mesmas peças e os mesmos encaixes, nos regimes abaixo',
  },
  {
    regime: 'immersive-vr',
    nome: 'realidade virtual imersiva',
    porQueVemAntes:
      'traz escala corporal e alcance de braço, que são as duas coisas que nenhum dos regimes ' +
      'seguintes tem como oferecer',
    oQueFalta: 'sessão imersiva, que exige um visor com rastreamento de cabeça',
    oQueAindaDa:
      'apanhar, girar e encaixar cada peça, com a mesma lógica que o controle do visor comanda',
  },
  {
    regime: 'marcador',
    nome: 'realidade aumentada por marcador impresso',
    porQueVemAntes:
      'ainda é registro contra o mundo captado pela câmera, e não desenho sobre vídeo — e é o ' +
      'que alcança o celular de quase toda a turma',
    oQueFalta: 'câmera acessível a esta página, ou permissão para usá-la',
    oQueAindaDa: 'a oficina completa em janela, com a câmera girando em torno da bancada',
  },
  {
    regime: 'janela',
    nome: 'realidade virtual não imersiva',
    porQueVemAntes:
      'é o caso base e o destino da degradação: roda em qualquer máquina, e monta a bancada ' +
      'inteira do primeiro parafuso ao último',
    oQueFalta: 'nada — este regime é o piso, e ele não falta em aparelho nenhum',
    oQueAindaDa: 'tudo o que o artefato faz, exceto o que depende de corpo e de mundo real',
  },
];

/** O que a decisão produziu, com a justificativa que vai para a tela. */
export interface Escolha {
  readonly regime: RegimeExecutavel;
  readonly nome: string;
  /** Os regimes preteridos antes deste, cada um com o que falta e o que resta. */
  readonly preteridos: readonly PosicaoDaOrdem[];
}

/** Diz se o aparelho alcança um regime, com o que a sonda e a câmera responderam. */
export function alcanca(regime: RegimeExecutavel, capacidades: CapacidadesDoAparelho): boolean {
  switch (regime) {
    case 'immersive-ar':
      return capacidades.modosSuportados.includes('immersive-ar');
    case 'immersive-vr':
      return capacidades.modosSuportados.includes('immersive-vr');
    case 'marcador':
      // O contexto seguro entra aqui porque a interface de mídia não é exposta
      // sem ele, e a ausência se parece exatamente com a de um aparelho sem
      // câmera. Sem esta linha, o laboratório inteiro conclui que os celulares
      // da turma não têm câmera.
      return capacidades.temCamera && capacidades.contextoSeguro;
    case 'janela':
      return true;
  }
}

/**
 * Escolhe o melhor regime disponível.
 *
 * A função é curta de propósito. O que ela decide é grave — é o que a turma
 * inteira vê ao abrir o endereço —, e uma decisão grave escrita em três linhas
 * legíveis é auditável por quem discorda. A parte longa deste arquivo é a
 * justificativa, e é assim que tem de ser.
 */
export function escolherRegime(capacidades: CapacidadesDoAparelho): Escolha {
  const preteridos: PosicaoDaOrdem[] = [];
  for (const posicao of ORDEM_DE_PREFERENCIA) {
    if (alcanca(posicao.regime, capacidades)) {
      return { regime: posicao.regime, nome: posicao.nome, preteridos };
    }
    preteridos.push(posicao);
  }
  // Inalcançável: a janela alcança sempre, e é o compilador que garante a
  // cobertura dos quatro casos acima. O retorno existe porque a lista poderia
  // ser editada, e um ambiente sem regime nenhum tem de dizer isso em vez de
  // devolver ausência para quem espera um regime.
  return { regime: 'janela', nome: 'realidade virtual não imersiva', preteridos };
}

/**
 * A mensagem honesta, que é a metade que se esquece.
 *
 * Duas regras a governam, e as duas nasceram de erro observado. A primeira é
 * nomear a capacidade que falta, e não o aparelho: "este aparelho não suporta"
 * ensina que o aparelho é ruim, quando muitas vezes o que falta é o endereço
 * seguro. A segunda é dizer o que ainda dá — sem isso, quem lê conclui que não
 * vai conseguir fazer a atividade, e fecha a página.
 */
export function mensagemHonesta(escolha: Escolha, camera: EstadoDaCamera): string[] {
  const linhas: string[] = [
    `Regime aberto neste aparelho: ${escolha.nome}.`,
  ];

  if (escolha.preteridos.length === 0) {
    linhas.push(
      'Este aparelho alcança o regime mais completo do ambiente, e nada foi degradado aqui.',
    );
    return linhas;
  }

  linhas.push('');
  for (const preterido of escolha.preteridos) {
    linhas.push(
      `Fora de alcance: ${preterido.nome}. O que falta é ${preterido.oQueFalta}. O que este ` +
        `aparelho ainda faz: ${preterido.oQueAindaDa}.`,
    );
  }

  if (camera !== 'ligada' && camera !== 'desligada') {
    linhas.push('', `Sobre a câmera: ${camera}.`);
  }

  linhas.push(
    '',
    'A diferença entre os regimes não é de acabamento, e não está sendo disfarçada. O registro ' +
      'contra a superfície detectada é corrigido pelo mapa que o aparelho mantém do cômodo; o ' +
      'registro por marcador reconhece um gabarito impresso e recomeça do zero a cada quadro. ' +
      'São dois resultados diferentes, e quem estiver comparando dois aparelhos lado a lado ' +
      'merece saber por quê.',
  );
  return linhas;
}

/** A ordem escrita por extenso, com a razão de cada posição. É a decisão a defender. */
export function linhasDaOrdem(): string[] {
  const linhas: string[] = [
    'Ordem de preferência declarada, do mais capaz ao mais acessível. Nenhuma ordem é ' +
      'obviamente correta quando os aparelhos diferem em várias dimensões ao mesmo tempo, e ' +
      'por isso cada posição carrega a razão de estar onde está.',
    '',
  ];
  let posicao: number = 1;
  for (const item of ORDEM_DE_PREFERENCIA) {
    linhas.push(`${posicao}. ${item.nome} — ${item.porQueVemAntes}.`);
    posicao += 1;
  }
  linhas.push(
    '',
    'A primeira posição é a discutível: ela vem do artefato. A bancada é uma oficina de ' +
      'montagem, e montar sobre a mesa real vale mais que montar numa sala inventada. Um ' +
      'ambiente que fosse um passeio por um lugar impossível ordenaria ao contrário, com razão.',
  );
  return linhas;
}

A ordem adotada põe a realidade aumentada com sessão do aparelho na frente, depois a imersiva, depois o registro por marcador, e a janela por último. A primeira posição é a discutível, e o argumento vem do artefato: a bancada é uma oficina de montagem, e montar sobre a mesa real de quem trabalha vale mais que montar numa sala inventada. Um ambiente que fosse um passeio por um lugar impossível ordenaria ao contrário, com razão.

A terceira posição existe por causa da turma. O registro por marcador é pior que os dois de cima em toda dimensão mensurável — treme, some, estima a distância. Ainda assim vem antes da janela, porque é realidade aumentada de verdade e alcança o celular de quase todo mundo.

E a última posição não é sobra. O regime em janela monta a bancada inteira, do primeiro parafuso ao último, e é onde a disciplina começou. Ele é o destino da degradação, e destino pela metade vira tela preta.

1.4.2 A metade que se esquece

Escolher o melhor regime disponível resolve o aparelho capaz. Quem produz a tela em branco é o aparelho que não tem nada, e para ele a resposta certa não é um botão desabilitado.

Botão morto ensina que o ambiente não funciona ali. É a leitura errada, e ela é cara: o estudante fecha a página concluindo que precisa de outro aparelho para fazer a atividade — quando o regime em janela, que a máquina dele roda folgada, monta a bancada inteira.

Duas regras governam a mensagem, e as duas nasceram de erro observado em sala. A primeira é nomear a capacidade que falta, e nunca o aparelho. Dizer que este aparelho não suporta ensina que o aparelho é ruim, e muitas vezes o que falta é o endereço seguro — o mesmo engano que já apareceu no módulo do ciclo de sessão e que volta aqui idêntico, agora com a câmera no lugar do visor.

A segunda regra é dizer o que ainda dá. Cada posição da ordem carrega, escrito ao lado, o que o aparelho continua conseguindo fazer sem ela. É a linha que separa uma limitação informada de uma porta fechada.

E há a terceira coisa que a mensagem diz, que a tarefa cobra por escrito: a diferença de qualidade entre os dois registros é explicada, e não disfarçada. Quem estiver comparando dois aparelhos lado a lado vai ver que um segura a bancada e o outro a faz tremer. Merece saber por quê, e a resposta é a mesma que abriu este trecho.

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

O recibo do módulo continua sendo a lista de arquivos que não mudaram.

Quantos arquivos mudaram fora das pastas novas? Três, e nenhum deles é de interação. A composição monta a câmera de fundo e o regime marcado, guarda a decisão de regime e acrescenta uma chamada no laço. O palco passou a admitir transparência, numa linha. A página ganhou os três botões e as duas folhas de leitura.

A chamada nova no laço fica ao lado da ancoragem, e não depois dela. As duas fazem a mesma coisa por caminhos opostos: movem a bancada. Pela mesma razão do trecho anterior, elas vêm antes de tudo o que lê posição — a mira, o agarre e o encaixe medem distâncias contra peças que pendem da bancada.

Os dois registros nunca estão ligados ao mesmo tempo, e isso não é coincidência feliz. Um exige sessão aumentada; o outro exige que não haja sessão alguma. Entrar por marcador com uma sessão viva poria dois registros disputando a mesma bancada, e o resultado seria ela oscilando entre dois lugares sem que nada acusasse motivo. A composição encerra a sessão antes, e a linha que faz isso tem o comentário que explica por quê.

Uma regra de cortesia mora ali e em nenhum outro lugar. Ao encerrar o ambiente, a câmera é apagada explicitamente. Soltar a referência e confiar no recolhimento automático da página deixa a luz indicadora do aparelho acesa depois de tudo parar, e quem usa lê isso como o programa gravando pelas costas dele.

E a decisão de regime não abre nada sozinha. Ela é tomada ao carregar e fica guardada para a folha exibir. Abrir sessão imersiva exige gesto de quem usa, por regra do navegador; ligar a câmera sem que ninguém tenha pedido seria pior que isso. O que a decisão faz é dizer qual botão apertar, e por quê.

1.6 O que este módulo deixa pronto

Quarenta aparelhos, um endereço, e nenhuma tela em branco.

A turma inteira passa a executar realidade aumentada. Os visores em sessão sobre a mesa real, os celulares sobre o papel impresso, os computadores de mesa com a oficina em janela — e cada aparelho dizendo, na própria tela, qual regime abriu e o que faltou para abrir o de cima.

Como se sabe que a degradação não é fachada? O ensaio é o mesmo que o trecho anterior propôs, feito agora em dois aparelhos ao mesmo tempo. Pouse a bancada na mesa pelo visor, registre-a pelo papel no celular, e ande em volta com os dois. Um segura; o outro treme e perde o papel de vista. A diferença está na tela e está medida em milímetros na folha.

Fica pendente a integração final, que é o assunto do próximo trecho. O que existe hoje são quatro regimes que funcionam e uma decisão que escolhe entre eles — falta o percurso completo, do primeiro parafuso ao mecanismo montado animando, atravessado sem que ninguém precise saber em que regime está.