1 Manipulação, agarre e encaixe — 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

Este é o módulo em que a peça sai do lugar, e ela sai pela mesma operação de dois módulos atrás.

No módulo anterior a Bancada passou a responder. A peça acendia sob a mira, um clique a escolhia, e nada mais acontecia com ela. Este é o módulo em que a mão entra na cena.

O que a tela ganha é grande: apanhar uma engrenagem, girá-la, levá-la até o suporte e vê-la deslizar para o encaixe. O que o projeto ganha é menos vistoso e vale mais. A tarefa passa a ter estado, e o ambiente passa a ter o que dizer quando recusa alguma coisa.

Entram quatro arquivos, e três deles vivem numa pasta nova. A manipulação fica ao lado da seleção, porque é da mesma camada; os encaixes, a interpolação e a máquina de estados formam a camada de montagem, que é a que conhece a tarefa da oficina. Um quinto arquivo, o do primeiro plugue, ganha seis linhas — e essas seis linhas são a decisão mais discutível do módulo.

Nenhum arquivo de módulo anterior é reescrito. A tabela de campos do ponteiro muda uma linha, a fronteira das dependências ganha um item, e a composição cresce um bloco. É pouco, e é assim que se sabe que o módulo anterior valeu o que custou.

1.2 Agarrar é reparentar

A operação central deste módulo foi escrita cinco módulos atrás, e naquele dia parecia um detalhe de organização da árvore.

Como se prende uma peça à mão? Há duas respostas, e as duas produzem a mesma imagem hoje.

A primeira é copiar, a cada quadro, a posição do ponteiro para a posição da peça. Funciona, tem três linhas e é a que quase todo grupo escreve primeiro. A segunda é trocar de pai: a peça deixa de ser filha do tampo e passa a ser filha de um nó que acompanha o ponteiro.

A diferença aparece no que cada uma esquece. Quem copia posição precisa lembrar de copiar a orientação também, e quem esquece produz a peça que se endireita sozinha ao ser apanhada. Depois precisa lembrar da escala, da ordem de atualização, do que acontece quando a bancada inteira se move. Quem reparenta não lembra de nada disso, porque filho não gira porque o pai andou.

A exigência da tarefa deste módulo é justamente essa: a orientação preservada durante o transporte. Ela sai de graça da segunda resposta e custa uma lista de casos na primeira — e a lista cresce a cada módulo seguinte.

src/bancada/interaction/agarre.ts
// ---------------------------------------------------------------------------
// Agarre e manipulação: a peça obedecendo ao ponteiro.
//
// Este é o arquivo em que o ambiente deixa de ser algo que se olha. Até o módulo
// anterior a peça acendia sob a mira e nada mais; daqui em diante ela é apanhada,
// levada e solta — e a lógica que faz isso continua sem saber se quem aponta é um
// cursor, um controle rastreado ou um dedo.
//
// O mecanismo é o mesmo de dois módulos atrás, e não é coincidência: agarrar é
// reparentar. A peça deixa de ser filha do tampo e passa a ser filha de um nó
// que acompanha o ponteiro; soltá-la é devolvê-la ao pai antigo. As duas
// operações preservam o lugar no mundo, e é por isso que a peça não pula ao ser
// apanhada nem ao ser largada. A alternativa — copiar a posição do ponteiro para
// a peça a cada quadro — produz a mesma imagem hoje e cobra na primeira vez em
// que a bancada inteira se mover.
//
// A orientação preservada durante o transporte sai de graça dessa escolha, e vale
// dizer por quê: a peça é filha, e filho não gira porque o pai andou. Quem
// implementa por cópia de posição precisa lembrar de copiar a orientação também,
// e quem esquece produz a peça que se endireita sozinha ao ser apanhada.
//
// Aqui a metáfora dos três plugues encontra o próprio limite, e ele estava
// anunciado desde o módulo da abstração. O controle rastreado tem pose: a peça
// vai para a mão e acompanha os seis graus de liberdade dela. O cursor não tem
// pose nenhuma, e o melhor que o regime em janela consegue é manter a peça a uma
// distância fixa ao longo do raio, girando-a por comando à parte. A tomada é a
// mesma; o que passa por ela, não. Fingir o contrário aqui seria inventar uma
// pose para o mouse, que é exatamente o que a tabela de campos proibiu.
// ---------------------------------------------------------------------------

import { Object3D, Vector3 } from 'three';

import { reparentar } from '../core/hierarquia';
import type { PecaId } from '../dominio/dominio';
import type { Ponteiro } from '../input/ponteiro';
import type { Montagem, Recusa } from '../assembly/montagem';
import type { Selecao } from './selecao';

/** Eixos em torno dos quais o regime em janela gira a peça apanhada. */
export type EixoDeGiro = 'vertical' | 'lateral';

const EIXO_VERTICAL: Vector3 = new Vector3(0, 1, 0);
const EIXO_LATERAL: Vector3 = new Vector3(1, 0, 0);

export interface Manipulacao {
  /** Lê os ponteiros do quadro, apanha, acompanha e solta. Uma vez por passo. */
  atualizar(ponteiros: readonly Ponteiro[]): void;
  portada(): PecaId | undefined;
  /** Gira a peça apanhada. Devolve falso quando não há nada na mão. */
  girar(eixo: EixoDeGiro, graus: number): boolean;
  /** A última coisa que a manipulação teve a dizer: uma recusa, um agarre, uma soltura. */
  ultimaResposta(): string | undefined;
  linhas(): string[];
}

/**
 * Monta a manipulação sobre a raiz da cena, o mapa de peças, a seleção e a
 * montagem.
 *
 * A seleção diz o que está sob a mira; a montagem diz se aquilo pode sair do
 * lugar. Nenhuma das duas conhece o ponteiro, e este arquivo é o único que
 * conhece as três coisas — que é a razão de ele ser curto e de nada abaixo dele
 * precisar mudar quando o controle rastreado chegar.
 */
export function montarManipulacao(
  raiz: Object3D,
  alvos: ReadonlyMap<PecaId, Object3D>,
  selecao: Selecao,
  montagem: Montagem,
): Manipulacao {
  // O nó que faz as vezes de mão. Ele existe mesmo no regime em janela, onde não
  // há mão nenhuma: ter sempre um portador é o que mantém uma só operação de
  // agarre para os três regimes, em vez de um caminho por aparelho.
  const portador: Object3D = new Object3D();
  portador.name = 'portador';
  raiz.add(portador);

  // A casa de cada peça: o pai que ela tinha antes de qualquer agarre.
  //
  // Guardar isto uma vez, no começo, em vez de ler o pai atual no instante do
  // agarre, resolve um caso que passa despercebido: a peça já encaixada tem o
  // encaixe como pai, e devolvê-la a ele ao ser largada no meio da bancada
  // deixaria uma peça solta pendurada na árvore do suporte. A imagem ficaria
  // certa e a árvore, mentindo — até alguém mover a bancada.
  const casaDe: Map<PecaId, Object3D> = new Map<PecaId, Object3D>();
  for (const [id, no] of alvos) {
    casaDe.set(id, no.parent ?? raiz);
  }

  const alcance: Vector3 = new Vector3();

  let portada: PecaId | undefined = undefined;
  let distanciaM: number = 0;
  let agarreAntes: boolean = false;
  let resposta: string | undefined = undefined;
  let apanhadas: number = 0;
  let comPose: boolean = false;

  function acompanhar(ponteiro: Ponteiro): void {
    if (ponteiro.pose !== undefined) {
      // Com pose, o portador É a mão: posição e orientação vêm do aparelho, e a
      // peça herda os seis graus de liberdade sem uma linha a mais.
      portador.position.copy(ponteiro.pose.posicao);
      portador.quaternion.copy(ponteiro.pose.orientacao);
      return;
    }
    // Sem pose, o portador desliza sobre a esfera de raio fixo centrada na
    // origem do raio. A distância é a que a peça tinha quando foi apanhada, e
    // mantê-la é o que impede a peça de vir para o rosto ao ser apanhada.
    portador.position.copy(ponteiro.origem).addScaledVector(ponteiro.direcao, distanciaM);
  }

  function apanhar(ponteiro: Ponteiro): void {
    const alvo: PecaId | undefined = selecao.apontada();
    if (alvo === undefined) {
      resposta = 'O agarre pegou no vazio: não havia peça sob a mira.';
      return;
    }

    const recusa: Recusa | undefined = montagem.podeAgarrar(alvo);
    if (recusa !== undefined) {
      resposta = recusa.motivo;
      return;
    }

    const no: Object3D | undefined = alvos.get(alvo);
    if (no === undefined) {
      resposta = `A peça "${alvo}" está no domínio e não está na cena.`;
      return;
    }

    no.getWorldPosition(alcance);
    distanciaM = ponteiro.origem.distanceTo(alcance);
    comPose = ponteiro.pose !== undefined;

    // O portador vai primeiro para o lugar certo, e só então recebe a peça. Na
    // ordem inversa a reparentagem calcularia a matriz local contra a posição
    // antiga do portador, e a peça saltaria no instante do agarre.
    acompanhar(ponteiro);
    reparentar(no, portador);

    portada = alvo;
    apanhadas += 1;
    montagem.aoAgarrar(alvo);
    resposta = `A ${alvo} foi apanhada a ${distanciaM.toFixed(2)} m do ponto de origem do raio.`;
  }

  function largar(): void {
    if (portada === undefined) {
      return;
    }
    const no: Object3D | undefined = alvos.get(portada);
    const destino: Object3D = casaDe.get(portada) ?? raiz;
    if (no !== undefined) {
      // A peça volta ao pai que tinha antes do agarre, no lugar em que foi
      // solta. Não há gravidade neste ambiente, e isso é decisão declarada: a
      // peça larga no ar fica no ar, e o encaixe é o que a leva a algum lugar.
      reparentar(no, destino);
      montagem.aoSoltar(portada, no);
    }
    portada = undefined;
    comPose = false;
  }

  return {
    atualizar(ponteiros: readonly Ponteiro[]): void {
      const ativo: Ponteiro | undefined = ponteiros.find(
        (ponteiro) => ponteiro.estado !== 'inativo',
      );

      if (ativo === undefined) {
        // Sem ponteiro ativo, a peça apanhada fica onde está. Soltá-la aqui
        // seria largar a peça no ar toda vez que o cursor cruzasse a borda da
        // superfície de desenho, o que quem usa lê como perda de controle.
        return;
      }

      const querAgarrar: boolean = ativo.agarre;
      const bordaDeSubida: boolean = querAgarrar && !agarreAntes;
      const bordaDeDescida: boolean = !querAgarrar && agarreAntes;
      agarreAntes = querAgarrar;

      if (bordaDeSubida && portada === undefined) {
        apanhar(ativo);
      } else if (bordaDeDescida && portada !== undefined) {
        largar();
      }

      if (portada !== undefined) {
        acompanhar(ativo);
      }
    },

    portada(): PecaId | undefined {
      return portada;
    },

    girar(eixo: EixoDeGiro, graus: number): boolean {
      if (portada === undefined) {
        return false;
      }
      // Girar por comando é remendo do regime em janela, e o arquivo diz isso em
      // vez de disfarçar. Onde há pose, a orientação do portador é reescrita a
      // cada quadro pelo aparelho, e este giro seria apagado no quadro seguinte.
      portador.rotateOnWorldAxis(
        eixo === 'vertical' ? EIXO_VERTICAL : EIXO_LATERAL,
        (graus * Math.PI) / 180,
      );
      return true;
    },

    ultimaResposta(): string | undefined {
      return resposta;
    },

    linhas(): string[] {
      const naMao: string =
        portada === undefined
          ? 'Nada na mão agora.'
          : `Na mão: ${portada}, a ${distanciaM.toFixed(2)} m ao longo do raio.`;
      const regime: string = comPose
        ? 'A fonte ativa entrega pose: a peça acompanha os seis graus de liberdade da mão.'
        : 'A fonte ativa não entrega pose: a peça desliza a distância fixa ao longo do raio, ' +
          'e o giro vem de comando à parte.';
      return [
        naMao,
        regime,
        `Peças apanhadas nesta sessão: ${apanhadas}.`,
        resposta === undefined ? 'Nenhum agarre tentado ainda.' : `Última resposta: ${resposta}`,
      ];
    },
  };
}

1.2.1 O nó que faz as vezes de mão

O portador existe mesmo no regime em janela, onde não há mão nenhuma. Isso incomoda quem lê o arquivo pela primeira vez, e a razão é curta: ter sempre um portador é o que mantém uma só operação de agarre para os três regimes.

A alternativa seria um caminho por aparelho — com pose, prende à mão; sem pose, move ao longo do raio. Dois caminhos hoje, três quando o toque chegar, e a lógica de montagem passando a perguntar qual deles está em uso. É exatamente o que a camada de entrada existe para impedir.

Repare também na ordem dentro do agarre. O portador vai primeiro para o lugar certo e só então recebe a peça. Na ordem inversa, a reparentagem calcularia a matriz local contra a posição antiga do portador, e a peça saltaria no instante do agarre — um salto pequeno, de poucos centímetros, que quem testa atribui a imprecisão do mouse.

Há um caso que passa despercebido e que o arquivo trata desde a primeira linha do construtor. Cada peça guarda a casa dela: o pai que tinha antes de qualquer agarre. Ler o pai atual no instante do agarre pareceria equivalente, e não é — a peça já encaixada tem o encaixe como pai, e devolvê-la a ele ao ser largada no meio da bancada deixaria uma peça solta pendurada na árvore do suporte. A imagem ficaria certa e a árvore, mentindo. O erro só apareceria quando alguém movesse a bancada.

1.3 Onde a tomada encontra o próprio limite

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

A frase acima abriu o módulo anterior e continua verdadeira. Este é o módulo em que ela cobra o preço, e o preço estava anunciado: a tabela de campos do ponteiro diz, desde que foi escrita, que a pose existe no controle rastreado e não existe no cursor.

Agora essa linha tem consequência visível. Com pose, o portador é a mão: posição e orientação vêm do aparelho, e a peça herda os seis graus de liberdade sem uma linha a mais. Sem pose, o melhor que o regime em janela consegue é manter a peça a uma distância fixa ao longo do raio.

A peça desliza, então, sobre uma esfera centrada na origem do raio. É menos do que seis graus de liberdade, e chamar isso de equivalente seria mentir por otimismo. A uniformidade da tomada é conveniência de arquitetura, e não afirmação de que os três regimes oferecem o mesmo.

1.3.1 A pega que o cursor não sabe manter

O controle rastreado mantém o gatilho apertado enquanto a peça está na mão. O cursor não consegue, e a razão é a órbita.

O botão está em disputa desde o módulo anterior: no instante em que ele desce, ninguém sabe se aquilo vai virar clique ou arrasto de câmera, e só a soltura responde. Um gatilho que só se conhece ao ser solto não serve para dizer “estou segurando”. A saída é o clique alternar — o primeiro apanha, o segundo larga.

A decisão que importa está no lugar em que essa alternância mora. Ela fica dentro do arquivo do cursor, e não na camada de manipulação.

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;
    },
  };
}

É o cursor que sabe que o aparelho dele não sabe dizer “mantido”. Escrever o caso especial na camada de cima faria a lógica de montagem ganhar um ramo por aparelho, e o segundo ramo chegaria no módulo do ciclo de sessão. A partir daí a economia do módulo anterior estaria desfeita, sem que nada quebrasse e sem que ninguém percebesse.

Quem consome continua lendo um booleano de estado, igual ao que o gatilho do controle vai entregar. A camada de manipulação lê a borda de subida e a borda de descida do mesmo campo, e não tem como perguntar qual dos dois está do outro lado.

1.3.2 O giro por tecla, e por que ele fica na composição

Falta a orientação. A tarefa pede pegar, orientar e soltar, e o cursor não orienta coisa alguma.

O remendo é conhecido: teclas. Duas para o eixo vertical, duas para o lateral, quinze graus por toque. Passos discretos, e não giro contínuo, porque giro contínuo por tecla disputaria com a órbita pelo mesmo teclado — e porque quinze graus é o suficiente para exercitar a folga angular do encaixe.

O ponto de projeto está no lugar em que esse remendo é ligado. Ele mora na composição da oficina, que é a única camada com licença para conhecer um aparelho pelo nome. Nada abaixo dela sabe que existe um teclado, e a manipulação só oferece a operação de girar a peça apanhada, sem perguntar quem pediu.

No visor essa ligação simplesmente não faz falta. A orientação do portador é reescrita a cada quadro pela pose da mão, e um giro por tecla seria apagado no quadro seguinte. É o mesmo arquivo, com uma linha da composição que deixa de importar — que é a forma honesta de um regime degradar para o outro.

1.4 Tarefa 1: Pegar, orientar e soltar

A pergunta de tutoria que resolve este módulo é uma só: o que acontece com a peça quando o menu está fechado?

Enunciado da tarefa

O usuário passa a agir sobre os objetos da cena, e não apenas sobre a interface em volta dela. Apanhar uma peça, girá-la, levá-la a outro lugar e soltá-la é a ação que dá objeto ao assunto deste trecho do percurso.

O que fica pronto é a peça obedecendo ao ponteiro enquanto está apanhada, com a orientação preservada durante o transporte. Manipulação restrita a botões e menus deixa o conteúdo virtual intocado, e é a forma mais comum de o ambiente parecer interativo sem nunca ter sido.

A resolução está no arquivo da manipulação, que já apareceu acima. O que vale destacar aqui é a ordem em que o quadro executa as três leituras.

A seleção lê os ponteiros primeiro, e a manipulação lê os mesmos ponteiros depois. Não é indiferente: o agarre precisa saber o que está sob a mira neste quadro, e não no anterior. Inverter as duas linhas faz o agarre pegar a peça que estava sob a mira antes do movimento, e o defeito só aparece quando o cursor anda rápido — que é justamente quando ninguém está prestando atenção nele.

1.4.1 O que acontece quando o ponteiro some

O cursor cruza a borda da superfície de desenho e deixa de existir. E a peça que estava na mão?

Duas respostas são defensáveis, e a escolhida está escrita no arquivo: a peça fica onde está. Soltá-la ali seria largá-la no ar toda vez que o cursor passasse pela borda, e quem usa lê isso como perda de controle — não como comportamento de borda.

A outra resposta, soltar, tem a favor a simetria com o mundo físico. Perdeu o contato, caiu. O problema é que não há queda neste ambiente, então a peça não cairia: ficaria parada no ar, no lugar em que o cursor a abandonou, que é o pior dos dois mundos.

Vale dizer em voz alta que não há gravidade aqui, e que isso é decisão declarada. A peça largada no ar fica no ar. O encaixe é o único mecanismo que a leva a algum lugar por conta própria, e um módulo de física não está na ementa deste percurso.

1.4.2 Onde é fácil errar, e como conferir

Três defeitos aparecem em quase todo grupo, e nenhum deles produz erro no console.

O primeiro é a peça que salta ao ser apanhada. A causa quase sempre é a ordem descrita acima — o portador recebendo a peça antes de ir para o lugar. O sintoma é discreto e some quando o cursor está no centro da tela, o que faz o grupo concluir que era impressão.

O segundo é a peça que se endireita ao ser apanhada. Aí o grupo abandonou a reparentagem e voltou a copiar posição, esquecendo a orientação. A conferência é direta: gire a peça no ar, solte-a, apanhe-a de novo e veja se ela mantém o ângulo.

O terceiro é o mais caro e o mais discreto. A peça acompanha o ponteiro, e a árvore da cena não muda: o grupo está escrevendo a posição de mundo direto no nó, quadro a quadro. Funciona hoje e quebra no módulo de ancoragem, quando a cena inteira passa a ser filha de uma âncora que se move. A conferência é abrir a árvore da cena, apanhar uma peça e ver o nó mudar de lugar na hierarquia.

1.5 Tarefa 2: Declarar a tolerância e interpolar o encaixe

Quatro centímetros. O número parece arbitrário e não é, e o arquivo carrega a prova.

Enunciado da tarefa

Soltar uma peça a três centímetros do encaixe deve funcionar. Soltá-la a trinta, não. O número entre os dois é decisão declarada, e ele determina se o ambiente parece generoso ou frouxo.

A tarefa fecha com a tolerância linear e a angular escritas, e com a peça interpolando até a posição final quando cai dentro delas. O valor escolhido merece registro junto do que se testou antes de escolhê-lo, porque ninguém o reconstitui depois olhando o código.

src/bancada/assembly/sockets.ts
// ---------------------------------------------------------------------------
// Os encaixes do suporte, e a tolerância como decisão declarada.
//
// Um socket é um lugar com endereço, orientação e duas folgas: a linear, em
// metros, e a angular, em graus. Soltar a peça dentro das duas manda encaixar;
// fora de qualquer uma delas, não.
//
// O número no meio é o conteúdo deste arquivo. Ele não sai de teoria: sai de
// tentar, e por isso o que se tentou está escrito aqui como dado tipado, ao lado
// do valor adotado. Tolerância sem o registro do que se experimentou antes é
// indistinguível de chute — e, pior, não se revisa: quem abrir o arquivo daqui a
// um ano encontra "0.04" e não tem como saber se aquilo foi medido ou digitado.
//
// A folga angular tem uma sutileza que a linear não tem, e ela precisa estar
// escrita porque quase todo grupo tropeça nela: peça com simetria de revolução
// não a exerce. A engrenagem é um cilindro, e girá-la em torno do próprio eixo
// não muda nada que o olho veja — a folga angular dela é decorativa. Quem a
// exerce é a tampa, que é uma caixa: girada quarenta e cinco graus, ela fica
// visivelmente torta sobre o mecanismo.
// ---------------------------------------------------------------------------

import {
  BoxGeometry,
  EdgesGeometry,
  LineBasicMaterial,
  LineSegments,
  Object3D,
  Quaternion,
  Vector3,
} from 'three';

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

/** Folga linear adotada, em metros. O caminho até este número está nos ensaios abaixo. */
const TOLERANCIA_LINEAR_M: number = 0.04;

/** Folga angular adotada, em graus. Só as peças sem simetria de revolução a exercem. */
const TOLERANCIA_ANGULAR_GRAUS: number = 20;

/**
 * Um encaixe do suporte.
 *
 * O nó é filho do suporte, e não da cena: arrastar a bancada leva os encaixes
 * junto sem que ninguém escreva uma linha para isso. É a mesma razão pela qual o
 * suporte é filho do tampo desde o módulo do grafo de cena.
 */
export interface Socket {
  readonly id: SocketId;
  readonly aceita: readonly PecaId[];
  readonly no: Object3D;
  readonly toleranciaLinearM: number;
  readonly toleranciaAngularGraus: number;
  /** O que a simetria da peça faz com a folga angular. Serve à leitura, não à conta. */
  readonly simetria: string;
}

/** O que se experimentou antes de fixar as duas folgas, e o que cada extremo produziu. */
export interface EnsaioDeTolerancia {
  readonly linearM: number;
  readonly angularGraus: number;
  readonly observado: string;
  readonly adotado: boolean;
}

export const ENSAIOS_DE_TOLERANCIA: readonly EnsaioDeTolerancia[] = [
  {
    linearM: 0.01,
    angularGraus: 5,
    observado:
      'o encaixe recusa quase toda tentativa; quem usa repete o gesto três vezes, ' +
      'conclui que o ambiente travou e para de tentar',
    adotado: false,
  },
  {
    linearM: 0.04,
    angularGraus: 20,
    observado:
      'a peça largada a quatro centímetros do alvo entra, e a largada a dez fica onde ' +
      'caiu; é a faixa em que o gesto parece generoso sem parecer automático',
    adotado: true,
  },
  {
    linearM: 0.12,
    angularGraus: 45,
    observado:
      'a peça salta para o encaixe de onde ninguém a quis; soltá-la sobre a bancada, ' +
      'a um palmo do suporte, faz o suporte engoli-la',
    adotado: false,
  },
];

/**
 * Onde cada encaixe fica, em relação ao suporte, e o que ele aceita.
 *
 * As posições saem da geometria das peças montadas umas sobre as outras: o corpo
 * assenta na base, o eixo sobe do corpo, as engrenagens se prendem ao eixo em
 * alturas próximas e a tampa fecha por cima.
 */
interface DescricaoDeSocket {
  readonly id: SocketId;
  readonly aceita: readonly PecaId[];
  readonly posicao: readonly [number, number, number];
  readonly simetria: string;
}

const DESCRICOES: readonly DescricaoDeSocket[] = [
  {
    id: 'base-do-suporte',
    aceita: ['corpo'],
    posicao: [0, 0.05, 0],
    simetria: 'caixa: a folga angular vale, e a peça torta se vê',
  },
  {
    id: 'furo-do-eixo',
    aceita: ['eixo'],
    posicao: [0, 0.19, 0],
    simetria: 'cilindro: gira em torno do próprio eixo sem que nada mude',
  },
  {
    id: 'dente-maior',
    aceita: ['engrenagem-grande'],
    posicao: [0, 0.16, 0],
    simetria: 'cilindro: a folga angular não é exercida por esta peça',
  },
  {
    id: 'dente-menor',
    aceita: ['engrenagem-pequena'],
    posicao: [0.12, 0.16, 0],
    simetria: 'cilindro: a folga angular não é exercida por esta peça',
  },
  {
    id: 'topo',
    aceita: ['tampa'],
    posicao: [0, 0.306, 0],
    simetria: 'caixa: é a peça que torna a folga angular observável',
  },
];

/**
 * Monta os encaixes sob o suporte, cada um com um contorno do tamanho exato da
 * própria folga linear.
 *
 * O contorno não é enfeite e não é depuração: ele É a tolerância, desenhada. A
 * discussão sobre qual número adotar muda de qualidade quando a folga deixa de
 * ser uma constante no arquivo e passa a ser uma caixa que se vê ao lado da peça.
 *
 * Ele é feito de segmentos de linha de propósito. A camada de seleção descarta
 * segmentos ao lançar o raio desde o módulo anterior, então exibir os encaixes
 * não passa a capturar a mira — que é o defeito que apareceria em silêncio se o
 * contorno fosse uma malha translúcida.
 */
export function montarSockets(suporte: Object3D): ReadonlyMap<SocketId, Socket> {
  const sockets: Map<SocketId, Socket> = new Map<SocketId, Socket>();
  const lado: number = TOLERANCIA_LINEAR_M * 2;

  for (const descricao of DESCRICOES) {
    const no: Object3D = new Object3D();
    no.name = `encaixe-${descricao.id}`;
    no.position.set(descricao.posicao[0], descricao.posicao[1], descricao.posicao[2]);
    suporte.add(no);

    const contorno: LineSegments = new LineSegments(
      new EdgesGeometry(new BoxGeometry(lado, lado, lado)),
      new LineBasicMaterial({ color: 0x4f9f6f }),
    );
    contorno.name = `folga-de-${descricao.id}`;
    contorno.visible = false;
    no.add(contorno);

    sockets.set(descricao.id, {
      id: descricao.id,
      aceita: descricao.aceita,
      no,
      toleranciaLinearM: TOLERANCIA_LINEAR_M,
      toleranciaAngularGraus: TOLERANCIA_ANGULAR_GRAUS,
      simetria: descricao.simetria,
    });
  }

  return sockets;
}

/** Liga e desliga os contornos de folga. Devolve o estado em que ficaram. */
export function alternarFolgasVisiveis(sockets: ReadonlyMap<SocketId, Socket>): boolean {
  let exibindo: boolean = false;
  for (const socket of sockets.values()) {
    for (const filho of socket.no.children) {
      filho.visible = !filho.visible;
      exibindo = filho.visible;
    }
  }
  return exibindo;
}

export interface AvaliacaoDeEncaixe {
  readonly socket: SocketId;
  readonly distanciaM: number;
  readonly desvioGraus: number;
  readonly dentroDoLinear: boolean;
  readonly dentroDoAngular: boolean;
  readonly dentro: boolean;
}

/** Reaproveitados entre chamadas: a avaliação roda a cada soltura, e às vezes por quadro. */
const posicaoDaPeca: Vector3 = new Vector3();
const posicaoDoSocket: Vector3 = new Vector3();
const giroDaPeca: Quaternion = new Quaternion();
const giroDoSocket: Quaternion = new Quaternion();

/**
 * Mede o quanto a peça está longe do encaixe, nas duas grandezas.
 *
 * As duas medidas saem do espaço do mundo, e não do espaço local de ninguém.
 * Comparar posições locais de nós com pais diferentes é o erro que produz o
 * encaixe funcionando enquanto a bancada está na origem e parando de funcionar
 * assim que alguém a move.
 */
export function avaliarEncaixe(socket: Socket, peca: Object3D): AvaliacaoDeEncaixe {
  peca.updateWorldMatrix(true, false);
  socket.no.updateWorldMatrix(true, false);

  peca.getWorldPosition(posicaoDaPeca);
  socket.no.getWorldPosition(posicaoDoSocket);
  peca.getWorldQuaternion(giroDaPeca);
  socket.no.getWorldQuaternion(giroDoSocket);

  const distanciaM: number = posicaoDaPeca.distanceTo(posicaoDoSocket);
  const desvioGraus: number = (giroDaPeca.angleTo(giroDoSocket) * 180) / Math.PI;
  const dentroDoLinear: boolean = distanciaM <= socket.toleranciaLinearM;
  const dentroDoAngular: boolean = desvioGraus <= socket.toleranciaAngularGraus;

  return {
    socket: socket.id,
    distanciaM,
    desvioGraus,
    dentroDoLinear,
    dentroDoAngular,
    dentro: dentroDoLinear && dentroDoAngular,
  };
}

export interface CandidatoAEncaixe {
  readonly socket: Socket;
  readonly avaliacao: AvaliacaoDeEncaixe;
}

/**
 * O encaixe compatível mais próximo da peça, esteja ela dentro da folga ou não.
 *
 * Devolver também o que ficou fora é deliberado: é o que permite dizer a quem
 * soltou "faltaram nove centímetros" em vez de não dizer nada. Silêncio aqui é o
 * mesmo defeito que a recusa sem explicação, com outra roupa.
 */
export function socketMaisProximo(
  sockets: ReadonlyMap<SocketId, Socket>,
  pecaId: PecaId,
  peca: Object3D,
): CandidatoAEncaixe | undefined {
  let melhor: CandidatoAEncaixe | undefined = undefined;

  for (const socket of sockets.values()) {
    if (!socket.aceita.includes(pecaId)) {
      continue;
    }
    const avaliacao: AvaliacaoDeEncaixe = avaliarEncaixe(socket, peca);
    if (melhor === undefined || avaliacao.distanciaM < melhor.avaliacao.distanciaM) {
      melhor = { socket, avaliacao };
    }
  }

  return melhor;
}

/** As folgas adotadas e o caminho até elas, para o painel e para a página. */
export function linhasDaTolerancia(): string[] {
  const linhas: string[] = [
    `Folga adotada: ${(TOLERANCIA_LINEAR_M * 100).toFixed(0)} cm de distância e ` +
      `${TOLERANCIA_ANGULAR_GRAUS}° de desvio de orientação.`,
    'O que se experimentou antes de fixá-la:',
  ];
  for (const ensaio of ENSAIOS_DE_TOLERANCIA) {
    const marca: string = ensaio.adotado ? 'adotada' : 'descartada';
    linhas.push(
      `  ${(ensaio.linearM * 100).toFixed(0)} cm / ${ensaio.angularGraus}° — ${marca}: ` +
        `${ensaio.observado}.`,
    );
  }
  linhas.push(
    'A folga angular só é exercida por peça sem simetria de revolução: a tampa a exerce, ' +
      'as duas engrenagens não.',
  );
  return linhas;
}

O arquivo dos encaixes carrega dois números e três ensaios. Os ensaios são o conteúdo, e eles estão ali como dado tipado pela razão que já governa a delimitação do domínio e a fronteira das dependências: o que precisa ser confrontado depois não sobrevive como comentário solto.

Um centímetro recusa quase toda tentativa. Quem usa repete o gesto três vezes, conclui que o ambiente travou e para de tentar. Doze centímetros fazem a peça saltar para o encaixe de onde ninguém a quis, e soltá-la sobre a bancada, a um palmo do suporte, faz o suporte engoli-la.

Quatro centímetros é a faixa em que o gesto parece generoso sem parecer automático. Não há teoria que entregue esse número: ele sai de tentar, e é por isso que o que se tentou está escrito ao lado dele.

1.5.1 A folga que a engrenagem não exerce

A tolerância angular tem uma sutileza que a linear não tem, e quase todo grupo tropeça nela.

Peça com simetria de revolução não a exerce. A engrenagem é um cilindro, e girá-la em torno do próprio eixo não muda nada que o olho veja — a folga angular dela é decorativa. Escrever vinte graus ali é escrever um número que jamais será testado.

Quem exerce a folga angular é a tampa, que é uma caixa. Girada quarenta e cinco graus, ela fica visivelmente torta sobre o mecanismo, e a recusa do encaixe é a coisa certa a fazer. O corpo, também uma caixa, é o segundo caso.

Isso rende uma conversa de tutoria que vale o tempo. Um grupo que calibrou a folga angular testando com a engrenagem calibrou nada: qualquer valor passa, porque qualquer orientação está certa. O ensaio precisa ser feito com a peça que tem lado.

1.5.2 Por que a peça desliza em vez de saltar

src/bancada/assembly/encaixe.ts
// ---------------------------------------------------------------------------
// O encaixe interpolado: da onde a peça foi solta até onde ela deve ficar.
//
// A operação inteira cabe em duas linhas, e as duas são de um módulo anterior. A
// peça é reparentada ao encaixe, o que preserva o lugar dela no mundo; a partir
// daí, a posição alvo é a origem do encaixe e a orientação alvo é a identidade,
// porque no espaço local do pai o destino é sempre esse. O que sobra é levar a
// transformação local de onde ela está até zero.
//
// Não é preciosismo escrever isso em vez de atribuir o destino de uma vez. O
// salto instantâneo quebra a causalidade para quem observa: a peça some de um
// lugar e aparece noutro, e o olho não liga as duas imagens. No visor é pior — um
// objeto que estava na mão saltando sem trajetória é lido como falha, e a pessoa
// tira o headset para conferir se travou.
//
// A interpolação da orientação é feita por quatérnio, e não por ângulo de Euler.
// Interpolar Euler produz o caminho torto pelo qual a peça dá uma volta a mais
// para chegar a um destino que estava logo ali — e o defeito só aparece em alguns
// pares de orientações, que é o pior tipo de defeito que existe.
// ---------------------------------------------------------------------------

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

import { reparentar } from '../core/hierarquia';
import type { PecaId, SocketId } from '../dominio/dominio';
import type { Socket } from './sockets';

/**
 * Duração do encaixe, em segundos.
 *
 * Curto o bastante para não parecer que o ambiente está pensando; longo o
 * bastante para o olho acompanhar de onde a peça veio. Abaixo de um décimo de
 * segundo é indistinguível de salto, e acima de meio segundo quem monta a quarta
 * peça já está esperando.
 */
const DURACAO_S: number = 0.22;

interface EncaixeAtivo {
  readonly peca: PecaId;
  readonly socket: SocketId;
  readonly no: Object3D;
  readonly posicaoInicial: Vector3;
  readonly giroInicial: Quaternion;
  decorrido: number;
}

export interface EncaixeEmCurso {
  readonly peca: PecaId;
  readonly socket: SocketId;
  /** De zero a um. É o que o painel exibe enquanto a peça caminha. */
  readonly progresso: number;
}

export interface Encaixes {
  /** Começa a levar a peça até o encaixe. A reparentagem acontece aqui, não no fim. */
  iniciar(peca: PecaId, no: Object3D, socket: Socket): void;
  /** Avança as interpolações em curso pelo tempo do quadro. */
  avancar(delta: number): void;
  emCurso(): EncaixeEmCurso | undefined;
  aoConcluir(ouvinte: (peca: PecaId, socket: SocketId) => void): void;
  linhas(): string[];
}

/**
 * A curva do movimento: acelera e desacelera, em vez de partir e parar de
 * repente. É a função suave mais barata que existe, e a diferença que ela faz é
 * a diferença entre a peça deslizando e a peça sendo arrastada por um cabo.
 */
function suavizar(t: number): number {
  return t * t * (3 - 2 * t);
}

export function montarEncaixes(): Encaixes {
  const ativos: EncaixeAtivo[] = [];
  const ouvintes: Array<(peca: PecaId, socket: SocketId) => void> = [];
  let concluidos: number = 0;
  let ultimoDesvioM: number = 0;

  return {
    iniciar(peca: PecaId, no: Object3D, socket: Socket): void {
      // A reparentagem preserva a pose de mundo e devolve o desvio residual, que
      // é o erro de arredondamento da conta. Ele fica registrado porque é a única
      // medida que denuncia matriz desatualizada no caminho até a raiz.
      ultimoDesvioM = reparentar(no, socket.no);
      ativos.push({
        peca,
        socket: socket.id,
        no,
        posicaoInicial: no.position.clone(),
        giroInicial: no.quaternion.clone(),
        decorrido: 0,
      });
    },

    avancar(delta: number): void {
      for (let i: number = ativos.length - 1; i >= 0; i -= 1) {
        const ativo: EncaixeAtivo | undefined = ativos[i];
        if (ativo === undefined) {
          continue;
        }
        ativo.decorrido += delta;
        const bruto: number = Math.min(ativo.decorrido / DURACAO_S, 1);
        const t: number = suavizar(bruto);

        // O destino é a origem do pai e a identidade, e não uma pose guardada:
        // depois da reparentagem, "estar encaixada" é literalmente estar em zero
        // no espaço local do encaixe. Guardar a pose alvo à parte seria manter um
        // segundo lugar onde a mesma verdade pode envelhecer.
        ativo.no.position.copy(ativo.posicaoInicial).multiplyScalar(1 - t);
        ativo.no.quaternion.slerpQuaternions(ativo.giroInicial, new Quaternion(), t);

        if (bruto >= 1) {
          // O último passo é atribuído, e não interpolado. Terminar em
          // 0,0000003 m do alvo deixa a peça quase no lugar, e "quase" acumula
          // ao longo de cinco encaixes até virar um mecanismo torto.
          ativo.no.position.set(0, 0, 0);
          ativo.no.quaternion.identity();
          ativos.splice(i, 1);
          concluidos += 1;
          for (const ouvinte of ouvintes) {
            ouvinte(ativo.peca, ativo.socket);
          }
        }
      }
    },

    emCurso(): EncaixeEmCurso | undefined {
      const ativo: EncaixeAtivo | undefined = ativos[0];
      if (ativo === undefined) {
        return undefined;
      }
      return {
        peca: ativo.peca,
        socket: ativo.socket,
        progresso: Math.min(ativo.decorrido / DURACAO_S, 1),
      };
    },

    aoConcluir(ouvinte: (peca: PecaId, socket: SocketId) => void): void {
      ouvintes.push(ouvinte);
    },

    linhas(): string[] {
      const emCurso: EncaixeAtivo | undefined = ativos[0];
      const andamento: string =
        emCurso === undefined
          ? 'Nenhuma peça a caminho de um encaixe agora.'
          : `A ${emCurso.peca} está a caminho de "${emCurso.socket}": ` +
            `${((emCurso.decorrido / DURACAO_S) * 100).toFixed(0)}% do percurso.`;
      return [
        andamento,
        `Duração do encaixe: ${DURACAO_S.toFixed(2)} s, com aceleração e desaceleração.`,
        `Encaixes concluídos nesta sessão: ${concluidos}.`,
        `Desvio de mundo na última reparentagem: ${ultimoDesvioM.toExponential(1)} m.`,
      ];
    },
  };
}

A operação inteira cabe em duas linhas, e as duas vêm de um módulo anterior. A peça é reparentada ao encaixe, o que preserva o lugar dela no mundo. A partir daí, o destino é a origem do pai e a identidade — porque, no espaço local do encaixe, “estar encaixada” é literalmente estar em zero.

Guardar a pose alvo à parte seria manter um segundo lugar onde a mesma verdade pode envelhecer. Depois da reparentagem, ela já está escrita na estrutura.

E por que não atribuir o destino de uma vez? Porque o salto instantâneo quebra a causalidade para quem observa. A peça some de um lugar e aparece noutro, e o olho não liga as duas imagens. No visor é pior: um objeto que estava na mão saltando sem trajetória é lido como falha, e a pessoa tira o headset para conferir se travou.

Duas escolhas menores dentro da interpolação merecem leitura. A orientação é interpolada por quatérnio, e não por ângulo de Euler — interpolar Euler produz o caminho torto pelo qual a peça dá uma volta a mais para chegar a um destino que estava logo ali, e o defeito só aparece em alguns pares de orientações. E o último passo é atribuído, não interpolado: terminar a um décimo de milésimo de milímetro do alvo deixa a peça quase no lugar, e “quase” acumula ao longo de cinco encaixes até virar um mecanismo torto.

1.5.3 A folga desenhada

O ambiente exibe, sob comando, um cubo verde em cada encaixe, do tamanho exato da folga linear.

O cubo não é enfeite e não é depuração. Ele é a tolerância, desenhada. A discussão sobre qual número adotar muda de qualidade quando a folga deixa de ser uma constante no arquivo e passa a ser uma caixa que se vê ao lado da peça.

O contorno é feito de segmentos de linha, e essa escolha tem consequência. A camada de seleção descarta segmentos ao lançar o raio desde o módulo anterior, então exibir os encaixes não passa a capturar a mira. Fosse uma malha translúcida, ligar as folgas faria o realce piscar — e o defeito apareceria em silêncio, três módulos depois, para quem tivesse esquecido de ligar a exibição durante o teste.

1.6 Tarefa 3: Validar a ordem e explicar a recusa

Recusar sem dizer por quê custa caro justamente por não parecer defeito.

Enunciado da tarefa

A tarefa do domínio passa a ter estado: alguns passos só fazem sentido depois de outros, e tentar o passo errado precisa produzir uma resposta compreensível.

O que fica pronto é a máquina de estados do objeto manipulável e a recusa explicada quando o passo está fora de sequência. Recusar sem dizer por quê é o defeito de interface mais comum em ambiente tridimensional, e ele custa caro justamente por parecer um travamento.

src/bancada/assembly/montagem.ts
// ---------------------------------------------------------------------------
// A tarefa com estado, e a recusa que diz por quê.
//
// Até aqui a Bancada tratava as cinco peças como iguais: qualquer uma podia ser
// apanhada, qualquer uma podia ser solta em qualquer lugar. A partir deste
// arquivo a tarefa tem ordem — a tampa fecha o mecanismo e não faz sentido antes
// das engrenagens; a engrenagem se prende ao eixo, e o eixo ao corpo.
//
// A ordem NÃO é uma fila. Escrevê-la como lista linear seria mais curto e mentiria
// sobre a tarefa: as duas engrenagens não dependem uma da outra, e obrigar a
// grande antes da pequena inventa uma exigência que a oficina não tem. O que
// existe é uma relação de precedência entre pares, e cada peça declara de quem
// depende.
//
// O assunto do arquivo, porém, não é a ordem: é a recusa. Recusar sem dizer por
// quê é o defeito de interface mais comum em ambiente tridimensional, e ele custa
// caro justamente por não parecer defeito — a peça volta ao lugar, nada é escrito
// em parte alguma, e quem usa conclui que o ambiente travou. Por isso toda recusa
// daqui nomeia a peça que falta, e não apenas informa que houve recusa.
//
// Há duas maneiras de uma soltura não encaixar, e elas precisam soar diferentes.
// Fora da folga é distância: a peça foi solta longe demais, e a resposta é um
// número em centímetros. Fora de ordem é dependência: a peça estava no lugar
// certo e a vez não era dela. Dizer a mesma frase nos dois casos faz quem monta
// procurar defeito na tolerância quando o problema era a sequência.
// ---------------------------------------------------------------------------

import type { Object3D } from 'three';

import { BANCADA, type Peca, type PecaId, type SocketId } from '../dominio/dominio';
import type { Encaixes } from './encaixe';
import { socketMaisProximo, type CandidatoAEncaixe, type Socket } from './sockets';

/**
 * Em que ponto do ciclo cada peça está.
 *
 * O ciclo completo é livre → agarrada → encaixando → encaixada, e o caminho de
 * volta existe: uma peça encaixada pode ser apanhada de novo, desde que nada que
 * dependa dela já esteja por cima. Máquina de estados sem caminho de volta obriga
 * quem errou a recarregar a página, e recarregar a página é o modo mais caro de
 * desfazer um passo.
 */
export type EstadoDaMontagem = 'livre' | 'agarrada' | 'encaixando' | 'encaixada';

/** Uma recusa, com a razão em texto e as peças que a resolveriam. */
export interface Recusa {
  readonly motivo: string;
  readonly faltam: readonly PecaId[];
}

/**
 * De quem cada peça depende para poder ser encaixada.
 *
 * As duas engrenagens dependem do eixo e não dependem uma da outra: a ordem entre
 * elas é livre, e essa liberdade é parte da tarefa, não descuido da tabela.
 */
export const PRE_REQUISITOS: Readonly<Record<PecaId, readonly PecaId[]>> = {
  corpo: [],
  eixo: ['corpo'],
  'engrenagem-grande': ['eixo'],
  'engrenagem-pequena': ['eixo'],
  tampa: ['engrenagem-grande', 'engrenagem-pequena'],
};

function nomeDa(id: PecaId): string {
  const peca: Peca | undefined = BANCADA.pecas.find((candidata) => candidata.id === id);
  return peca === undefined ? id : peca.nome.toLowerCase();
}

function listar(ids: readonly PecaId[]): string {
  const nomes: string[] = ids.map(nomeDa);
  if (nomes.length <= 1) {
    return nomes.join('');
  }
  return `${nomes.slice(0, -1).join(', ')} e ${nomes[nomes.length - 1] ?? ''}`;
}

export interface Montagem {
  /** Se a peça pode ser apanhada agora. Devolve a recusa explicada quando não pode. */
  podeAgarrar(id: PecaId): Recusa | undefined;
  /** Avisa que a peça saiu do lugar. Desencaixa, se ela estava encaixada. */
  aoAgarrar(id: PecaId): void;
  /** Avisa que a peça foi solta ali. É aqui que a tolerância e a ordem são cobradas. */
  aoSoltar(id: PecaId, no: Object3D): void;
  avancar(delta: number): void;
  estadoDe(id: PecaId): EstadoDaMontagem;
  encaixadas(): readonly PecaId[];
  concluida(): boolean;
  /** A última resposta dada a quem soltou uma peça. Nunca vazia depois da primeira soltura. */
  ultimoParecer(): string | undefined;
  aoResponder(ouvinte: (parecer: string) => void): void;
  linhas(): string[];
}

export function montarMontagem(
  sockets: ReadonlyMap<SocketId, Socket>,
  encaixes: Encaixes,
): Montagem {
  const encaixada: Map<PecaId, SocketId> = new Map<PecaId, SocketId>();
  const emCurso: Set<PecaId> = new Set<PecaId>();
  const agarradas: Set<PecaId> = new Set<PecaId>();
  const ouvintes: Array<(parecer: string) => void> = [];

  let parecer: string | undefined = undefined;
  let recusas: number = 0;

  encaixes.aoConcluir((peca: PecaId, socket: SocketId) => {
    emCurso.delete(peca);
    encaixada.set(peca, socket);
  });

  function responder(texto: string): void {
    parecer = texto;
    for (const ouvinte of ouvintes) {
      ouvinte(texto);
    }
  }

  /** Quem depende desta peça e já está encaixado por cima dela. */
  function dependentesEncaixados(id: PecaId): PecaId[] {
    const presos: PecaId[] = [];
    for (const peca of BANCADA.pecas) {
      const requisitos: readonly PecaId[] = PRE_REQUISITOS[peca.id];
      if (requisitos.includes(id) && encaixada.has(peca.id)) {
        presos.push(peca.id);
      }
    }
    return presos;
  }

  function faltantes(id: PecaId): PecaId[] {
    return PRE_REQUISITOS[id].filter((requisito) => !encaixada.has(requisito));
  }

  function estadoAtual(id: PecaId): EstadoDaMontagem {
    if (encaixada.has(id)) {
      return 'encaixada';
    }
    if (emCurso.has(id)) {
      return 'encaixando';
    }
    if (agarradas.has(id)) {
      return 'agarrada';
    }
    return 'livre';
  }

  return {
    podeAgarrar(id: PecaId): Recusa | undefined {
      if (emCurso.has(id)) {
        return {
          motivo: `A ${nomeDa(id)} está a caminho do encaixe. Espere ela chegar.`,
          faltam: [],
        };
      }
      const presos: PecaId[] = dependentesEncaixados(id);
      if (presos.length > 0) {
        return {
          // Desmontar tem ordem inversa, e dizer isso é o que evita a conclusão
          // errada — a de que a peça encaixada ficou presa para sempre.
          motivo:
            `A ${nomeDa(id)} não sai enquanto ${listar(presos)} estiver por cima. ` +
            'Retire primeiro o que foi montado depois.',
          faltam: presos,
        };
      }
      return undefined;
    },

    aoAgarrar(id: PecaId): void {
      agarradas.add(id);
      if (encaixada.delete(id)) {
        responder(`A ${nomeDa(id)} foi retirada do encaixe.`);
      }
    },

    aoSoltar(id: PecaId, no: Object3D): void {
      agarradas.delete(id);

      const candidato: CandidatoAEncaixe | undefined = socketMaisProximo(sockets, id, no);
      if (candidato === undefined) {
        responder(`A ${nomeDa(id)} não tem encaixe compatível no suporte.`);
        return;
      }

      // Primeiro a distância, depois a ordem. A inversão produziria a mensagem de
      // sequência para quem soltou a peça no meio da bancada, a meio metro de
      // qualquer encaixe — e essa pessoa não estava tentando montar nada.
      if (!candidato.avaliacao.dentro) {
        const distancia: string = `${(candidato.avaliacao.distanciaM * 100).toFixed(1)} cm`;
        const desvio: string = `${candidato.avaliacao.desvioGraus.toFixed(0)}°`;
        const causa: string = candidato.avaliacao.dentroDoLinear
          ? `a orientação está ${desvio} fora do encaixe, e a folga é de ` +
            `${candidato.socket.toleranciaAngularGraus}°`
          : `faltaram ${distancia} até o encaixe, e a folga é de ` +
            `${(candidato.socket.toleranciaLinearM * 100).toFixed(0)} cm`;
        responder(`A ${nomeDa(id)} ficou onde foi solta: ${causa}.`);
        return;
      }

      const faltam: PecaId[] = faltantes(id);
      if (faltam.length > 0) {
        recusas += 1;
        responder(
          `A ${nomeDa(id)} estava no lugar certo, e a vez não era dela: ` +
            `${listar(faltam)} ${faltam.length === 1 ? 'precisa' : 'precisam'} entrar antes.`,
        );
        return;
      }

      emCurso.add(id);
      encaixes.iniciar(id, no, candidato.socket);
      responder(
        `A ${nomeDa(id)} entrou em "${candidato.socket.id}", solta a ` +
          `${(candidato.avaliacao.distanciaM * 100).toFixed(1)} cm do alvo.`,
      );
    },

    avancar(delta: number): void {
      encaixes.avancar(delta);
    },

    estadoDe(id: PecaId): EstadoDaMontagem {
      return estadoAtual(id);
    },

    encaixadas(): readonly PecaId[] {
      return [...encaixada.keys()];
    },

    concluida(): boolean {
      return encaixada.size === BANCADA.pecas.length;
    },

    ultimoParecer(): string | undefined {
      return parecer;
    },

    aoResponder(ouvinte: (parecer: string) => void): void {
      ouvintes.push(ouvinte);
    },

    linhas(): string[] {
      const linhas: string[] = [];
      for (const peca of BANCADA.pecas) {
        const estado: EstadoDaMontagem = estadoAtual(peca.id);
        const faltam: PecaId[] = faltantes(peca.id);
        const espera: string =
          estado === 'livre' && faltam.length > 0 ? ` — espera por ${listar(faltam)}` : '';
        linhas.push(`${peca.nome}: ${estado}${espera}.`);
      }
      linhas.push(
        `Encaixadas: ${encaixada.size} de ${BANCADA.pecas.length}. ` +
          `Recusas por ordem nesta sessão: ${recusas}.`,
      );
      if (parecer !== undefined) {
        linhas.push(`Última resposta a quem soltou uma peça: ${parecer}`);
      }
      return linhas;
    },
  };
}

A ordem não é uma fila, e escrevê-la como lista linear seria mais curto. Seria também uma mentira sobre a tarefa: as duas engrenagens não dependem uma da outra, e obrigar a grande antes da pequena inventa uma exigência que a oficina não tem.

O que existe é precedência entre pares. Cada peça declara de quem depende, e a tabela lê como o mecanismo se monta — o eixo depende do corpo, as duas engrenagens dependem do eixo, a tampa depende das duas. A liberdade entre as engrenagens é parte da tarefa, não descuido da tabela.

1.6.1 Duas maneiras de não encaixar, e por que precisam soar diferentes

Uma soltura pode não encaixar por duas razões, e confundi-las é o defeito que este arquivo existe para impedir.

Fora da folga é distância: a peça foi solta longe demais, e a resposta correta é um número em centímetros. Fora de ordem é dependência: a peça estava no lugar certo e a vez não era dela. Dizer a mesma frase nos dois casos faz quem monta procurar defeito na tolerância quando o problema era a sequência.

A ordem das duas verificações também importa, e ela está escrita no arquivo com o porquê. A distância é conferida primeiro. A inversão produziria a mensagem de sequência para quem soltou a peça no meio da bancada, a meio metro de qualquer encaixe — e essa pessoa não estava tentando montar nada.

Toda recusa daqui nomeia a peça que falta. É o que separa uma tarefa que se aprende jogando de uma que exige alguém do lado explicando. A frase “a tampa estava no lugar certo, e a vez não era dela: engrenagem grande precisa entrar antes” resolve a dúvida no instante em que ela nasce.

1.6.2 O caminho de volta

Uma peça encaixada pode ser apanhada de novo. Isso não é enfeite, e a ausência disso tem um custo específico.

Máquina de estados sem caminho de volta obriga quem errou a recarregar a página. Recarregar a página é o modo mais caro de desfazer um passo, e num visor é pior ainda — a pessoa perde a sessão inteira porque encaixou a engrenagem antes do eixo.

A regra de saída é a simétrica da regra de entrada: a peça sai enquanto nada que dependa dela estiver por cima. A engrenagem grande não sai com a tampa fechada. E a recusa, de novo, diz o que fazer: retire primeiro o que foi montado depois.

Repare que essa simetria fecha a máquina de estados sem uma linha nova de estrutura. A mesma tabela de precedência responde às duas perguntas, lida em um sentido e no outro. Se as duas leituras precisassem de tabelas diferentes, a segunda envelheceria — e envelheceria em silêncio, porque desmontar é o que ninguém testa.

1.7 O que este módulo deixa pronto

A Bancada virou uma tarefa. Ainda não virou um lugar.

O ambiente deixou de ser algo que se olha. A peça é apanhada, girada, levada e solta; solta perto do encaixe compatível, ela desliza até ele e trava; solta fora de ordem, ouve por quê. As cinco peças montam o mecanismo, e o mecanismo montado gira — que era o estado final declarado na delimitação do domínio, no primeiro módulo do percurso.

Vale registrar o que não entrou. Não há física: peça largada no ar fica no ar, e isso é decisão, não pendência. Não há colisão entre peças durante o transporte — a engrenagem atravessa o suporte a caminho do encaixe, e ninguém no percurso vai escrever o resolvedor que impediria isso.

E continua havendo um único plugue ligado na tomada. O cursor é a única fonte, e o agarre por alternância de clique é o remendo que ele exige. A camada de manipulação não sabe disso, e a próxima parte do percurso é a que cobra a aposta: quando o controle rastreado se registrar, a pose passa a estar preenchida, o portador vira a mão de verdade, e nem a manipulação nem a montagem mudam uma linha.

É lá também que o giro por tecla deixa de ser necessário, sem ser removido. Ele fica na composição, útil no regime em janela e irrelevante no imersivo — que é a forma correta de um remendo envelhecer.