1 A camada de interação: ponteiro e seleção — Projeto do Professor

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

1.1 Visão geral

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

Até aqui a Bancada era um ambiente que se olhava. A cena estava montada, o conteúdo estava medido, a câmera girava em volta, e nenhum objeto respondia a nada. Este módulo é onde isso muda, e ele muda menos do que parece na tela e mais do que parece no arquivo.

O que a tela ganha é modesto: a peça sob a mira acende, e um clique escolhe uma delas. O que o projeto ganha é a tomada. A partir daqui existe uma abstração de apontar, e a lógica que decide o que foi alcançado consome só ela.

Entram três arquivos novos e um quarto que não desenha nada. Dois em uma pasta de entrada — a abstração e o primeiro plugue —, dois em uma pasta de interação, que é onde a seleção e o realce moram. O quarto registra a fronteira das dependências como dado, e a razão de ele existir é o assunto da próxima seção.

Nenhum arquivo anterior é reescrito. A composição ganha três linhas, e o laço do quadro ganha uma chamada. É pouco código, e é o ponto mais caro do período.

1.2 A pergunta que decide a arquitetura

Uma dependência resolveria este módulo em quinze minutos, e o preço venceria três módulos adiante.

Há, nesta plataforma, bibliotecas que entregam apontamento e agarre prontos. Elas funcionam, são mantidas por gente competente e tratam casos de borda que nós não vamos tratar. Por que não usar uma?

Porque o que elas entregam pronto é exatamente o trecho em que os três regimes se encontram. Instalar a dependência resolve a seleção de hoje e apaga a tomada: o projeto passa a ter o formato do exemplo dela, e o encontro entre cursor, controle e dedo acontece dentro de código que ninguém do grupo escreveu nem leu.

O critério que este projeto adota cabe em uma frase, e ele vale para o percurso inteiro. O que desenha triângulos e lê arquivos vem de biblioteca; o que decide o que a pessoa está fazendo é escrito no projeto.

src/bancada/input/fronteira.ts
// ---------------------------------------------------------------------------
// A fronteira das dependências: o que vem pronto e o que se escreve aqui.
//
// O critério cabe em uma frase e vale para o percurso inteiro: o que desenha
// triângulos e lê arquivos vem de biblioteca; o que decide o que a pessoa está
// fazendo é escrito no projeto. Ele não é preferência por escrever tudo — a
// simplificação de malha do módulo de ativos foi entregue à ferramenta, e por
// razão declarada.
//
// A decisão está aqui como dado, e não como comentário solto, porque ela é a que
// mais se perde. Uma dependência que entrega apontamento e agarre prontos resolve
// este módulo em quinze minutos e apaga exatamente o trecho em que os três
// regimes se encontram — e a perda não aparece hoje: aparece no módulo do ciclo
// de sessão, quando o controle rastreado chega e não há tomada nenhuma onde
// ligá-lo.
// ---------------------------------------------------------------------------

/** De onde a peça vem: pronta de uma biblioteca, ou escrita dentro do projeto. */
export type Origem = 'biblioteca' | 'projeto';

export interface ItemDaFronteira {
  readonly assunto: string;
  readonly origem: Origem;
  readonly razao: string;
  /** O que se perde ao decidir o contrário. Sem esta coluna, a regra vira gosto. */
  readonly custoDeErrar: string;
}

export const FRONTEIRA: readonly ItemDaFronteira[] = [
  {
    assunto: 'desenhar triângulos, compilar material, montar o grafo de cena',
    origem: 'biblioteca',
    razao: 'é infraestrutura de renderização, não conteúdo da ementa',
    custoDeErrar: 'o período inteiro gasto reescrevendo o que já funciona em toda máquina',
  },
  {
    assunto: 'ler o arquivo de troca e reconstruir a malha que veio nele',
    origem: 'biblioteca',
    razao: 'o formato é uma especificação pública e extensa, e interpretá-la não é habilidade cobrada',
    custoDeErrar: 'um leitor incompleto que aceita os arquivos de teste e recusa os de verdade',
  },
  // recorte:inicio fronteira-o-criterio
  {
    assunto: 'a interseção do raio com a malha',
    origem: 'biblioteca',
    razao: 'é geometria computacional resolvida, e a estrutura de aceleração vem junto',
    custoDeErrar: 'interseção correta e lenta, que só cobra no visor e sob carga',
  },
  {
    assunto: 'a abstração de apontar e as implementações que a alimentam',
    origem: 'projeto',
    razao: 'é o encontro dos três regimes, e é a economia que o percurso inteiro constrói',
    custoDeErrar: 'três programas paralelos disfarçados de um só, descobertos tarde demais',
  },
  {
    assunto: 'decidir o que foi alcançado, o que foi apontado e o que foi selecionado',
    origem: 'projeto',
    razao: 'é a lógica da tarefa da oficina, e nenhuma biblioteca conhece a tarefa',
    custoDeErrar: 'a interação passa a ter o formato do exemplo da dependência, e não o da montagem',
  },
  // recorte:fim fronteira-o-criterio
  {
    assunto: 'a tolerância do encaixe, a ordem dos passos e a recusa explicada',
    origem: 'projeto',
    razao:
      'os dois números e a precedência entre as peças são a tarefa da oficina, e a ' +
      'frase que a recusa devolve é conteúdo, não mensagem de erro',
    custoDeErrar:
      'a folga vira o padrão de quem escreveu a dependência, e a recusa sai muda — ' +
      'que é o que quem monta lê como travamento',
  },
  {
    assunto: 'a câmera em órbita do regime em janela',
    origem: 'projeto',
    razao: 'a conta é de três linhas e está na ementa; a decisão está declarada no arquivo dela',
    custoDeErrar: 'o comportamento entregue sem a conta entregue junto',
  },
];

/** Uma linha por item, com a origem e o que a escolha contrária custaria. */
export function linhasDaFronteira(): string[] {
  return FRONTEIRA.map(
    (item) =>
      `${item.assunto} — ${item.origem}: ${item.razao}. ` +
      `Ao contrário: ${item.custoDeErrar}.`,
  );
}

1.2.1 Não é preferência por escrever tudo

Repare na terceira linha da tabela: a interseção do raio com a malha vem de biblioteca, e sem hesitação. É geometria computacional resolvida, e a estrutura de aceleração que a acompanha é o que mantém a conta barata quando a cena cresce.

O contraste com o módulo de ativos é o mesmo. Lá a simplificação de malha foi entregue à ferramenta, de propósito, porque reimplementar colapso de arestas consumiria o período e não está na ementa. O critério é o que a ementa cobra e o que o percurso está construindo.

Já a linha seguinte — decidir o que foi alcançado, apontado e escolhido — é lógica da tarefa da oficina. Biblioteca nenhuma conhece a tarefa, e a que fingir conhecer vai impor a dela.

1.2.2 Por que a decisão sai na página

A fronteira está escrita como dado tipado e é exibida no ambiente, ao lado da lista de limites do regime em janela. É a mesma decisão daquele módulo, tomada pelo mesmo motivo.

Decisão de projeto guardada em documento à parte envelhece bem demais: continua parecendo verdadeira anos depois de ter deixado de ser. E não alcança quem está avaliando o resultado, que é justamente quem precisa saber o que veio pronto.

Na tutoria, a coluna que rende discussão é a última — o que se perde ao decidir o contrário. Um grupo que não consegue preencher essa coluna para um item ainda não decidiu nada sobre ele: herdou a decisão do exemplo mais próximo.

1.3 Tarefa 1: Escrever a abstração de apontar

Quatro campos, e a decisão inteira do módulo está em qual deles é opcional.

Enunciado da tarefa

Este é o ponto de virada do projeto, e ele cabe numa frase: nenhuma linha da lógica de manipulação pode saber se quem aponta é um cursor, um controle rastreado ou um dedo na tela. A abstração carrega origem, direção, acionamento e estado, mais a pose quando o aparelho a fornece.

Ela precisa ser escrita dentro do projeto. Uma dependência que já entrega agarre e apontamento prontos apaga exatamente o trecho em que os três regimes se encontram, e o ambiente passa a ter três caminhos paralelos disfarçados de um só.

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

import { Quaternion, Vector3 } from 'three';

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

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

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

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

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

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

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

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

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

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

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

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

1.3.1 Onde a fronteira foi traçada, e por quê

A abstração carrega o que os três regimes têm a dizer em comum: um raio no espaço do mundo, um acionamento, um agarre e um estado. Origem e direção são o par obrigatório — sem eles não há apontamento nenhum.

A pose de seis graus de liberdade fica de fora dessa lista comum e entra como campo opcional. Ela existe no controle rastreado e não existe no cursor, e essa assimetria é fato do aparelho, não descuido de desenho.

A tentação é preencher a pose para os três, com um valor plausível derivado da câmera. O código compilaria e a mentira ficaria escondida até o módulo em que a mão fecha sobre a peça a quarenta centímetros do rosto. Ali a peça iria para o lugar errado, e a causa estaria três módulos atrás.

1.3.2 O acionamento, e o quase-escorregão

O gatilho do controle rastreado é estado mantido: fica verdadeiro enquanto o dedo aperta. O clique do cursor não tem essa propriedade, e a razão é a órbita.

No instante em que o botão desce, ninguém sabe se aquilo vai virar clique ou arrasto de câmera. Só a soltura responde. O primeiro desenho tentado aqui foi acrescentar um campo de cancelamento à abstração — e ele durou uma tarde, porque um campo que só o cursor preenche é a fronteira vazando para dentro da tomada.

A solução que ficou não mexe no tipo. O campo continua sendo um booleano de estado; o cursor o entrega como pulso de uma leitura, e quem consome lê a borda de subida. As duas formas cabem no mesmo campo, e a camada de interação não sabe da diferença.

1.3.3 A tabela de campos, e o que ela vai medir depois

Junto do tipo há uma lista tipada com o que cada campo carrega, quem o preenche e o que a ausência dele significa. É a mesma decisão de estrutura que a delimitação do domínio tomou no primeiro módulo, e que os limites do regime em janela repetiram no anterior.

O que precisa ser confrontado depois não sobrevive como parágrafo. Quando o controle rastreado chegar, é esta tabela que diz o que passou a estar preenchido e o que continua vazio, e a mudança aparece na página de quem abrir o endereço.

A quarta linha é a que mais vale ler em voz alta na tutoria. Sem pose, não há mão a que prender a peça, e o regime move por raio e plano. É o prazo de validade da tomada, escrito antes de alguém tropeçar nele.

1.3.4 Onde é fácil errar, e como conferir

O defeito mais comum não produz erro nenhum: os vetores devolvidos como referência ao estado interno da fonte.

Economiza alocação e cria o problema mais difícil de enxergar desta camada. A leitura de um quadro muda sozinha durante o quadro seguinte, e o sintoma é a seleção que às vezes pega a peça errada. Devolver cópias resolve, e o custo é irrelevante diante de tudo o mais que o quadro faz.

A conferência da tarefa é de leitura, e ela é impiedosa. Peça a alguém do grupo que descreva a lógica de seleção sem usar as palavras mouse, cursor, clique ou pixel. Quem não consegue ainda não abstraiu, e nenhum teste automático vai dizer isso.

1.4 Tarefa 2: Ligar o primeiro back-end e a seleção por raio

O teste de que a abstração funciona é de leitura: procure a palavra “cursor” no arquivo da seleção.

Enunciado da tarefa

O cursor é a primeira implementação a alimentar a abstração, porque roda em qualquer máquina e pode ser conferida a olho. A seleção lança um raio contra a cena e decide o que foi alcançado, consumindo apenas a abstração e nunca o evento bruto do aparelho.

O que fica pronto é a seleção funcionando com realce visível do que está prestes a ser apanhado. Sem esse retorno ninguém sabe o que vai pegar, e o defeito só aparece quando a mira deixa de ser o centro exato da tela.

src/bancada/input/cursor.ts
// ---------------------------------------------------------------------------
// O primeiro plugue: o cursor.
//
// Ele vem primeiro porque roda em qualquer máquina da turma e porque o resultado
// dele se confere a olho, sem rodízio de equipamento. O controle rastreado e o
// toque entram depois, e nenhum dos dois vai tocar na camada de interação — se
// tocar, a abstração falhou e o lugar de descobrir isso é aqui, não lá.
//
// Duas contas e um cuidado, e o cuidado é o que ninguém prevê.
//
// A primeira conta é a passagem do pixel para o raio. O navegador entrega a
// posição em pixels dentro do elemento; a placa trabalha em coordenadas
// normalizadas, de menos um a um, com o eixo vertical apontando para cima. A
// inversão do eixo vertical é o erro clássico: a mira funciona no meio da tela e
// erra cada vez mais para as bordas, o que se lê como imprecisão do apontamento e
// não como sinal trocado.
//
// A segunda é a projeção inversa. Um ponto na profundidade intermediária do
// volume de visão, levado de volta ao espaço do mundo, dá o alvo; a direção é
// dele até a posição da câmera. Não se usa a direção de vista da câmera, que é a
// mesma para todos os pixels — usá-la faria a mira ser sempre o centro da tela, e
// o defeito só apareceria quando alguém clicasse fora do centro.
//
// O cuidado é a convivência com a órbita. Os dois ouvem o mesmo elemento e o
// mesmo botão: um gesto que gira a câmera não pode selecionar peça nenhuma ao
// terminar. A regra é o deslocamento acumulado — abaixo do limiar o gesto era
// clique, acima era arrasto —, e é por isso que o acionamento do cursor só se
// conhece na soltura.
// ---------------------------------------------------------------------------

import { PerspectiveCamera, Vector3 } from 'three';

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

/**
 * Deslocamento, em pixels, a partir do qual o gesto deixa de ser clique.
 *
 * Doze pixels é folgado de propósito: no celular o dedo escorrega alguns pixels
 * em todo toque, e um limiar apertado transforma seleção em loteria. No mouse
 * ninguém alcança doze pixels sem querer arrastar.
 */
const LIMIAR_DE_ARRASTO_PX: number = 12;

/** Profundidade normalizada do ponto projetado de volta ao mundo. */
const PROFUNDIDADE_DE_PROJECAO: number = 0.5;

/**
 * Instala a fonte de cursor sobre um elemento e uma câmera.
 *
 * A fonte não conhece a cena, não conhece peça alguma e não decide nada: ela
 * traduz evento de navegador em raio de mundo. Todo o resto é da camada de
 * interação, e essa separação é o assunto do módulo.
 */
export function montarFonteDeCursor(
  camera: PerspectiveCamera,
  elemento: HTMLElement,
): FonteDePonteiro {
  let dentro: boolean = false;
  let ndcX: number = 0;
  let ndcY: number = 0;

  let botaoDescido: boolean = false;
  let virouArrasto: boolean = false;
  let xDaDescida: number = 0;
  let yDaDescida: number = 0;

  // O pulso do clique. Ele nasce na soltura e é consumido pela primeira leitura
  // seguinte: quem consome vê a borda de subida do acionamento, exatamente como
  // veria no gatilho de um controle.
  let cliquePendente: boolean = false;

  // A pega do regime em janela, e o lugar em que ela precisa morar.
  //
  // O controle rastreado mantém o gatilho apertado enquanto a peça está na mão; o
  // cursor não consegue, porque o botão está em disputa com a órbita e só a
  // soltura diz se o gesto era clique ou arrasto. A saída é o clique alternar: o
  // primeiro apanha, o segundo larga.
  //
  // A alternância fica AQUI, e não na camada de manipulação, e essa é a decisão
  // que o módulo compra. É o cursor que sabe que o aparelho dele não sabe dizer
  // "mantido"; escrever o caso especial lá em cima faria a lógica de montagem
  // ganhar um ramo por aparelho, que é exatamente o que a abstração existe para
  // impedir.
  let agarreMantido: boolean = false;

  const alvo: Vector3 = new Vector3();

  // recorte:inicio cursor-pixel-para-ndc
  function atualizarNdc(evento: PointerEvent): void {
    const area: DOMRect = elemento.getBoundingClientRect();
    ndcX = ((evento.clientX - area.left) / area.width) * 2 - 1;
    // O eixo vertical do navegador cresce para baixo e o da placa cresce para
    // cima. O sinal trocado aqui é o defeito que se disfarça de imprecisão.
    ndcY = -(((evento.clientY - area.top) / area.height) * 2 - 1);
    dentro = ndcX >= -1 && ndcX <= 1 && ndcY >= -1 && ndcY <= 1;
  }
  // recorte:fim cursor-pixel-para-ndc

  function aoEntrar(evento: PointerEvent): void {
    atualizarNdc(evento);
  }

  function aoSair(): void {
    dentro = false;
  }

  function aoDescer(evento: PointerEvent): void {
    atualizarNdc(evento);
    botaoDescido = true;
    virouArrasto = false;
    xDaDescida = evento.clientX;
    yDaDescida = evento.clientY;
  }

  // recorte:inicio cursor-limiar-de-arrasto
  function aoMover(evento: PointerEvent): void {
    atualizarNdc(evento);
    if (!botaoDescido || virouArrasto) {
      return;
    }
    const percorrido: number = Math.hypot(evento.clientX - xDaDescida, evento.clientY - yDaDescida);
    if (percorrido > LIMIAR_DE_ARRASTO_PX) {
      // A partir daqui o gesto pertence à órbita. A soltura não vai produzir
      // clique, e é isso que impede girar a câmera de selecionar a peça que por
      // acaso estava sob o cursor quando o botão foi solto.
      virouArrasto = true;
    }
  }
  // recorte:fim cursor-limiar-de-arrasto

  function aoSubir(evento: PointerEvent): void {
    atualizarNdc(evento);
    if (botaoDescido && !virouArrasto) {
      cliquePendente = true;
      agarreMantido = !agarreMantido;
    }
    botaoDescido = false;
    virouArrasto = false;
  }

  elemento.addEventListener('pointerenter', aoEntrar);
  elemento.addEventListener('pointerleave', aoSair);
  elemento.addEventListener('pointerdown', aoDescer);
  elemento.addEventListener('pointermove', aoMover);
  elemento.addEventListener('pointerup', aoSubir);
  elemento.addEventListener('pointercancel', aoSubir);

  return {
    nome: 'cursor do computador de mesa',
    regime: 'janela',
    ler(): readonly Ponteiro[] {
      if (!dentro) {
        // Sem cursor sobre a superfície não há apontamento, e devolver a última
        // posição conhecida deixaria o realce aceso depois que ninguém está mais
        // olhando para a peça.
        cliquePendente = false;
        return [];
      }

      // recorte:inicio cursor-projecao-inversa
      // A projeção inversa: o ponto normalizado, na profundidade intermediária,
      // levado de volta ao espaço do mundo. É este ponto que faz a direção variar
      // com o pixel, em vez de ser sempre a direção de vista da câmera.
      alvo.set(ndcX, ndcY, PROFUNDIDADE_DE_PROJECAO).unproject(camera);
      // recorte:fim cursor-projecao-inversa

      const acionado: boolean = cliquePendente;
      cliquePendente = false;

      return [
        {
          id: 'cursor',
          regime: 'janela',
          origem: camera.position.clone(),
          direcao: alvo.clone().sub(camera.position).normalize(),
          acionado,
          // O cursor não tem pega separada do clique, e por isso a deriva do
          // próprio acionamento: o clique alterna a pega, que fica mantida até o
          // clique seguinte. Quem consome lê um booleano de estado, igual ao que
          // o gatilho do controle vai entregar, e não sabe da diferença.
          agarre: agarreMantido,
          estado: acionado ? 'acionado' : 'apontando',
          // Sem pose. O mouse não tem posição no mundo nem orientação de mão, e
          // inventar uma aqui seria a mentira que só apareceria no visor.
          pose: undefined,
        },
      ];
    },
    encerrar(): void {
      elemento.removeEventListener('pointerenter', aoEntrar);
      elemento.removeEventListener('pointerleave', aoSair);
      elemento.removeEventListener('pointerdown', aoDescer);
      elemento.removeEventListener('pointermove', aoMover);
      elemento.removeEventListener('pointerup', aoSubir);
      elemento.removeEventListener('pointercancel', aoSubir);
      dentro = false;
      cliquePendente = false;
      agarreMantido = false;
    },
  };
}

1.4.1 Do pixel ao raio, em duas contas

O navegador entrega a posição em pixels dentro do elemento. A placa trabalha em coordenadas normalizadas, de menos um a um, com o eixo vertical apontando para cima. A primeira conta é essa conversão, e ela tem uma armadilha de sinal.

O eixo vertical do navegador cresce para baixo. Esquecer a inversão produz uma mira que acerta no meio da tela e erra cada vez mais em direção às bordas, o que qualquer pessoa lê como imprecisão do apontamento. É o defeito que mais consome tempo de tutoria aqui, e ele é um sinal.

A segunda conta é a projeção inversa. Um ponto na profundidade intermediária do volume de visão, levado de volta ao espaço do mundo, dá o alvo; a direção vai da posição da câmera até ele.

Não se usa a direção de vista da câmera, e isso merece dito em voz alta. Ela é a mesma para todos os pixels, então a mira seria sempre o centro da tela. O ambiente funcionaria bem enquanto o objeto estivesse no meio, que é justamente onde ele está durante o teste.

1.4.2 O gesto disputado com a órbita

A órbita e o cursor ouvem o mesmo elemento e o mesmo botão. Girar a câmera não pode selecionar a peça que por acaso estava sob o cursor quando o botão foi solto.

A regra é o deslocamento acumulado desde a descida do botão. Abaixo do limiar, o gesto era clique; acima, era arrasto, e a soltura não produz acionamento nenhum. Doze pixels é folgado de propósito: no celular o dedo escorrega alguns pixels em todo toque, e limiar apertado transforma seleção em loteria.

Esta é a decisão que os grupos mais tentam resolver do jeito difícil, com bandeiras trocadas entre os dois módulos. Não é preciso: a órbita continua sem saber que a seleção existe, e a seleção continua sem saber da órbita.

1.4.3 A subida na árvore

src/bancada/interaction/selecao.ts
// ---------------------------------------------------------------------------
// A seleção por lançamento de raio.
//
// Este arquivo é o teste da abstração, e o teste é de leitura: procure aqui as
// palavras mouse, cursor, clique, tela ou pixel. Elas não existem. O que entra é
// uma lista de ponteiros, e o que sai é qual peça está sob a mira e qual foi
// escolhida. Quando o controle rastreado chegar, nada aqui muda — e é essa frase
// que o percurso inteiro está construindo desde o primeiro módulo.
//
// O problema técnico do arquivo não é lançar o raio: isso a biblioteca faz, e faz
// melhor. É decidir o que a interseção significa.
//
// O raio acerta um triângulo, e o triângulo pertence a um nó da árvore. Esse nó
// quase nunca é a peça: a bancada é uma hierarquia, e o que está sob a mira pode
// ser a cabeça de um parafuso instanciado, o contorno de um volume de contato ou
// o tampo. A conversão de "o que o raio acertou" para "a peça de que isso faz
// parte" é uma subida na árvore até encontrar um nó registrado como alvo, e é o
// conteúdo teórico do módulo.
//
// A subida também é o que resolve a oclusão sem uma linha dedicada. Lançar o raio
// contra a cena inteira, e não só contra as peças, faz o tampo interceptar o que
// está atrás dele: a interseção mais próxima é o tampo, o tampo não é peça, a
// subida não encontra alvo e a mira não alcança nada. Lançar só contra as peças
// atravessaria a madeira e apanharia a engrenagem do outro lado.
// ---------------------------------------------------------------------------

import { Object3D, Raycaster, Vector3, type Intersection } from 'three';

import type { PecaId } from '../dominio/dominio';
import type { Ponteiro } from '../input/ponteiro';
import type { Destaque } from './destaque';

/**
 * Em que ponto do ciclo uma peça está.
 *
 * São só três, e continuam sendo três depois de o agarre chegar. Agarrada,
 * encaixando e encaixada pertencem à camada de montagem, que é quem tem o que
 * dizer sobre elas; a seleção sabe apenas o que a mira alcança e o que alguém
 * escolheu. Duplicar a máquina inteira aqui criaria dois lugares onde o mesmo
 * estado pode divergir, e o que diverge em silêncio é sempre o que ninguém lê.
 */
export type EstadoDaPeca = 'livre' | 'apontada' | 'selecionada';

/** Alcance do raio, em metros. A bancada inteira cabe com folga. */
const ALCANCE_M: number = 12;

export interface MudancaDeSelecao {
  readonly apontada: PecaId | undefined;
  readonly selecionada: PecaId | undefined;
  /** Quantos nós a subida percorreu até chegar à peça. Zero quando o raio já a acertou. */
  readonly degrausDeSubida: number;
  /** O nome do nó que o raio de fato acertou. É o que a tutoria pede para ver. */
  readonly noAtingido: string | undefined;
}

export interface Selecao {
  /** Lê os ponteiros do quadro e atualiza mira e escolha. Chamada uma vez por passo. */
  atualizar(ponteiros: readonly Ponteiro[]): void;
  apontada(): PecaId | undefined;
  selecionada(): PecaId | undefined;
  estadoDe(id: PecaId): EstadoDaPeca;
  aoMudar(ouvinte: (mudanca: MudancaDeSelecao) => void): void;
  linhas(): string[];
}

/**
 * Monta a seleção sobre a raiz da cena e um conjunto de alvos.
 *
 * Os alvos são a tradução entre a árvore e o domínio: nó da cena de um lado,
 * identificador de peça do outro. Nada fora dessa tabela é selecionável, e é por
 * isso que apontar para o cavalete não seleciona a bancada.
 */
export function montarSelecao(
  raiz: Object3D,
  alvos: ReadonlyMap<PecaId, Object3D>,
  destaque: Destaque,
): Selecao {
  const raio: Raycaster = new Raycaster();
  raio.far = ALCANCE_M;

  const porNo: Map<Object3D, PecaId> = new Map<Object3D, PecaId>();
  for (const [id, no] of alvos) {
    porNo.set(no, id);
    destaque.registrar(id, no);
  }

  let apontada: PecaId | undefined = undefined;
  let selecionada: PecaId | undefined = undefined;
  let degraus: number = 0;
  let noAtingido: string | undefined = undefined;
  let acionadoAntes: boolean = false;

  const ouvintes: Array<(mudanca: MudancaDeSelecao) => void> = [];

  // recorte:inicio selecao-subida-na-arvore
  /**
   * Sobe a árvore a partir do nó atingido até encontrar uma peça registrada.
   *
   * O laço para na raiz, e não em número de níveis: a profundidade da hierarquia
   * muda a cada módulo, e um limite escrito à mão envelheceria em silêncio — o
   * sintoma seria a peça que deixa de responder depois de ser reparentada.
   */
  function subirAtePeca(no: Object3D): { readonly id: PecaId | undefined; readonly degraus: number } {
    let atual: Object3D | null = no;
    let subidos: number = 0;
    while (atual !== null) {
      const id: PecaId | undefined = porNo.get(atual);
      if (id !== undefined) {
        return { id, degraus: subidos };
      }
      atual = atual.parent;
      subidos += 1;
    }
    return { id: undefined, degraus: subidos };
  }
  // recorte:fim selecao-subida-na-arvore

  // recorte:inicio selecao-raio-contra-a-cena
  function primeiroAlcancado(ponteiro: Ponteiro): Intersection | undefined {
    raio.set(ponteiro.origem, ponteiro.direcao);
    const encontros: Intersection[] = raio.intersectObject(raiz, true);
    for (const encontro of encontros) {
      // O contorno dos volumes de contato é auxiliar de depuração e não é
      // superfície: deixá-lo capturar o raio faria o realce piscar quando alguém
      // ligasse a exibição das caixas.
      if (!encontro.object.visible || encontro.object.type === 'LineSegments') {
        continue;
      }
      return encontro;
    }
    return undefined;
  }
  // recorte:fim selecao-raio-contra-a-cena

  function avisar(): void {
    const mudanca: MudancaDeSelecao = {
      apontada,
      selecionada,
      degrausDeSubida: degraus,
      noAtingido,
    };
    for (const ouvinte of ouvintes) {
      ouvinte(mudanca);
    }
  }

  return {
    atualizar(ponteiros: readonly Ponteiro[]): void {
      // O primeiro ponteiro ativo manda. Com dois controles, a regra vira a do
      // mais recentemente acionado, e é ali que ela é escrita — não aqui, onde
      // ainda não há dois de nada para exercitá-la.
      const ativo: Ponteiro | undefined = ponteiros.find(
        (ponteiro) => ponteiro.estado !== 'inativo',
      );

      const antes: PecaId | undefined = apontada;
      const escolhidaAntes: PecaId | undefined = selecionada;

      if (ativo === undefined) {
        apontada = undefined;
        noAtingido = undefined;
        degraus = 0;
        acionadoAntes = false;
        destaque.apontar(undefined);
        if (antes !== undefined) {
          avisar();
        }
        return;
      }

      const encontro: Intersection | undefined = primeiroAlcancado(ativo);
      if (encontro === undefined) {
        apontada = undefined;
        noAtingido = undefined;
        degraus = 0;
      } else {
        const subida = subirAtePeca(encontro.object);
        apontada = subida.id;
        degraus = subida.degraus;
        noAtingido = encontro.object.name === '' ? encontro.object.type : encontro.object.name;
      }
      destaque.apontar(apontada);

      // recorte:inicio selecao-borda-de-subida
      // A borda de subida do acionamento, e não o estado dele. É isto que faz o
      // pulso do cursor e o gatilho mantido do controle produzirem uma escolha
      // cada, em vez de uma escolha por quadro enquanto o dedo aperta.
      const bordaDeSubida: boolean = ativo.acionado && !acionadoAntes;
      acionadoAntes = ativo.acionado;

      if (bordaDeSubida) {
        // Acionar no vazio desfaz a escolha. É o comportamento que quem usa
        // espera e o que impede a peça de ficar acesa para sempre.
        selecionada = apontada;
        destaque.selecionar(selecionada);
      }
      // recorte:fim selecao-borda-de-subida

      if (apontada !== antes || selecionada !== escolhidaAntes) {
        avisar();
      }
    },
    apontada(): PecaId | undefined {
      return apontada;
    },
    selecionada(): PecaId | undefined {
      return selecionada;
    },
    estadoDe(id: PecaId): EstadoDaPeca {
      if (id === selecionada) {
        return 'selecionada';
      }
      if (id === apontada) {
        return 'apontada';
      }
      return 'livre';
    },
    aoMudar(ouvinte: (mudanca: MudancaDeSelecao) => void): void {
      ouvintes.push(ouvinte);
    },
    linhas(): string[] {
      const alvo: string = apontada ?? 'nada';
      const escolha: string = selecionada ?? 'nada';
      const caminho: string =
        noAtingido === undefined
          ? 'O raio não alcançou superfície alguma.'
          : `O raio acertou "${noAtingido}" e subiu ${degraus} ${
              degraus === 1 ? 'nível' : 'níveis'
            } até a peça.`;
      return [
        `Sob a mira: ${alvo}.`,
        `Escolhida: ${escolha}.`,
        caminho,
        `Alvos registrados: ${porNo.size}. Alcance do raio: ${ALCANCE_M} m.`,
      ];
    },
  };
}

/** O ponto do mundo em que o raio de um ponteiro chega a uma distância dada. */
export function pontoNoRaio(ponteiro: Ponteiro, distancia: number): Vector3 {
  return ponteiro.origem.clone().addScaledVector(ponteiro.direcao, distancia);
}

Lançar o raio é a parte fácil, e é a que vem de biblioteca. O problema é decidir o que a interseção significa.

O raio acerta um triângulo, e o triângulo pertence a um nó da árvore. Esse nó quase nunca é a peça. A bancada é uma hierarquia: o que está sob a mira pode ser a cabeça de um parafuso instanciado, o contorno de um volume de contato ou o próprio tampo.

A conversão de “o que o raio acertou” para “a peça de que isso faz parte” é uma subida na árvore até encontrar um nó registrado como alvo. O laço para na raiz, e não em um número de níveis: a profundidade da hierarquia muda a cada módulo, e limite escrito à mão envelhece em silêncio — o sintoma seria a peça que deixa de responder depois de ser reparentada.

1.4.4 A oclusão que ninguém precisou escrever

Por que lançar o raio contra a cena inteira, se só as peças interessam?

Porque é isso que faz o tampo interceptar o que está atrás dele. A interseção mais próxima é o tampo, o tampo não é peça, a subida não encontra alvo, e a mira não alcança nada — que é exatamente o correto, porque há madeira no caminho.

Lançar só contra as peças seria mais rápido e atravessaria a bancada. A engrenagem do outro lado acenderia através de cinco centímetros de tampo, e ninguém consegue nomear esse defeito olhando para a tela: parece um problema de perspectiva.

O ambiente exibe, ao vivo, o nó que o raio acertou ao lado da peça a que ele pertence, com quantos níveis a subida percorreu. Essa distância entre os dois é o conteúdo do módulo, e vê-la mudar enquanto se move o cursor encerra a discussão mais rápido que qualquer explicação.

1.4.5 O realce, e a armadilha do material compartilhado

src/bancada/interaction/destaque.ts
// ---------------------------------------------------------------------------
// O realce: o retorno visual sem o qual ninguém sabe o que está prestes a pegar.
//
// Ele não é acabamento. Uma seleção que funciona e não se vê é indistinguível de
// uma seleção quebrada, e o defeito só se manifesta quando a mira deixa de ser o
// centro exato da tela — que é justamente quando a pessoa mais precisa saber o
// que a mira alcançou.
//
// São dois estados, e mantê-los distintos é o assunto do arquivo. Apontada é a
// peça sob a mira agora, e ela muda o tempo todo, sem que ninguém tenha pedido
// nada. Selecionada é a peça que alguém escolheu, e ela permanece enquanto o
// cursor passeia por cima das outras. Um realce só para os dois estados produz o
// ambiente em que a pessoa não sabe se já escolheu.
//
// A armadilha deste arquivo está no material, e ela é silenciosa. As duas
// engrenagens compartilham o mesmo latão, e o corpo compartilha o plástico com a
// tampa — de propósito, desde o módulo de materiais, porque material distinto é
// troca de estado a mais no quadro. Escrever o realce direto no material
// compartilhado acende as duas engrenagens ao apontar para uma. O código
// compila, o realce aparece, e o que está errado é qual peça está acesa.
//
// A saída é dar a cada peça realçável uma cópia própria do material, e o preço é
// declarado: cinco cópias, cinco programas na placa, cinco trocas de estado por
// quadro em vez de três. É pouco porque são cinco peças; num ambiente com
// centenas, o realce se faria por outra via, e a conta é a mesma que decidiu a
// instanciação dos parafusos.
// ---------------------------------------------------------------------------

import { Color, Mesh, MeshStandardMaterial, Object3D } from 'three';

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

/** Cor e intensidade da emissão de quem está sob a mira. Discreta: é sinal, não destaque. */
const COR_APONTADA: number = 0x2f6fed;
const EMISSAO_APONTADA: number = 0.45;

/** Quem foi escolhido. Mais quente e mais forte, para não se confundir com a mira. */
const COR_SELECIONADA: number = 0xf0a02a;
const EMISSAO_SELECIONADA: number = 0.8;

export interface CustoDoRealce {
  /** Quantas peças ganharam cópia própria de material. */
  readonly copias: number;
  /** Quantos materiais distintos a cena tinha antes das cópias. */
  readonly materiaisAntes: number;
  /** Quantos passou a ter. É esta diferença que o orçamento do quadro paga. */
  readonly materiaisDepois: number;
}

export interface Destaque {
  /** Prepara a peça para poder ser realçada. Só quem foi registrado acende. */
  registrar(id: PecaId, no: Object3D): void;
  apontar(id: PecaId | undefined): void;
  selecionar(id: PecaId | undefined): void;
  custo(): CustoDoRealce;
  linhas(): string[];
}

interface PecaRealcavel {
  readonly material: MeshStandardMaterial;
  /** A emissão que o material tinha antes de qualquer realce, para poder voltar. */
  readonly emissaoOriginal: Color;
  readonly intensidadeOriginal: number;
}

export function montarDestaque(): Destaque {
  const pecas: Map<PecaId, PecaRealcavel> = new Map<PecaId, PecaRealcavel>();
  const materiaisCompartilhados: Set<MeshStandardMaterial> = new Set<MeshStandardMaterial>();
  let copias: number = 0;

  let apontada: PecaId | undefined = undefined;
  let selecionada: PecaId | undefined = undefined;

  function aplicar(): void {
    for (const [id, peca] of pecas) {
      if (id === selecionada) {
        peca.material.emissive.setHex(COR_SELECIONADA);
        peca.material.emissiveIntensity = EMISSAO_SELECIONADA;
        continue;
      }
      if (id === apontada) {
        peca.material.emissive.setHex(COR_APONTADA);
        peca.material.emissiveIntensity = EMISSAO_APONTADA;
        continue;
      }
      // Voltar ao valor guardado, e não a preto: o material pode ter emissão
      // própria por decisão do catálogo, e zerar apagaria uma escolha alheia.
      peca.material.emissive.copy(peca.emissaoOriginal);
      peca.material.emissiveIntensity = peca.intensidadeOriginal;
    }
  }

  return {
    registrar(id: PecaId, no: Object3D): void {
      if (!(no instanceof Mesh)) {
        throw new Error(`A peça "${id}" não é uma malha, e o realce escreve no material dela.`);
      }
      const atual: unknown = no.material;
      if (!(atual instanceof MeshStandardMaterial)) {
        throw new Error(`A peça "${id}" não usa o material padrão da oficina.`);
      }
      // recorte:inicio destaque-copia-do-material
      materiaisCompartilhados.add(atual);
      // A cópia é o ponto do arquivo. Sem ela, apontar para a engrenagem grande
      // acende também a pequena, porque as duas são o mesmo latão.
      const proprio: MeshStandardMaterial = atual.clone();
      proprio.name = `${atual.name}-de-${id}`;
      no.material = proprio;
      copias += 1;
      pecas.set(id, {
        material: proprio,
        emissaoOriginal: proprio.emissive.clone(),
        intensidadeOriginal: proprio.emissiveIntensity,
      });
      // recorte:fim destaque-copia-do-material
    },
    apontar(id: PecaId | undefined): void {
      if (id === apontada) {
        return;
      }
      apontada = id;
      aplicar();
    },
    selecionar(id: PecaId | undefined): void {
      if (id === selecionada) {
        return;
      }
      selecionada = id;
      aplicar();
    },
    custo(): CustoDoRealce {
      return {
        copias,
        materiaisAntes: materiaisCompartilhados.size,
        materiaisDepois: pecas.size,
      };
    },
    linhas(): string[] {
      const custo: CustoDoRealce = {
        copias,
        materiaisAntes: materiaisCompartilhados.size,
        materiaisDepois: pecas.size,
      };
      return [
        `Peças realçáveis: ${custo.copias}, cada uma com cópia própria do material.`,
        `Materiais distintos entre elas: ${custo.materiaisAntes} antes das cópias, ` +
          `${custo.materiaisDepois} depois.`,
        'Sem as cópias, apontar para uma engrenagem acenderia as duas: elas dividem o mesmo latão.',
        'A conta é a do orçamento do quadro, e ela vale enquanto as peças forem poucas.',
      ];
    },
  };
}

O realce não é acabamento. Uma seleção que funciona e não se vê é indistinguível de uma seleção quebrada, e a diferença só aparece quando a mira deixa de ser o centro exato da tela.

São dois estados, e mantê-los distintos é o que o arquivo faz. Apontada é a peça sob a mira agora, e ela muda o tempo todo, sem que ninguém tenha pedido nada. Escolhida é a peça que alguém decidiu, e ela permanece enquanto o cursor passeia por cima das outras. Um realce só para os dois estados produz o ambiente em que a pessoa não sabe se já escolheu.

A armadilha está no material, e é silenciosa. As duas engrenagens compartilham o mesmo latão desde o módulo de materiais, e o corpo compartilha o plástico com a tampa. Escrever o realce direto no material compartilhado acende as duas engrenagens ao apontar para uma.

O código compila, o realce aparece, e o que está errado é qual peça está acesa. A saída é dar a cada peça realçável uma cópia própria do material, e o preço está declarado no arquivo: cinco cópias, cinco programas na placa. É pouco porque são cinco peças — num ambiente com centenas, a conta seria a mesma que decidiu a instanciação dos parafusos.

1.4.6 O que a mira custa por quadro

Cada quadro é um envelope de onze milissegundos, e o que não couber nele não fica para depois: some.

A mira se refaz a cada passo do laço, e não a cada movimento do cursor. Parada a cena e parado o cursor, girar a órbita muda o que está sob a mira — então a leitura precisa acontecer no quadro, junto com tudo o mais.

O lançamento contra a cena inteira é a parte cara, e ela entra no mesmo envelope que o painel da bancada já exibe desde o módulo do laço. Nesta cena o custo não aparece na medição, e vale registrar por quê: são poucas dezenas de nós, e a biblioteca usa estrutura de aceleração. Num ambiente com milhares, a primeira medida seria restringir o alcance do raio, que aqui está em doze metros por folga.

1.4.7 Onde é fácil errar, e como conferir

Quatro defeitos aparecem sempre, e três deles não produzem erro no console.

O primeiro é o realce que fica aceso depois que o cursor sai da superfície. A fonte precisa distinguir “não aponta para dentro da cena” de “aponta para o vazio”, e devolver a última posição conhecida é o atalho que produz o defeito.

O segundo é a escolha por quadro. Ler o estado do acionamento em vez da borda de subida faz o gatilho mantido do controle escolher sessenta vezes por segundo. No cursor isso não aparece, porque o pulso dura uma leitura — e é por isso que o defeito atravessa este módulo inteiro e explode no seguinte.

O terceiro é o contorno dos volumes de contato capturando o raio. Ele é auxiliar de depuração, não superfície, e deixá-lo participar faz o realce piscar assim que alguém liga a exibição das caixas. A conferência é ligar as caixas e passear com o cursor.

O quarto tem erro visível e causa invisível: apontar para o cavalete e nada acontecer. É o comportamento correto — o cavalete não é peça —, e confundi-lo com falha custa meia tutoria.

1.5 O que este módulo deixa pronto

A tomada existe, e nenhum plugue de verdade foi ligado nela ainda.

A Bancada passou a responder. Passar o cursor sobre uma peça acende a mira, clicar escolhe, e a lógica que faz as duas coisas não sabe que existe um cursor. A fronteira das dependências está escrita e exibida, e a tabela de campos do ponteiro está pronta para dizer o que muda quando o controle chegar.

Vale registrar o que não entrou, porque é a fronteira que a tutoria mais precisa defender. Não há agarre, não há movimento de peça e não há encaixe. A escolha acende a peça e nada mais; o campo de agarre existe no tipo e é falso em toda leitura.

Também não entrou plugue algum além do cursor. A abstração tem um único alimentador, e essa é a situação em que ela parece um rodeio caro — é exatamente agora que um grupo desfaz o desenho, liga o evento do navegador direto na seleção, e economiza um arquivo.

O módulo seguinte traz agarre e manipulação, ainda com o cursor. É o do ciclo de sessão que traz o controle rastreado, e é lá que a conta deste módulo é cobrada. Quem escreveu a tomada registra outra fonte e não toca na lógica de montagem; quem não escreveu passa a manter dois programas, e a partir dali a distância entre os dois só cresce.