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.