1 A camada de interação: ponteiro e seleção — Projeto do Professor
Este é o projeto de referência resolvido pelo professor: a implementação que serve de modelo do que cada grupo deve produzir no Projeto Integrador. Ele existe para ser estudado, discutido e criticado — não para ser copiado. O que está aqui é uma solução defensável, com as decisões explicadas uma a uma; a solução de cada grupo será outra, e terá de ser defendida do mesmo jeito.
1.1 Visão geral
Mouse, controle e dedo são plugues diferentes na mesma tomada — a lógica de montagem só enxerga a tomada.
Até aqui a Bancada era um ambiente que se olhava. A cena estava montada, o conteúdo estava medido, a câmera girava em volta, e nenhum objeto respondia a nada. Este módulo é onde isso muda, e ele muda menos do que parece na tela e mais do que parece no arquivo.
O que a tela ganha é modesto: a peça sob a mira acende, e um clique escolhe uma delas. O que o projeto ganha é a tomada. A partir daqui existe uma abstração de apontar, e a lógica que decide o que foi alcançado consome só ela.
Entram três arquivos novos e um quarto que não desenha nada. Dois em uma pasta de entrada — a abstração e o primeiro plugue —, dois em uma pasta de interação, que é onde a seleção e o realce moram. O quarto registra a fronteira das dependências como dado, e a razão de ele existir é o assunto da próxima seção.
Nenhum arquivo anterior é reescrito. A composição ganha três linhas, e o laço do quadro ganha uma chamada. É pouco código, e é o ponto mais caro do período.
1.2 A pergunta que decide a arquitetura
Uma dependência resolveria este módulo em quinze minutos, e o preço venceria três módulos adiante.
Há, nesta plataforma, bibliotecas que entregam apontamento e agarre prontos. Elas funcionam, são mantidas por gente competente e tratam casos de borda que nós não vamos tratar. Por que não usar uma?
Porque o que elas entregam pronto é exatamente o trecho em que os três regimes se encontram. Instalar a dependência resolve a seleção de hoje e apaga a tomada: o projeto passa a ter o formato do exemplo dela, e o encontro entre cursor, controle e dedo acontece dentro de código que ninguém do grupo escreveu nem leu.
O critério que este projeto adota cabe em uma frase, e ele vale para o percurso inteiro. O que desenha triângulos e lê arquivos vem de biblioteca; o que decide o que a pessoa está fazendo é escrito no projeto.
src/bancada/input/fronteira.ts
// ---------------------------------------------------------------------------
// A fronteira das dependências: o que vem pronto e o que se escreve aqui.
//
// O critério cabe em uma frase e vale para o percurso inteiro: o que desenha
// triângulos e lê arquivos vem de biblioteca; o que decide o que a pessoa está
// fazendo é escrito no projeto. Ele não é preferência por escrever tudo — a
// simplificação de malha do módulo de ativos foi entregue à ferramenta, e por
// razão declarada.
//
// A decisão está aqui como dado, e não como comentário solto, porque ela é a que
// mais se perde. Uma dependência que entrega apontamento e agarre prontos resolve
// este módulo em quinze minutos e apaga exatamente o trecho em que os três
// regimes se encontram — e a perda não aparece hoje: aparece no módulo do ciclo
// de sessão, quando o controle rastreado chega e não há tomada nenhuma onde
// ligá-lo.
// ---------------------------------------------------------------------------
/** De onde a peça vem: pronta de uma biblioteca, ou escrita dentro do projeto. */
export type Origem = 'biblioteca' | 'projeto';
export interface ItemDaFronteira {
readonly assunto: string;
readonly origem: Origem;
readonly razao: string;
/** O que se perde ao decidir o contrário. Sem esta coluna, a regra vira gosto. */
readonly custoDeErrar: string;
}
export const FRONTEIRA: readonly ItemDaFronteira[] = [
{
assunto: 'desenhar triângulos, compilar material, montar o grafo de cena',
origem: 'biblioteca',
razao: 'é infraestrutura de renderização, não conteúdo da ementa',
custoDeErrar: 'o período inteiro gasto reescrevendo o que já funciona em toda máquina',
},
{
assunto: 'ler o arquivo de troca e reconstruir a malha que veio nele',
origem: 'biblioteca',
razao: 'o formato é uma especificação pública e extensa, e interpretá-la não é habilidade cobrada',
custoDeErrar: 'um leitor incompleto que aceita os arquivos de teste e recusa os de verdade',
},
// recorte:inicio fronteira-o-criterio
{
assunto: 'a interseção do raio com a malha',
origem: 'biblioteca',
razao: 'é geometria computacional resolvida, e a estrutura de aceleração vem junto',
custoDeErrar: 'interseção correta e lenta, que só cobra no visor e sob carga',
},
{
assunto: 'a abstração de apontar e as implementações que a alimentam',
origem: 'projeto',
razao: 'é o encontro dos três regimes, e é a economia que o percurso inteiro constrói',
custoDeErrar: 'três programas paralelos disfarçados de um só, descobertos tarde demais',
},
{
assunto: 'decidir o que foi alcançado, o que foi apontado e o que foi selecionado',
origem: 'projeto',
razao: 'é a lógica da tarefa da oficina, e nenhuma biblioteca conhece a tarefa',
custoDeErrar: 'a interação passa a ter o formato do exemplo da dependência, e não o da montagem',
},
// recorte:fim fronteira-o-criterio
{
assunto: 'a tolerância do encaixe, a ordem dos passos e a recusa explicada',
origem: 'projeto',
razao:
'os dois números e a precedência entre as peças são a tarefa da oficina, e a ' +
'frase que a recusa devolve é conteúdo, não mensagem de erro',
custoDeErrar:
'a folga vira o padrão de quem escreveu a dependência, e a recusa sai muda — ' +
'que é o que quem monta lê como travamento',
},
{
assunto: 'a câmera em órbita do regime em janela',
origem: 'projeto',
razao: 'a conta é de três linhas e está na ementa; a decisão está declarada no arquivo dela',
custoDeErrar: 'o comportamento entregue sem a conta entregue junto',
},
];
/** Uma linha por item, com a origem e o que a escolha contrária custaria. */
export function linhasDaFronteira(): string[] {
return FRONTEIRA.map(
(item) =>
`${item.assunto} — ${item.origem}: ${item.razao}. ` +
`Ao contrário: ${item.custoDeErrar}.`,
);
}1.2.1 Não é preferência por escrever tudo
Repare na terceira linha da tabela: a interseção do raio com a malha vem de biblioteca, e sem hesitação. É geometria computacional resolvida, e a estrutura de aceleração que a acompanha é o que mantém a conta barata quando a cena cresce.
O contraste com o módulo de ativos é o mesmo. Lá a simplificação de malha foi entregue à ferramenta, de propósito, porque reimplementar colapso de arestas consumiria o período e não está na ementa. O critério é o que a ementa cobra e o que o percurso está construindo.
Já a linha seguinte — decidir o que foi alcançado, apontado e escolhido — é lógica da tarefa da oficina. Biblioteca nenhuma conhece a tarefa, e a que fingir conhecer vai impor a dela.
1.2.2 Por que a decisão sai na página
A fronteira está escrita como dado tipado e é exibida no ambiente, ao lado da lista de limites do regime em janela. É a mesma decisão daquele módulo, tomada pelo mesmo motivo.
Decisão de projeto guardada em documento à parte envelhece bem demais: continua parecendo verdadeira anos depois de ter deixado de ser. E não alcança quem está avaliando o resultado, que é justamente quem precisa saber o que veio pronto.
Na tutoria, a coluna que rende discussão é a última — o que se perde ao decidir o contrário. Um grupo que não consegue preencher essa coluna para um item ainda não decidiu nada sobre ele: herdou a decisão do exemplo mais próximo.
1.3 Tarefa 1: Escrever a abstração de apontar
Quatro campos, e a decisão inteira do módulo está em qual deles é opcional.
Enunciado da tarefa
Este é o ponto de virada do projeto, e ele cabe numa frase: nenhuma linha da lógica de manipulação pode saber se quem aponta é um cursor, um controle rastreado ou um dedo na tela. A abstração carrega origem, direção, acionamento e estado, mais a pose quando o aparelho a fornece.
Ela precisa ser escrita dentro do projeto. Uma dependência que já entrega agarre e apontamento prontos apaga exatamente o trecho em que os três regimes se encontram, e o ambiente passa a ter três caminhos paralelos disfarçados de um só.
src/bancada/input/ponteiro.ts
// ---------------------------------------------------------------------------
// A abstração de apontar: o ponto de virada do projeto.
//
// Mouse, controle e dedo são plugues diferentes na mesma tomada — a lógica de
// montagem só enxerga a tomada. Este arquivo é a tomada, e ele existe para que
// nenhuma linha de seleção, agarre ou encaixe precise saber qual dos três está
// ligado do outro lado.
//
// A fronteira foi traçada onde os três regimes têm o que dizer em comum: um raio
// no espaço do mundo, um acionamento, um agarre e um estado. A pose de seis graus
// de liberdade fica de fora dessa lista comum e entra como campo opcional, porque
// ela existe no controle rastreado e não existe no cursor. Fingir que existe nos
// três produziria uma pose inventada para o mouse, e a mentira só apareceria no
// módulo em que a mão fecha sobre a peça a quarenta centímetros do rosto.
//
// O acionamento merece um parágrafo, porque é onde o desenho quase escorrega. No
// controle rastreado o gatilho é estado mantido: fica verdadeiro enquanto o dedo
// aperta. No cursor não dá para saber, no instante em que o botão desce, se
// aquilo vai virar clique ou arrasto de câmera — só a soltura responde. O campo
// continua sendo um booleano de estado, e o cursor o entrega como pulso de uma
// leitura; quem consome lê a borda de subida, e as duas formas cabem no mesmo
// campo sem que a camada de interação saiba da diferença.
// ---------------------------------------------------------------------------
import { Quaternion, Vector3 } from 'three';
/** O regime de entrada que uma fonte atende. Serve ao relatório, não à lógica. */
export type RegimeDeEntrada = 'janela' | 'imersivo' | 'aumentado';
/**
* Em que situação o apontamento está.
*
* `inativo` é o ponteiro que existe mas não aponta para dentro da cena — o cursor
* fora da superfície de desenho, o controle que o aparelho parou de rastrear.
* Distinguir esse caso de `apontando` é o que impede o realce de ficar aceso em
* cima de uma peça depois que o cursor saiu da tela.
*/
export type EstadoDoPonteiro = 'inativo' | 'apontando' | 'acionado';
/** Posição e orientação da mão ou do controle, quando o aparelho as fornece. */
export interface PoseDoPonteiro {
readonly posicao: Vector3;
readonly orientacao: Quaternion;
}
// recorte:inicio ponteiro-a-tomada
/**
* Uma leitura de apontamento, no espaço do mundo.
*
* Os vetores são cópias, e não referências ao estado interno da fonte. Devolver a
* referência economizaria alocação e criaria o defeito mais difícil de enxergar
* desta camada: a leitura de um quadro mudando sozinha durante o quadro seguinte.
*/
export interface Ponteiro {
/** Identificação estável da fonte, para o relatório e para depuração. */
readonly id: string;
readonly regime: RegimeDeEntrada;
readonly origem: Vector3;
/** Unitário, no espaço do mundo. */
readonly direcao: Vector3;
/** Gatilho, botão ou toque. Estado no controle; pulso de uma leitura no cursor. */
readonly acionado: boolean;
/** Agarre, onde o aparelho o separa do acionamento. Falso onde não existe. */
readonly agarre: boolean;
readonly estado: EstadoDoPonteiro;
/** Ausente onde o aparelho não rastreia posição e orientação da mão. */
readonly pose: PoseDoPonteiro | undefined;
}
// recorte:fim ponteiro-a-tomada
/**
* O contrato que cada implementação de entrada cumpre.
*
* A leitura é feita uma vez por quadro e devolve os ponteiros ativos daquela
* fonte — plural porque o visor tem dois controles e a tela tem dois dedos. A
* camada de interação recebe a lista já unificada e não pergunta de onde veio
* cada item.
*/
export interface FonteDePonteiro {
readonly nome: string;
readonly regime: RegimeDeEntrada;
ler(): readonly Ponteiro[];
/** Solta ouvintes e recursos. Trocar de regime sem isto deixa a fonte antiga viva. */
encerrar(): void;
}
/**
* O que cada campo do ponteiro carrega, quem o preenche e o que acontece onde
* ele falta.
*
* A lista é dado tipado pela mesma razão que os limites do regime em janela são:
* o que precisa ser confrontado depois não sobrevive como parágrafo. Quando o
* controle rastreado chegar, é esta tabela que diz o que passa a estar preenchido
* e o que continua vazio — e a diferença fica visível na página, e não na memória
* de quem escreveu.
*/
export interface CampoDoPonteiro {
readonly campo: string;
readonly carrega: string;
readonly preenchidoPor: string;
readonly ausenciaSignifica: string;
}
export const CAMPOS_DO_PONTEIRO: readonly CampoDoPonteiro[] = [
{
campo: 'origem e direção',
carrega: 'o raio no espaço do mundo, de onde parte e para onde vai',
preenchidoPor: 'cursor, controle rastreado e toque',
ausenciaSignifica: 'não há apontamento nenhum; é o único par obrigatório',
},
{
campo: 'acionado',
carrega: 'gatilho, botão ou toque — a intenção de agir sobre o que está sob a mira',
preenchidoPor: 'cursor, controle rastreado e toque',
ausenciaSignifica: 'a mira existe e ninguém pediu nada',
},
{
campo: 'agarre',
carrega: 'a pega, onde o aparelho a separa do acionamento',
preenchidoPor:
'controle rastreado, pelo gatilho mantido; cursor, derivando-a do clique, ' +
'que alterna a pega em vez de mantê-la; toque, pelo dedo que permanece na tela',
ausenciaSignifica: 'o regime terá de derivar a pega do próprio acionamento',
},
// recorte:inicio ponteiro-campo-pose
{
campo: 'pose',
carrega: 'posição e orientação da mão, seis graus de liberdade',
preenchidoPor: 'somente o controle rastreado',
ausenciaSignifica: 'não há mão a que prender a peça; o regime move por raio e plano',
},
// recorte:fim ponteiro-campo-pose
];
/**
* O conjunto de fontes ativas, lido como uma lista só.
*
* É aqui que a economia do projeto se materializa: a camada de interação recebe
* uma lista de ponteiros e não tem como perguntar quantas fontes existem nem
* quais são. Trocar o cursor pelo controle é registrar outra fonte, e nada mais.
*/
export interface Entrada {
registrar(fonte: FonteDePonteiro): void;
/**
* Passa a ler somente as fontes deste regime.
*
* Acrescentado no módulo do ciclo de sessão, e a razão é concreta: com a
* sessão aberta, o cursor continua achando que está sobre a superfície de
* desenho, porque nada lhe disse o contrário — a última posição conhecida dele
* é de antes de a pessoa pôr o visor. Sem esta linha, o ponteiro do mouse
* disputa a mira com o controle rastreado dentro do visor, e quem perde é
* quem está lá dentro.
*
* A escolha do lugar é a de sempre. A tomada aprende qual plugue está vivo; a
* lógica de montagem continua sem ter como perguntar quantos existem.
*/
emRegime(regime: RegimeDeEntrada): void;
/**
* Passa a ler as fontes de mais de um regime ao mesmo tempo.
*
* Acrescentado no módulo aumentado, e o caso que o obrigou é concreto: a
* sessão sobre o mundo físico é aberta tanto pelo aparelho de mão, cuja
* entrada é o dedo, quanto pelo visor com passagem de vídeo, cuja entrada
* continua sendo o controle rastreado. O regime da SESSÃO é um só; as fontes
* que ele admite são duas.
*
* A alternativa seria a composição escolher, por aparelho, qual dos dois
* regimes ativar — e aí a pergunta "este aparelho tem controle?" passaria a
* ser feita acima da tomada, que é exatamente o conhecimento que ela existe
* para conter.
*/
emRegimes(regimes: readonly RegimeDeEntrada[]): void;
regimesAtivos(): readonly RegimeDeEntrada[];
ler(): readonly Ponteiro[];
/** As fontes registradas, em texto, para o relatório. */
descrever(): string[];
encerrar(): void;
}
export function montarEntrada(): Entrada {
const fontes: FonteDePonteiro[] = [];
// O regime em janela é o estado inicial porque ele é o caso base do projeto:
// a bancada sobe sem sessão alguma, e é assim que a maior parte da turma a
// usa na maior parte do tempo.
//
// O estado é uma lista, e não um valor, desde o módulo aumentado: a sessão
// sobre o mundo físico admite duas famílias de apontamento ao mesmo tempo. Na
// esmagadora maioria dos quadros ela tem um elemento só.
let regimes: readonly RegimeDeEntrada[] = ['janela'];
return {
registrar(fonte: FonteDePonteiro): void {
fontes.push(fonte);
},
emRegime(novo: RegimeDeEntrada): void {
regimes = [novo];
},
emRegimes(novos: readonly RegimeDeEntrada[]): void {
regimes = [...novos];
},
regimesAtivos(): readonly RegimeDeEntrada[] {
return regimes;
},
ler(): readonly Ponteiro[] {
const leitura: Ponteiro[] = [];
for (const fonte of fontes) {
if (!regimes.includes(fonte.regime)) {
continue;
}
leitura.push(...fonte.ler());
}
return leitura;
},
descrever(): string[] {
if (fontes.length === 0) {
return ['Nenhuma fonte de apontamento registrada.'];
}
return fontes.map(
(fonte) =>
`${fonte.nome} — regime ${fonte.regime}` +
(regimes.includes(fonte.regime) ? ' (em uso agora)' : ' (registrada, em silêncio)'),
);
},
encerrar(): void {
for (const fonte of fontes) {
fonte.encerrar();
}
fontes.length = 0;
},
};
}
/** Uma linha por campo, com o que ele carrega e o que a ausência dele significa. */
export function linhasDoPonteiro(): string[] {
return CAMPOS_DO_PONTEIRO.map(
(campo) =>
`${campo.campo}: ${campo.carrega}. Preenchido por ${campo.preenchidoPor}. ` +
`Ausente, ${campo.ausenciaSignifica}.`,
);
}
/** Uma leitura em texto, para conferir a olho o que a fonte está entregando. */
export function descreverPonteiro(ponteiro: Ponteiro): string {
const pose: string = ponteiro.pose === undefined ? 'sem pose' : 'com pose de seis graus';
return (
`${ponteiro.id} (${ponteiro.regime}): ${ponteiro.estado}, ` +
`origem ${ponteiro.origem.x.toFixed(2)}, ${ponteiro.origem.y.toFixed(2)}, ` +
`${ponteiro.origem.z.toFixed(2)} m · direção ${ponteiro.direcao.x.toFixed(2)}, ` +
`${ponteiro.direcao.y.toFixed(2)}, ${ponteiro.direcao.z.toFixed(2)} · ${pose}`
);
}1.3.1 Onde a fronteira foi traçada, e por quê
A abstração carrega o que os três regimes têm a dizer em comum: um raio no espaço do mundo, um acionamento, um agarre e um estado. Origem e direção são o par obrigatório — sem eles não há apontamento nenhum.
A pose de seis graus de liberdade fica de fora dessa lista comum e entra como campo opcional. Ela existe no controle rastreado e não existe no cursor, e essa assimetria é fato do aparelho, não descuido de desenho.
A tentação é preencher a pose para os três, com um valor plausível derivado da câmera. O código compilaria e a mentira ficaria escondida até o módulo em que a mão fecha sobre a peça a quarenta centímetros do rosto. Ali a peça iria para o lugar errado, e a causa estaria três módulos atrás.
1.3.2 O acionamento, e o quase-escorregão
O gatilho do controle rastreado é estado mantido: fica verdadeiro enquanto o dedo aperta. O clique do cursor não tem essa propriedade, e a razão é a órbita.
No instante em que o botão desce, ninguém sabe se aquilo vai virar clique ou arrasto de câmera. Só a soltura responde. O primeiro desenho tentado aqui foi acrescentar um campo de cancelamento à abstração — e ele durou uma tarde, porque um campo que só o cursor preenche é a fronteira vazando para dentro da tomada.
A solução que ficou não mexe no tipo. O campo continua sendo um booleano de estado; o cursor o entrega como pulso de uma leitura, e quem consome lê a borda de subida. As duas formas cabem no mesmo campo, e a camada de interação não sabe da diferença.
1.3.3 A tabela de campos, e o que ela vai medir depois
Junto do tipo há uma lista tipada com o que cada campo carrega, quem o preenche e o que a ausência dele significa. É a mesma decisão de estrutura que a delimitação do domínio tomou no primeiro módulo, e que os limites do regime em janela repetiram no anterior.
O que precisa ser confrontado depois não sobrevive como parágrafo. Quando o controle rastreado chegar, é esta tabela que diz o que passou a estar preenchido e o que continua vazio, e a mudança aparece na página de quem abrir o endereço.
A quarta linha é a que mais vale ler em voz alta na tutoria. Sem pose, não há mão a que prender a peça, e o regime move por raio e plano. É o prazo de validade da tomada, escrito antes de alguém tropeçar nele.
1.3.4 Onde é fácil errar, e como conferir
O defeito mais comum não produz erro nenhum: os vetores devolvidos como referência ao estado interno da fonte.
Economiza alocação e cria o problema mais difícil de enxergar desta camada. A leitura de um quadro muda sozinha durante o quadro seguinte, e o sintoma é a seleção que às vezes pega a peça errada. Devolver cópias resolve, e o custo é irrelevante diante de tudo o mais que o quadro faz.
A conferência da tarefa é de leitura, e ela é impiedosa. Peça a alguém do grupo que descreva a lógica de seleção sem usar as palavras mouse, cursor, clique ou pixel. Quem não consegue ainda não abstraiu, e nenhum teste automático vai dizer isso.
1.4 Tarefa 2: Ligar o primeiro back-end e a seleção por raio
O teste de que a abstração funciona é de leitura: procure a palavra “cursor” no arquivo da seleção.
Enunciado da tarefa
O cursor é a primeira implementação a alimentar a abstração, porque roda em qualquer máquina e pode ser conferida a olho. A seleção lança um raio contra a cena e decide o que foi alcançado, consumindo apenas a abstração e nunca o evento bruto do aparelho.
O que fica pronto é a seleção funcionando com realce visível do que está prestes a ser apanhado. Sem esse retorno ninguém sabe o que vai pegar, e o defeito só aparece quando a mira deixa de ser o centro exato da tela.
src/bancada/input/cursor.ts
// ---------------------------------------------------------------------------
// O primeiro plugue: o cursor.
//
// Ele vem primeiro porque roda em qualquer máquina da turma e porque o resultado
// dele se confere a olho, sem rodízio de equipamento. O controle rastreado e o
// toque entram depois, e nenhum dos dois vai tocar na camada de interação — se
// tocar, a abstração falhou e o lugar de descobrir isso é aqui, não lá.
//
// Duas contas e um cuidado, e o cuidado é o que ninguém prevê.
//
// A primeira conta é a passagem do pixel para o raio. O navegador entrega a
// posição em pixels dentro do elemento; a placa trabalha em coordenadas
// normalizadas, de menos um a um, com o eixo vertical apontando para cima. A
// inversão do eixo vertical é o erro clássico: a mira funciona no meio da tela e
// erra cada vez mais para as bordas, o que se lê como imprecisão do apontamento e
// não como sinal trocado.
//
// A segunda é a projeção inversa. Um ponto na profundidade intermediária do
// volume de visão, levado de volta ao espaço do mundo, dá o alvo; a direção é
// dele até a posição da câmera. Não se usa a direção de vista da câmera, que é a
// mesma para todos os pixels — usá-la faria a mira ser sempre o centro da tela, e
// o defeito só apareceria quando alguém clicasse fora do centro.
//
// O cuidado é a convivência com a órbita. Os dois ouvem o mesmo elemento e o
// mesmo botão: um gesto que gira a câmera não pode selecionar peça nenhuma ao
// terminar. A regra é o deslocamento acumulado — abaixo do limiar o gesto era
// clique, acima era arrasto —, e é por isso que o acionamento do cursor só se
// conhece na soltura.
// ---------------------------------------------------------------------------
import { PerspectiveCamera, Vector3 } from 'three';
import type { FonteDePonteiro, Ponteiro } from './ponteiro';
/**
* Deslocamento, em pixels, a partir do qual o gesto deixa de ser clique.
*
* Doze pixels é folgado de propósito: no celular o dedo escorrega alguns pixels
* em todo toque, e um limiar apertado transforma seleção em loteria. No mouse
* ninguém alcança doze pixels sem querer arrastar.
*/
const LIMIAR_DE_ARRASTO_PX: number = 12;
/** Profundidade normalizada do ponto projetado de volta ao mundo. */
const PROFUNDIDADE_DE_PROJECAO: number = 0.5;
/**
* Instala a fonte de cursor sobre um elemento e uma câmera.
*
* A fonte não conhece a cena, não conhece peça alguma e não decide nada: ela
* traduz evento de navegador em raio de mundo. Todo o resto é da camada de
* interação, e essa separação é o assunto do módulo.
*/
export function montarFonteDeCursor(
camera: PerspectiveCamera,
elemento: HTMLElement,
): FonteDePonteiro {
let dentro: boolean = false;
let ndcX: number = 0;
let ndcY: number = 0;
let botaoDescido: boolean = false;
let virouArrasto: boolean = false;
let xDaDescida: number = 0;
let yDaDescida: number = 0;
// O pulso do clique. Ele nasce na soltura e é consumido pela primeira leitura
// seguinte: quem consome vê a borda de subida do acionamento, exatamente como
// veria no gatilho de um controle.
let cliquePendente: boolean = false;
// A pega do regime em janela, e o lugar em que ela precisa morar.
//
// O controle rastreado mantém o gatilho apertado enquanto a peça está na mão; o
// cursor não consegue, porque o botão está em disputa com a órbita e só a
// soltura diz se o gesto era clique ou arrasto. A saída é o clique alternar: o
// primeiro apanha, o segundo larga.
//
// A alternância fica AQUI, e não na camada de manipulação, e essa é a decisão
// que o módulo compra. É o cursor que sabe que o aparelho dele não sabe dizer
// "mantido"; escrever o caso especial lá em cima faria a lógica de montagem
// ganhar um ramo por aparelho, que é exatamente o que a abstração existe para
// impedir.
let agarreMantido: boolean = false;
const alvo: Vector3 = new Vector3();
// recorte:inicio cursor-pixel-para-ndc
function atualizarNdc(evento: PointerEvent): void {
const area: DOMRect = elemento.getBoundingClientRect();
ndcX = ((evento.clientX - area.left) / area.width) * 2 - 1;
// O eixo vertical do navegador cresce para baixo e o da placa cresce para
// cima. O sinal trocado aqui é o defeito que se disfarça de imprecisão.
ndcY = -(((evento.clientY - area.top) / area.height) * 2 - 1);
dentro = ndcX >= -1 && ndcX <= 1 && ndcY >= -1 && ndcY <= 1;
}
// recorte:fim cursor-pixel-para-ndc
function aoEntrar(evento: PointerEvent): void {
atualizarNdc(evento);
}
function aoSair(): void {
dentro = false;
}
function aoDescer(evento: PointerEvent): void {
atualizarNdc(evento);
botaoDescido = true;
virouArrasto = false;
xDaDescida = evento.clientX;
yDaDescida = evento.clientY;
}
// recorte:inicio cursor-limiar-de-arrasto
function aoMover(evento: PointerEvent): void {
atualizarNdc(evento);
if (!botaoDescido || virouArrasto) {
return;
}
const percorrido: number = Math.hypot(evento.clientX - xDaDescida, evento.clientY - yDaDescida);
if (percorrido > LIMIAR_DE_ARRASTO_PX) {
// A partir daqui o gesto pertence à órbita. A soltura não vai produzir
// clique, e é isso que impede girar a câmera de selecionar a peça que por
// acaso estava sob o cursor quando o botão foi solto.
virouArrasto = true;
}
}
// recorte:fim cursor-limiar-de-arrasto
function aoSubir(evento: PointerEvent): void {
atualizarNdc(evento);
if (botaoDescido && !virouArrasto) {
cliquePendente = true;
agarreMantido = !agarreMantido;
}
botaoDescido = false;
virouArrasto = false;
}
elemento.addEventListener('pointerenter', aoEntrar);
elemento.addEventListener('pointerleave', aoSair);
elemento.addEventListener('pointerdown', aoDescer);
elemento.addEventListener('pointermove', aoMover);
elemento.addEventListener('pointerup', aoSubir);
elemento.addEventListener('pointercancel', aoSubir);
return {
nome: 'cursor do computador de mesa',
regime: 'janela',
ler(): readonly Ponteiro[] {
if (!dentro) {
// Sem cursor sobre a superfície não há apontamento, e devolver a última
// posição conhecida deixaria o realce aceso depois que ninguém está mais
// olhando para a peça.
cliquePendente = false;
return [];
}
// recorte:inicio cursor-projecao-inversa
// A projeção inversa: o ponto normalizado, na profundidade intermediária,
// levado de volta ao espaço do mundo. É este ponto que faz a direção variar
// com o pixel, em vez de ser sempre a direção de vista da câmera.
alvo.set(ndcX, ndcY, PROFUNDIDADE_DE_PROJECAO).unproject(camera);
// recorte:fim cursor-projecao-inversa
const acionado: boolean = cliquePendente;
cliquePendente = false;
return [
{
id: 'cursor',
regime: 'janela',
origem: camera.position.clone(),
direcao: alvo.clone().sub(camera.position).normalize(),
acionado,
// O cursor não tem pega separada do clique, e por isso a deriva do
// próprio acionamento: o clique alterna a pega, que fica mantida até o
// clique seguinte. Quem consome lê um booleano de estado, igual ao que
// o gatilho do controle vai entregar, e não sabe da diferença.
agarre: agarreMantido,
estado: acionado ? 'acionado' : 'apontando',
// Sem pose. O mouse não tem posição no mundo nem orientação de mão, e
// inventar uma aqui seria a mentira que só apareceria no visor.
pose: undefined,
},
];
},
encerrar(): void {
elemento.removeEventListener('pointerenter', aoEntrar);
elemento.removeEventListener('pointerleave', aoSair);
elemento.removeEventListener('pointerdown', aoDescer);
elemento.removeEventListener('pointermove', aoMover);
elemento.removeEventListener('pointerup', aoSubir);
elemento.removeEventListener('pointercancel', aoSubir);
dentro = false;
cliquePendente = false;
agarreMantido = false;
},
};
}1.4.1 Do pixel ao raio, em duas contas
O navegador entrega a posição em pixels dentro do elemento. A placa trabalha em coordenadas normalizadas, de menos um a um, com o eixo vertical apontando para cima. A primeira conta é essa conversão, e ela tem uma armadilha de sinal.
O eixo vertical do navegador cresce para baixo. Esquecer a inversão produz uma mira que acerta no meio da tela e erra cada vez mais em direção às bordas, o que qualquer pessoa lê como imprecisão do apontamento. É o defeito que mais consome tempo de tutoria aqui, e ele é um sinal.
A segunda conta é a projeção inversa. Um ponto na profundidade intermediária do volume de visão, levado de volta ao espaço do mundo, dá o alvo; a direção vai da posição da câmera até ele.
Não se usa a direção de vista da câmera, e isso merece dito em voz alta. Ela é a mesma para todos os pixels, então a mira seria sempre o centro da tela. O ambiente funcionaria bem enquanto o objeto estivesse no meio, que é justamente onde ele está durante o teste.
1.4.2 O gesto disputado com a órbita
A órbita e o cursor ouvem o mesmo elemento e o mesmo botão. Girar a câmera não pode selecionar a peça que por acaso estava sob o cursor quando o botão foi solto.
A regra é o deslocamento acumulado desde a descida do botão. Abaixo do limiar, o gesto era clique; acima, era arrasto, e a soltura não produz acionamento nenhum. Doze pixels é folgado de propósito: no celular o dedo escorrega alguns pixels em todo toque, e limiar apertado transforma seleção em loteria.
Esta é a decisão que os grupos mais tentam resolver do jeito difícil, com bandeiras trocadas entre os dois módulos. Não é preciso: a órbita continua sem saber que a seleção existe, e a seleção continua sem saber da órbita.
1.4.3 A subida na árvore
src/bancada/interaction/selecao.ts
// ---------------------------------------------------------------------------
// A seleção por lançamento de raio.
//
// Este arquivo é o teste da abstração, e o teste é de leitura: procure aqui as
// palavras mouse, cursor, clique, tela ou pixel. Elas não existem. O que entra é
// uma lista de ponteiros, e o que sai é qual peça está sob a mira e qual foi
// escolhida. Quando o controle rastreado chegar, nada aqui muda — e é essa frase
// que o percurso inteiro está construindo desde o primeiro módulo.
//
// O problema técnico do arquivo não é lançar o raio: isso a biblioteca faz, e faz
// melhor. É decidir o que a interseção significa.
//
// O raio acerta um triângulo, e o triângulo pertence a um nó da árvore. Esse nó
// quase nunca é a peça: a bancada é uma hierarquia, e o que está sob a mira pode
// ser a cabeça de um parafuso instanciado, o contorno de um volume de contato ou
// o tampo. A conversão de "o que o raio acertou" para "a peça de que isso faz
// parte" é uma subida na árvore até encontrar um nó registrado como alvo, e é o
// conteúdo teórico do módulo.
//
// A subida também é o que resolve a oclusão sem uma linha dedicada. Lançar o raio
// contra a cena inteira, e não só contra as peças, faz o tampo interceptar o que
// está atrás dele: a interseção mais próxima é o tampo, o tampo não é peça, a
// subida não encontra alvo e a mira não alcança nada. Lançar só contra as peças
// atravessaria a madeira e apanharia a engrenagem do outro lado.
// ---------------------------------------------------------------------------
import { Object3D, Raycaster, Vector3, type Intersection } from 'three';
import type { PecaId } from '../dominio/dominio';
import type { Ponteiro } from '../input/ponteiro';
import type { Destaque } from './destaque';
/**
* Em que ponto do ciclo uma peça está.
*
* São só três, e continuam sendo três depois de o agarre chegar. Agarrada,
* encaixando e encaixada pertencem à camada de montagem, que é quem tem o que
* dizer sobre elas; a seleção sabe apenas o que a mira alcança e o que alguém
* escolheu. Duplicar a máquina inteira aqui criaria dois lugares onde o mesmo
* estado pode divergir, e o que diverge em silêncio é sempre o que ninguém lê.
*/
export type EstadoDaPeca = 'livre' | 'apontada' | 'selecionada';
/** Alcance do raio, em metros. A bancada inteira cabe com folga. */
const ALCANCE_M: number = 12;
export interface MudancaDeSelecao {
readonly apontada: PecaId | undefined;
readonly selecionada: PecaId | undefined;
/** Quantos nós a subida percorreu até chegar à peça. Zero quando o raio já a acertou. */
readonly degrausDeSubida: number;
/** O nome do nó que o raio de fato acertou. É o que a tutoria pede para ver. */
readonly noAtingido: string | undefined;
}
export interface Selecao {
/** Lê os ponteiros do quadro e atualiza mira e escolha. Chamada uma vez por passo. */
atualizar(ponteiros: readonly Ponteiro[]): void;
apontada(): PecaId | undefined;
selecionada(): PecaId | undefined;
estadoDe(id: PecaId): EstadoDaPeca;
aoMudar(ouvinte: (mudanca: MudancaDeSelecao) => void): void;
linhas(): string[];
}
/**
* Monta a seleção sobre a raiz da cena e um conjunto de alvos.
*
* Os alvos são a tradução entre a árvore e o domínio: nó da cena de um lado,
* identificador de peça do outro. Nada fora dessa tabela é selecionável, e é por
* isso que apontar para o cavalete não seleciona a bancada.
*/
export function montarSelecao(
raiz: Object3D,
alvos: ReadonlyMap<PecaId, Object3D>,
destaque: Destaque,
): Selecao {
const raio: Raycaster = new Raycaster();
raio.far = ALCANCE_M;
const porNo: Map<Object3D, PecaId> = new Map<Object3D, PecaId>();
for (const [id, no] of alvos) {
porNo.set(no, id);
destaque.registrar(id, no);
}
let apontada: PecaId | undefined = undefined;
let selecionada: PecaId | undefined = undefined;
let degraus: number = 0;
let noAtingido: string | undefined = undefined;
let acionadoAntes: boolean = false;
const ouvintes: Array<(mudanca: MudancaDeSelecao) => void> = [];
// recorte:inicio selecao-subida-na-arvore
/**
* Sobe a árvore a partir do nó atingido até encontrar uma peça registrada.
*
* O laço para na raiz, e não em número de níveis: a profundidade da hierarquia
* muda a cada módulo, e um limite escrito à mão envelheceria em silêncio — o
* sintoma seria a peça que deixa de responder depois de ser reparentada.
*/
function subirAtePeca(no: Object3D): { readonly id: PecaId | undefined; readonly degraus: number } {
let atual: Object3D | null = no;
let subidos: number = 0;
while (atual !== null) {
const id: PecaId | undefined = porNo.get(atual);
if (id !== undefined) {
return { id, degraus: subidos };
}
atual = atual.parent;
subidos += 1;
}
return { id: undefined, degraus: subidos };
}
// recorte:fim selecao-subida-na-arvore
// recorte:inicio selecao-raio-contra-a-cena
function primeiroAlcancado(ponteiro: Ponteiro): Intersection | undefined {
raio.set(ponteiro.origem, ponteiro.direcao);
const encontros: Intersection[] = raio.intersectObject(raiz, true);
for (const encontro of encontros) {
// O contorno dos volumes de contato é auxiliar de depuração e não é
// superfície: deixá-lo capturar o raio faria o realce piscar quando alguém
// ligasse a exibição das caixas.
if (!encontro.object.visible || encontro.object.type === 'LineSegments') {
continue;
}
return encontro;
}
return undefined;
}
// recorte:fim selecao-raio-contra-a-cena
function avisar(): void {
const mudanca: MudancaDeSelecao = {
apontada,
selecionada,
degrausDeSubida: degraus,
noAtingido,
};
for (const ouvinte of ouvintes) {
ouvinte(mudanca);
}
}
return {
atualizar(ponteiros: readonly Ponteiro[]): void {
// O primeiro ponteiro ativo manda. Com dois controles, a regra vira a do
// mais recentemente acionado, e é ali que ela é escrita — não aqui, onde
// ainda não há dois de nada para exercitá-la.
const ativo: Ponteiro | undefined = ponteiros.find(
(ponteiro) => ponteiro.estado !== 'inativo',
);
const antes: PecaId | undefined = apontada;
const escolhidaAntes: PecaId | undefined = selecionada;
if (ativo === undefined) {
apontada = undefined;
noAtingido = undefined;
degraus = 0;
acionadoAntes = false;
destaque.apontar(undefined);
if (antes !== undefined) {
avisar();
}
return;
}
const encontro: Intersection | undefined = primeiroAlcancado(ativo);
if (encontro === undefined) {
apontada = undefined;
noAtingido = undefined;
degraus = 0;
} else {
const subida = subirAtePeca(encontro.object);
apontada = subida.id;
degraus = subida.degraus;
noAtingido = encontro.object.name === '' ? encontro.object.type : encontro.object.name;
}
destaque.apontar(apontada);
// recorte:inicio selecao-borda-de-subida
// A borda de subida do acionamento, e não o estado dele. É isto que faz o
// pulso do cursor e o gatilho mantido do controle produzirem uma escolha
// cada, em vez de uma escolha por quadro enquanto o dedo aperta.
const bordaDeSubida: boolean = ativo.acionado && !acionadoAntes;
acionadoAntes = ativo.acionado;
if (bordaDeSubida) {
// Acionar no vazio desfaz a escolha. É o comportamento que quem usa
// espera e o que impede a peça de ficar acesa para sempre.
selecionada = apontada;
destaque.selecionar(selecionada);
}
// recorte:fim selecao-borda-de-subida
if (apontada !== antes || selecionada !== escolhidaAntes) {
avisar();
}
},
apontada(): PecaId | undefined {
return apontada;
},
selecionada(): PecaId | undefined {
return selecionada;
},
estadoDe(id: PecaId): EstadoDaPeca {
if (id === selecionada) {
return 'selecionada';
}
if (id === apontada) {
return 'apontada';
}
return 'livre';
},
aoMudar(ouvinte: (mudanca: MudancaDeSelecao) => void): void {
ouvintes.push(ouvinte);
},
linhas(): string[] {
const alvo: string = apontada ?? 'nada';
const escolha: string = selecionada ?? 'nada';
const caminho: string =
noAtingido === undefined
? 'O raio não alcançou superfície alguma.'
: `O raio acertou "${noAtingido}" e subiu ${degraus} ${
degraus === 1 ? 'nível' : 'níveis'
} até a peça.`;
return [
`Sob a mira: ${alvo}.`,
`Escolhida: ${escolha}.`,
caminho,
`Alvos registrados: ${porNo.size}. Alcance do raio: ${ALCANCE_M} m.`,
];
},
};
}
/** O ponto do mundo em que o raio de um ponteiro chega a uma distância dada. */
export function pontoNoRaio(ponteiro: Ponteiro, distancia: number): Vector3 {
return ponteiro.origem.clone().addScaledVector(ponteiro.direcao, distancia);
}Lançar o raio é a parte fácil, e é a que vem de biblioteca. O problema é decidir o que a interseção significa.
O raio acerta um triângulo, e o triângulo pertence a um nó da árvore. Esse nó quase nunca é a peça. A bancada é uma hierarquia: o que está sob a mira pode ser a cabeça de um parafuso instanciado, o contorno de um volume de contato ou o próprio tampo.
A conversão de “o que o raio acertou” para “a peça de que isso faz parte” é uma subida na árvore até encontrar um nó registrado como alvo. O laço para na raiz, e não em um número de níveis: a profundidade da hierarquia muda a cada módulo, e limite escrito à mão envelhece em silêncio — o sintoma seria a peça que deixa de responder depois de ser reparentada.
1.4.4 A oclusão que ninguém precisou escrever
Por que lançar o raio contra a cena inteira, se só as peças interessam?
Porque é isso que faz o tampo interceptar o que está atrás dele. A interseção mais próxima é o tampo, o tampo não é peça, a subida não encontra alvo, e a mira não alcança nada — que é exatamente o correto, porque há madeira no caminho.
Lançar só contra as peças seria mais rápido e atravessaria a bancada. A engrenagem do outro lado acenderia através de cinco centímetros de tampo, e ninguém consegue nomear esse defeito olhando para a tela: parece um problema de perspectiva.
O ambiente exibe, ao vivo, o nó que o raio acertou ao lado da peça a que ele pertence, com quantos níveis a subida percorreu. Essa distância entre os dois é o conteúdo do módulo, e vê-la mudar enquanto se move o cursor encerra a discussão mais rápido que qualquer explicação.
1.4.5 O realce, e a armadilha do material compartilhado
src/bancada/interaction/destaque.ts
// ---------------------------------------------------------------------------
// O realce: o retorno visual sem o qual ninguém sabe o que está prestes a pegar.
//
// Ele não é acabamento. Uma seleção que funciona e não se vê é indistinguível de
// uma seleção quebrada, e o defeito só se manifesta quando a mira deixa de ser o
// centro exato da tela — que é justamente quando a pessoa mais precisa saber o
// que a mira alcançou.
//
// São dois estados, e mantê-los distintos é o assunto do arquivo. Apontada é a
// peça sob a mira agora, e ela muda o tempo todo, sem que ninguém tenha pedido
// nada. Selecionada é a peça que alguém escolheu, e ela permanece enquanto o
// cursor passeia por cima das outras. Um realce só para os dois estados produz o
// ambiente em que a pessoa não sabe se já escolheu.
//
// A armadilha deste arquivo está no material, e ela é silenciosa. As duas
// engrenagens compartilham o mesmo latão, e o corpo compartilha o plástico com a
// tampa — de propósito, desde o módulo de materiais, porque material distinto é
// troca de estado a mais no quadro. Escrever o realce direto no material
// compartilhado acende as duas engrenagens ao apontar para uma. O código
// compila, o realce aparece, e o que está errado é qual peça está acesa.
//
// A saída é dar a cada peça realçável uma cópia própria do material, e o preço é
// declarado: cinco cópias, cinco programas na placa, cinco trocas de estado por
// quadro em vez de três. É pouco porque são cinco peças; num ambiente com
// centenas, o realce se faria por outra via, e a conta é a mesma que decidiu a
// instanciação dos parafusos.
// ---------------------------------------------------------------------------
import { Color, Mesh, MeshStandardMaterial, Object3D } from 'three';
import type { PecaId } from '../dominio/dominio';
/** Cor e intensidade da emissão de quem está sob a mira. Discreta: é sinal, não destaque. */
const COR_APONTADA: number = 0x2f6fed;
const EMISSAO_APONTADA: number = 0.45;
/** Quem foi escolhido. Mais quente e mais forte, para não se confundir com a mira. */
const COR_SELECIONADA: number = 0xf0a02a;
const EMISSAO_SELECIONADA: number = 0.8;
export interface CustoDoRealce {
/** Quantas peças ganharam cópia própria de material. */
readonly copias: number;
/** Quantos materiais distintos a cena tinha antes das cópias. */
readonly materiaisAntes: number;
/** Quantos passou a ter. É esta diferença que o orçamento do quadro paga. */
readonly materiaisDepois: number;
}
export interface Destaque {
/** Prepara a peça para poder ser realçada. Só quem foi registrado acende. */
registrar(id: PecaId, no: Object3D): void;
apontar(id: PecaId | undefined): void;
selecionar(id: PecaId | undefined): void;
custo(): CustoDoRealce;
linhas(): string[];
}
interface PecaRealcavel {
readonly material: MeshStandardMaterial;
/** A emissão que o material tinha antes de qualquer realce, para poder voltar. */
readonly emissaoOriginal: Color;
readonly intensidadeOriginal: number;
}
export function montarDestaque(): Destaque {
const pecas: Map<PecaId, PecaRealcavel> = new Map<PecaId, PecaRealcavel>();
const materiaisCompartilhados: Set<MeshStandardMaterial> = new Set<MeshStandardMaterial>();
let copias: number = 0;
let apontada: PecaId | undefined = undefined;
let selecionada: PecaId | undefined = undefined;
function aplicar(): void {
for (const [id, peca] of pecas) {
if (id === selecionada) {
peca.material.emissive.setHex(COR_SELECIONADA);
peca.material.emissiveIntensity = EMISSAO_SELECIONADA;
continue;
}
if (id === apontada) {
peca.material.emissive.setHex(COR_APONTADA);
peca.material.emissiveIntensity = EMISSAO_APONTADA;
continue;
}
// Voltar ao valor guardado, e não a preto: o material pode ter emissão
// própria por decisão do catálogo, e zerar apagaria uma escolha alheia.
peca.material.emissive.copy(peca.emissaoOriginal);
peca.material.emissiveIntensity = peca.intensidadeOriginal;
}
}
return {
registrar(id: PecaId, no: Object3D): void {
if (!(no instanceof Mesh)) {
throw new Error(`A peça "${id}" não é uma malha, e o realce escreve no material dela.`);
}
const atual: unknown = no.material;
if (!(atual instanceof MeshStandardMaterial)) {
throw new Error(`A peça "${id}" não usa o material padrão da oficina.`);
}
// recorte:inicio destaque-copia-do-material
materiaisCompartilhados.add(atual);
// A cópia é o ponto do arquivo. Sem ela, apontar para a engrenagem grande
// acende também a pequena, porque as duas são o mesmo latão.
const proprio: MeshStandardMaterial = atual.clone();
proprio.name = `${atual.name}-de-${id}`;
no.material = proprio;
copias += 1;
pecas.set(id, {
material: proprio,
emissaoOriginal: proprio.emissive.clone(),
intensidadeOriginal: proprio.emissiveIntensity,
});
// recorte:fim destaque-copia-do-material
},
apontar(id: PecaId | undefined): void {
if (id === apontada) {
return;
}
apontada = id;
aplicar();
},
selecionar(id: PecaId | undefined): void {
if (id === selecionada) {
return;
}
selecionada = id;
aplicar();
},
custo(): CustoDoRealce {
return {
copias,
materiaisAntes: materiaisCompartilhados.size,
materiaisDepois: pecas.size,
};
},
linhas(): string[] {
const custo: CustoDoRealce = {
copias,
materiaisAntes: materiaisCompartilhados.size,
materiaisDepois: pecas.size,
};
return [
`Peças realçáveis: ${custo.copias}, cada uma com cópia própria do material.`,
`Materiais distintos entre elas: ${custo.materiaisAntes} antes das cópias, ` +
`${custo.materiaisDepois} depois.`,
'Sem as cópias, apontar para uma engrenagem acenderia as duas: elas dividem o mesmo latão.',
'A conta é a do orçamento do quadro, e ela vale enquanto as peças forem poucas.',
];
},
};
}O realce não é acabamento. Uma seleção que funciona e não se vê é indistinguível de uma seleção quebrada, e a diferença só aparece quando a mira deixa de ser o centro exato da tela.
São dois estados, e mantê-los distintos é o que o arquivo faz. Apontada é a peça sob a mira agora, e ela muda o tempo todo, sem que ninguém tenha pedido nada. Escolhida é a peça que alguém decidiu, e ela permanece enquanto o cursor passeia por cima das outras. Um realce só para os dois estados produz o ambiente em que a pessoa não sabe se já escolheu.
A armadilha está no material, e é silenciosa. As duas engrenagens compartilham o mesmo latão desde o módulo de materiais, e o corpo compartilha o plástico com a tampa. Escrever o realce direto no material compartilhado acende as duas engrenagens ao apontar para uma.
O código compila, o realce aparece, e o que está errado é qual peça está acesa. A saída é dar a cada peça realçável uma cópia própria do material, e o preço está declarado no arquivo: cinco cópias, cinco programas na placa. É pouco porque são cinco peças — num ambiente com centenas, a conta seria a mesma que decidiu a instanciação dos parafusos.
1.4.6 O que a mira custa por quadro
Cada quadro é um envelope de onze milissegundos, e o que não couber nele não fica para depois: some.
A mira se refaz a cada passo do laço, e não a cada movimento do cursor. Parada a cena e parado o cursor, girar a órbita muda o que está sob a mira — então a leitura precisa acontecer no quadro, junto com tudo o mais.
O lançamento contra a cena inteira é a parte cara, e ela entra no mesmo envelope que o painel da bancada já exibe desde o módulo do laço. Nesta cena o custo não aparece na medição, e vale registrar por quê: são poucas dezenas de nós, e a biblioteca usa estrutura de aceleração. Num ambiente com milhares, a primeira medida seria restringir o alcance do raio, que aqui está em doze metros por folga.
1.4.7 Onde é fácil errar, e como conferir
Quatro defeitos aparecem sempre, e três deles não produzem erro no console.
O primeiro é o realce que fica aceso depois que o cursor sai da superfície. A fonte precisa distinguir “não aponta para dentro da cena” de “aponta para o vazio”, e devolver a última posição conhecida é o atalho que produz o defeito.
O segundo é a escolha por quadro. Ler o estado do acionamento em vez da borda de subida faz o gatilho mantido do controle escolher sessenta vezes por segundo. No cursor isso não aparece, porque o pulso dura uma leitura — e é por isso que o defeito atravessa este módulo inteiro e explode no seguinte.
O terceiro é o contorno dos volumes de contato capturando o raio. Ele é auxiliar de depuração, não superfície, e deixá-lo participar faz o realce piscar assim que alguém liga a exibição das caixas. A conferência é ligar as caixas e passear com o cursor.
O quarto tem erro visível e causa invisível: apontar para o cavalete e nada acontecer. É o comportamento correto — o cavalete não é peça —, e confundi-lo com falha custa meia tutoria.
1.5 O que este módulo deixa pronto
A tomada existe, e nenhum plugue de verdade foi ligado nela ainda.
A Bancada passou a responder. Passar o cursor sobre uma peça acende a mira, clicar escolhe, e a lógica que faz as duas coisas não sabe que existe um cursor. A fronteira das dependências está escrita e exibida, e a tabela de campos do ponteiro está pronta para dizer o que muda quando o controle chegar.
Vale registrar o que não entrou, porque é a fronteira que a tutoria mais precisa defender. Não há agarre, não há movimento de peça e não há encaixe. A escolha acende a peça e nada mais; o campo de agarre existe no tipo e é falso em toda leitura.
Também não entrou plugue algum além do cursor. A abstração tem um único alimentador, e essa é a situação em que ela parece um rodeio caro — é exatamente agora que um grupo desfaz o desenho, liga o evento do navegador direto na seleção, e economiza um arquivo.
O módulo seguinte traz agarre e manipulação, ainda com o cursor. É o do ciclo de sessão que traz o controle rastreado, e é lá que a conta deste módulo é cobrada. Quem escreveu a tomada registra outra fonte e não toca na lógica de montagem; quem não escreveu passa a manter dois programas, e a partir dali a distância entre os dois só cresce.