1 Realidade virtual não imersiva — 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
Escada rolante parada ainda é escada; ambiente que só existe com o visor no rosto não é nada sem ele.
Até aqui a Bancada tinha câmera fixa. A cena estava completa, o conteúdo estava medido, e ninguém conseguia olhar a oficina pelo outro lado. Este módulo fecha o buraco.
O que entra é pequeno em linhas e grande em consequência: a câmera passa a girar em volta da cena, e o regime que roda em qualquer máquina fica utilizável por quem nunca ouviu falar do projeto. É o piso de todas as demonstrações do percurso, inclusive da última.
Entra também a metade que quase nenhum projeto escreve: a lista do que este regime não faz, com a razão técnica de cada ausência e com a aferição de cada uma contra o ambiente em execução.
O código ocupa dois arquivos novos, um em core/ e outro em modes/. Nenhum arquivo anterior é reescrito, mas a composição muda em um ponto que merece registro: quem manda na câmera passa a ser a órbita, e não mais quem monta a cena.
Essa troca de dono tem uma consequência imediata dentro do próprio projeto. O botão que aproximava a câmera da prateleira, no módulo anterior, escrevia posição direto na câmera. Com a órbita instalada, aquele código continuaria compilando e o primeiro arrasto do cursor desfaria o efeito dele, sem aviso nenhum.
A correção foi transformar a aproximação em movimento da órbita: muda-se o alvo e o raio, e a órbita reescreve a posição. É a regra geral de quem instala um controlador de câmera — a partir dali, ninguém mais escreve posição de câmera fora dele.
1.2 A tentação de tratar este módulo como degrau
Os aparelhos mais capazes do laboratório são três, e a turma tem 40 estudantes.
O grupo que já ouviu falar em visor tende a entregar este módulo pela metade, contando com o regime imersivo para impressionar depois. O argumento contra isso é aritmético, antes de ser pedagógico.
Os visores são poucos e o parque de celulares é heterogêneo. O regime em janela é o único que sempre abre, em qualquer máquina da sala, sem permissão, sem certificado aceito e sem rodízio de equipamento. Toda demonstração do período passa por ele.
Há ainda a consequência de projeto. Este é o destino para onde tudo degrada adiante: quando o aparelho não entra em sessão imersiva, é aqui que a pessoa cai. Um destino de degradação construído pela metade transforma cada falha de hardware em tela preta.
E há a consequência de avaliação, que costuma ser a que convence. A demonstração final do percurso é assistida por quem não tem visor na mão, e o que essa pessoa vê é a janela. Um projeto excelente no aparelho e capenga na tela é lido como projeto capenga, porque a tela é o que se lê.
O instrumento que este módulo usa contra a entrega pela metade é a conferência cruzada, e não a nota: cada grupo abre o ambiente de outro e tenta usá-lo sem que ninguém explique nada. O relato de quem travou chega sem hierarquia e com evidência, e vale mais que qualquer observação vinda da tutoria.
1.3 Tarefa 1: Entregar o ambiente em janela
Azimute, elevação e raio — e a câmera em órbita inteira sai desses três.
Enunciado da tarefa
O regime que roda em qualquer máquina fecha aqui, e fecha utilizável. Câmera em órbita controlada pelo cursor, cena completa, custo dentro do teto medido, sem exigir equipamento algum além do navegador.
O que fica pronto é um caminho de navegação inteiro, percorrido por tela: quem abre o endereço enxerga a cena, gira em volta dela, aproxima, afasta e alcança todos os objetos do domínio. Este regime é o destino para onde tudo degrada adiante, e por isso precisa bastar-se antes de qualquer outro existir.
src/bancada/core/orbita.ts
// ---------------------------------------------------------------------------
// A câmera em órbita: o único jeito de percorrer a cena que não exige aparelho.
//
// A conta inteira deste arquivo cabe em três números. Azimute é o quanto se
// girou em volta do alvo; elevação é o quanto se subiu ou desceu em relação a
// ele; raio é a distância até ele. Do trio saem as coordenadas cartesianas da
// câmera, e é só isso que a órbita é.
//
// Escrever isso à mão, em vez de usar o controlador pronto da biblioteca, tem
// motivo didático declarado: a habilidade que este módulo cobra é implementar a
// órbita, e arrastar um auxiliar pronto entrega o comportamento sem entregar a
// conta. Fora da sala, num projeto de produção, o controlador pronto seria a
// escolha certa — ele trata amortecimento, inércia e casos de borda que aqui
// ficam de fora de propósito.
//
// Dois cuidados que não são óbvios e que produzem defeito silencioso. A elevação
// é travada antes dos polos: passar de noventa graus inverte o sentido do
// arrasto, e quem usa acha que o ambiente enguiçou. E os eventos são de ponteiro,
// e não de mouse: a mesma função atende mouse, dedo e caneta, o que é o que
// permite ao regime em janela funcionar no celular sem uma segunda
// implementação.
//
// Cuidado de nomenclatura: este ponteiro é o do navegador, não a abstração de
// entrada do projeto. A abstração que unifica mouse, controle e dedo para a
// lógica de montagem chega adiante, e é outra coisa — esta órbita move a câmera,
// e não toca em objeto nenhum da cena.
// ---------------------------------------------------------------------------
import { Box3, MathUtils, PerspectiveCamera, Sphere, Vector3 } from 'three';
/** Limites de elevação, em graus. Travados antes dos polos, e não neles. */
const ELEVACAO_MINIMA: number = -80;
const ELEVACAO_MAXIMA: number = 85;
/** Limites de raio, em metros. O mínimo evita entrar dentro das peças. */
const RAIO_MINIMO: number = 0.35;
const RAIO_MAXIMO: number = 8;
/** Graus de giro por pixel arrastado. */
const SENSIBILIDADE: number = 0.35;
/** Fração do raio ganha ou perdida a cada passo da roda. */
const PASSO_DA_RODA: number = 0.12;
/** Folga sobre o raio calculado ao enquadrar, para a cena não encostar na borda. */
const FOLGA_DE_ENQUADRAMENTO: number = 1.25;
export interface EstadoDaOrbita {
readonly azimuteGraus: number;
readonly elevacaoGraus: number;
readonly raioM: number;
readonly alvo: Vector3;
}
export interface Orbita {
/** Reposiciona o alvo e o raio de uma vez. Usado para levar a câmera a outro ponto da cena. */
olharPara(alvo: Vector3, raio: number): void;
/** Ajusta alvo e raio para que a caixa inteira caiba no campo de visão. */
enquadrar(caixa: Box3): void;
/**
* Reescreve a câmera a partir do estado guardado, sem mudar o estado.
*
* Existe por causa da saída da sessão imersiva. Enquanto ela dura, quem
* escreve a câmera é a biblioteca, a partir da pose da cabeça; ao terminar, a
* câmera fica onde a cabeça parou, e a órbita continua achando que a deixou
* olhando para a bancada. Sem esta chamada, sair do visor devolve um desktop
* olhando para o teto, e o primeiro arrasto conserta sozinho — o que faz o
* defeito parecer intermitente.
*/
reaplicar(): void;
estado(): EstadoDaOrbita;
descrever(): string[];
/** Solta os ouvintes do elemento. Sem isto, trocar de regime deixa a órbita ativa por baixo. */
encerrar(): void;
}
/**
* Instala a órbita sobre um elemento e uma câmera.
*
* A câmera é escrita a cada interação, e não a cada quadro. Recalcular a posição
* em todo quadro custaria trigonometria em sessenta ou noventa quadros por
* segundo para produzir o mesmo valor de antes na esmagadora maioria deles.
*/
export function montarOrbita(camera: PerspectiveCamera, elemento: HTMLElement): Orbita {
const alvo: Vector3 = new Vector3(0, 0.95, 0);
let azimute: number = 0;
let elevacao: number = 12;
let raio: number = 1.8;
// recorte:inicio orbita-mapa-de-ponteiros
// Ponteiros ativos, por identificador. O mapa existe por causa do celular: com
// dois dedos na tela, a distância entre eles é o que controla o raio, e sem
// guardar os dois não há como medir essa distância.
const ponteiros: Map<number, { x: number; y: number }> = new Map();
let distanciaEntreDedos: number = 0;
// recorte:fim orbita-mapa-de-ponteiros
// recorte:inicio orbita-a-conta-esferica
function aplicar(): void {
const a: number = MathUtils.degToRad(azimute);
const e: number = MathUtils.degToRad(elevacao);
const horizontal: number = raio * Math.cos(e);
camera.position.set(
alvo.x + horizontal * Math.sin(a),
alvo.y + raio * Math.sin(e),
alvo.z + horizontal * Math.cos(a),
);
camera.lookAt(alvo);
}
// recorte:fim orbita-a-conta-esferica
// recorte:inicio orbita-elevacao-travada
function girar(dx: number, dy: number): void {
azimute -= dx * SENSIBILIDADE;
elevacao = MathUtils.clamp(elevacao + dy * SENSIBILIDADE, ELEVACAO_MINIMA, ELEVACAO_MAXIMA);
aplicar();
}
// recorte:fim orbita-elevacao-travada
function aproximar(fator: number): void {
raio = MathUtils.clamp(raio * fator, RAIO_MINIMO, RAIO_MAXIMO);
aplicar();
}
// recorte:inicio orbita-captura-do-ponteiro
function aoDescer(evento: PointerEvent): void {
ponteiros.set(evento.pointerId, { x: evento.clientX, y: evento.clientY });
// A captura mantém os eventos vindo para este elemento mesmo quando o cursor
// sai dele durante o arrasto. Sem ela, arrastar para fora da tela deixa a
// órbita presa girando, e o botão já foi solto sem ninguém saber.
elemento.setPointerCapture(evento.pointerId);
}
// recorte:fim orbita-captura-do-ponteiro
// recorte:inicio orbita-pinca-multiplica
function aoMover(evento: PointerEvent): void {
const anterior = ponteiros.get(evento.pointerId);
if (anterior === undefined) {
return;
}
const dx: number = evento.clientX - anterior.x;
const dy: number = evento.clientY - anterior.y;
ponteiros.set(evento.pointerId, { x: evento.clientX, y: evento.clientY });
if (ponteiros.size >= 2) {
const nova: number = distanciaAtual();
if (distanciaEntreDedos > 0 && nova > 0) {
aproximar(distanciaEntreDedos / nova);
}
distanciaEntreDedos = nova;
return;
}
girar(dx, dy);
}
// recorte:fim orbita-pinca-multiplica
function aoSubir(evento: PointerEvent): void {
ponteiros.delete(evento.pointerId);
distanciaEntreDedos = 0;
if (elemento.hasPointerCapture(evento.pointerId)) {
elemento.releasePointerCapture(evento.pointerId);
}
}
function aoRodar(evento: WheelEvent): void {
// Sem isto, a roda rola a página em vez de aproximar a câmera — e no
// celular o mesmo problema aparece com o arrasto de um dedo.
evento.preventDefault();
aproximar(evento.deltaY > 0 ? 1 + PASSO_DA_RODA : 1 - PASSO_DA_RODA);
}
function distanciaAtual(): number {
const lista = [...ponteiros.values()];
const a = lista[0];
const b = lista[1];
if (a === undefined || b === undefined) {
return 0;
}
return Math.hypot(a.x - b.x, a.y - b.y);
}
elemento.addEventListener('pointerdown', aoDescer);
elemento.addEventListener('pointermove', aoMover);
elemento.addEventListener('pointerup', aoSubir);
elemento.addEventListener('pointercancel', aoSubir);
elemento.addEventListener('wheel', aoRodar, { passive: false });
// O toque prolongado abriria o menu do sistema no meio do arrasto.
elemento.style.touchAction = 'none';
aplicar();
return {
olharPara(novoAlvo: Vector3, novoRaio: number): void {
alvo.copy(novoAlvo);
raio = MathUtils.clamp(novoRaio, RAIO_MINIMO, RAIO_MAXIMO);
aplicar();
},
/**
* Enquadra a caixa inteira.
*
* O raio sai da esfera que envolve a caixa e da metade do campo de visão
* vertical: a distância em que a esfera cabe na tela é o raio dela dividido
* pelo seno desse meio-ângulo. É essa conta que garante a exigência da
* tarefa — alcançar todos os objetos do domínio, e não só os que por acaso
* estavam perto da posição inicial.
*/
// recorte:inicio orbita-enquadrar-pela-esfera
enquadrar(caixa: Box3): void {
const esfera: Sphere = new Sphere();
caixa.getBoundingSphere(esfera);
alvo.copy(esfera.center);
const meioCampo: number = MathUtils.degToRad(camera.fov / 2);
const necessario: number = esfera.radius / Math.sin(meioCampo);
raio = MathUtils.clamp(necessario * FOLGA_DE_ENQUADRAMENTO, RAIO_MINIMO, RAIO_MAXIMO);
aplicar();
},
// recorte:fim orbita-enquadrar-pela-esfera
reaplicar(): void {
aplicar();
},
estado(): EstadoDaOrbita {
return {
azimuteGraus: azimute,
elevacaoGraus: elevacao,
raioM: raio,
alvo: alvo.clone(),
};
},
descrever(): string[] {
return [
`Alvo da órbita: ${alvo.x.toFixed(2)}, ${alvo.y.toFixed(2)}, ${alvo.z.toFixed(2)} m`,
`Azimute: ${azimute.toFixed(0)} graus · elevação: ${elevacao.toFixed(0)} graus`,
`Distância ao alvo: ${raio.toFixed(2)} m ` +
`(entre ${RAIO_MINIMO.toFixed(2)} e ${RAIO_MAXIMO.toFixed(0)})`,
`Elevação travada entre ${ELEVACAO_MINIMA} e ${ELEVACAO_MAXIMA} graus, antes dos polos`,
];
},
encerrar(): void {
elemento.removeEventListener('pointerdown', aoDescer);
elemento.removeEventListener('pointermove', aoMover);
elemento.removeEventListener('pointerup', aoSubir);
elemento.removeEventListener('pointercancel', aoSubir);
elemento.removeEventListener('wheel', aoRodar);
ponteiros.clear();
},
};
}1.3.1 A conta, que cabe em uma linha
Azimute é o quanto se girou em volta do alvo. Elevação é o quanto se subiu ou desceu em relação a ele. Raio é a distância até ele.
Do trio saem as coordenadas da câmera por duas funções trigonométricas, e não há mais nada. O componente horizontal é o raio vezes o cosseno da elevação; a altura é o raio vezes o seno dela; o azimute distribui o horizontal entre os eixos X e Z.
Confira o número com o material do módulo: com o alvo no tampo, elevação de 12 graus e raio de 1,8 metro, a câmera fica a 1,32 metro de altura e a 1,76 metro à frente. São contas que se refazem à mão em papel, e refazê-las em tutoria é a maneira mais rápida de encerrar a discussão sobre o que a órbita faz.
1.3.2 Por que não usar o controlador pronto
A biblioteca de renderização traz um controlador de órbita pronto, e ele é melhor que o nosso: trata amortecimento, inércia e casos de borda que aqui ficam de fora.
A escolha de escrever à mão é didática e está declarada no arquivo. A habilidade que este módulo cobra é implementar a órbita, e arrastar um auxiliar pronto entrega o comportamento sem entregar a conta. Fora da sala, num projeto de produção, a escolha seria a outra.
Repare no contraste com o módulo anterior. Lá a simplificação de malha foi entregue à ferramenta, e de propósito: reimplementar um algoritmo de colapso de arestas consumiria o período inteiro e não está na ementa. Aqui a conta é de três linhas e está na ementa. O critério é esse, e não preferência por escrever tudo.
1.3.3 A câmera se escreve na interação, não no quadro
Por que recalcular a posição da câmera só quando alguém arrasta?
Porque em noventa quadros por segundo a trigonometria produziria o mesmo valor de antes na esmagadora maioria deles. O orçamento do quadro já está declarado e medido desde o módulo anterior, e gasto sem retorno é a definição do que ele existe para impedir.
O efeito prático aparece no visor, e não no computador de mesa. A conta é barata, mas ela concorre com tudo o mais que o quadro precisa fazer, e o teto lá é de onze milissegundos.
1.3.4 Enquadrar tudo, e a esfera que decide a distância
A tarefa exige alcançar todos os objetos do domínio, e não os que por acaso nasceram diante da câmera.
O enquadramento resolve isso por medição. Toma-se a esfera que envolve a cena inteira, e a distância em que ela cabe na tela é o raio dela dividido pelo seno da metade do campo de visão vertical. Sobre esse valor entra uma folga para a cena não encostar na borda.
O número desta cena: a esfera envolvente tem cerca de 1,9 metro de raio e o campo é de 55 graus, o que dá 5,19 metros de distância. Um objeto fora dessa esfera é um objeto que a câmera nunca encontra, e o sintoma é a peça que ninguém acha e que todo mundo jura ter posicionado.
1.3.5 Servir o ambiente para quem não estava na sala
A tarefa não termina no código: termina em outra pessoa abrindo o endereço e usando o ambiente sem que ninguém explique nada.
A infraestrutura já existe desde o começo do percurso — o servidor escuta em todas as interfaces da rede, sobre canal seguro, e o endereço se abre no navegador de qualquer aparelho do laboratório. O que este módulo acrescenta é a exigência de que o resultado se explique sozinho.
Daí a instrução de uso na própria página, acima da cena, dizendo que se arrasta para girar e se usa a roda para aproximar. É uma linha de texto, e ela é a diferença entre a conferência cruzada produzir um relato sobre o ambiente e produzir um relato sobre não saber por onde começar.
1.3.6 Dois dedos, e o mapa de ponteiros ativos
Por que guardar os ponteiros num mapa, se o mouse tem um cursor só?
Por causa do celular, que é metade do parque desta disciplina. Com dois dedos na tela, o que controla a distância até o alvo é a distância entre eles, e medir essa distância exige ter guardado os dois. Com um dedo só, o gesto é o mesmo do arrasto do mouse e cai no mesmo caminho de código.
O mapa é indexado pelo identificador que o navegador atribui a cada ponteiro. Esse identificador é o que permite tratar o segundo dedo como segundo dedo, e não como um salto brusco do primeiro — que é exatamente o defeito de quem guarda uma posição só e recebe eventos de dois.
O gesto de pinça produz um fator, e não um deslocamento. A razão entre a distância anterior e a atual multiplica o raio, o que faz o gesto responder igual perto e longe. Somar centímetros em vez de multiplicar produz aproximação lenta a três metros e brusca a trinta centímetros.
1.3.7 Onde é fácil errar, e como conferir
Três defeitos aparecem sempre, e nenhum deles produz erro no console.
O primeiro é a elevação sem trava. Passar de noventa graus inverte o sentido do arrasto, e quem usa acha que o ambiente enguiçou. A trava aqui fica antes dos polos, e não neles.
O segundo é o arrasto que fica preso. Sem capturar o ponteiro, arrastar para fora do elemento deixa a órbita girando com o botão já solto. A conferência é sair da tela durante o arrasto e voltar.
O terceiro é o toque no celular. Sem desligar a ação de toque padrão, o navegador rola a página e abre menu de sistema no meio do gesto. A conferência não tem substituto: é abrir no aparelho.
O ponteiro daqui não é a abstração do projeto. Os eventos de ponteiro do navegador unificam mouse, dedo e caneta, e é isso que faz a órbita funcionar no celular sem uma segunda implementação. A abstração que unifica mouse, controle rastreado e toque para a lógica de montagem chega adiante e é outra coisa: esta órbita move a câmera e não toca em objeto nenhum da cena. Confundir as duas em aula produz um grupo inteiro tentando agarrar peça com o código da câmera.
1.4 Tarefa 2: Registrar o que este regime não oferece
Um limite escrito em documento à parte não se confronta com nada — e este é aferido em execução.
Enunciado da tarefa
Um regime bem construído é também um regime cujos limites quem o construiu sabe enunciar. Aqui não há escala corporal, não há alcance de braço, e a profundidade é inferida por perspectiva e oclusão em vez de ser vista com dois olhos.
A tarefa fecha com esses limites escritos e com o que eles impedem de demonstrar. A lista serve de contraste quando o mesmo ambiente ganhar corpo adiante, e impede que a versão em janela seja exibida depois no lugar de um regime imersivo que nunca foi construído.
src/bancada/modes/janela.ts
// ---------------------------------------------------------------------------
// O regime em janela, e a lista do que ele não faz.
//
// Este arquivo tem duas metades, e a segunda é a que costuma não existir em
// projeto nenhum. A primeira declara o que o regime entrega. A segunda declara o
// que ele NÃO entrega, item a item, com a razão técnica de cada ausência e com
// aquilo que a ausência impede de demonstrar.
//
// A segunda metade é escrita como dado, e não como parágrafo em documento à
// parte, pela mesma razão que levou a delimitação do domínio a ser escrita
// assim: limite em texto solto não se confronta com nada. Aqui cada limite
// carrega uma verificação, e a verificação é rodada contra o ambiente em
// execução — de modo que a afirmação "não há visão estereoscópica" deixa de ser
// promessa e passa a ser leitura.
//
// Escada rolante parada ainda é escada; ambiente que só existe com o visor no
// rosto não é nada sem ele. Este regime é o piso de todas as demonstrações do
// percurso, inclusive da última, e por isso fecha utilizável antes de qualquer
// outro existir.
// ---------------------------------------------------------------------------
import { Box3, Object3D, Vector3, WebGLRenderer } from 'three';
import { regimePorId, type Regime } from './regimes';
/** Como a verificação de um limite terminou. */
export type Afericao = 'confirmado' | 'contrariado' | 'sem-resposta';
// recorte:inicio limite-com-quatro-campos
export interface LimiteDoRegime {
readonly id: string;
/** O que falta, em uma frase que quem lê consegue conferir. */
readonly enunciado: string;
/** Por que falta — a razão técnica, e não a lamentação. */
readonly porque: string;
/** O que a ausência impede de demonstrar. É esta coluna que a tutoria cobra. */
readonly impedeDemonstrar: string;
}
// recorte:fim limite-com-quatro-campos
/**
* Os três limites do regime em janela.
*
* Nenhum deles é defeito de implementação: os três decorrem de o ambiente ser
* exibido numa superfície plana, a uma distância que não é a do corpo de quem
* olha. Corrigi-los dentro deste regime é impossível, e prometer corrigi-los é o
* que produz a demonstração desonesta que o percurso adiante desmonta.
*/
export const LIMITES_DA_JANELA: readonly LimiteDoRegime[] = [
{
id: 'profundidade-inferida',
enunciado: 'A profundidade é inferida, e não vista.',
porque:
'a tela entrega uma imagem só, para os dois olhos; o que sugere distância é ' +
'perspectiva, oclusão e tamanho relativo, que são pistas de imagem e não de ' +
'disparidade entre duas vistas',
impedeDemonstrar:
'julgar a distância entre duas peças próximas sem girar a câmera — e é ' +
'justamente esse julgamento que o encaixe vai exigir adiante',
},
{
id: 'sem-escala-corporal',
enunciado: 'Não há escala corporal.',
porque:
'a bancada está a noventa centímetros do chão da cena, e esse número não tem ' +
'contra o que ser comparado: quem olha está sentado diante de um monitor, e o ' +
'ambiente não sabe a altura dos olhos de ninguém',
impedeDemonstrar:
'que a bancada está na altura certa para trabalhar de pé — a afirmação só se ' +
'verifica quando o piso da cena coincidir com o piso da sala',
},
{
id: 'sem-alcance-de-braco',
enunciado: 'Não há alcance de braço.',
porque:
'o cursor alcança qualquer pixel da tela com o mesmo esforço, esteja o objeto ' +
'a vinte centímetros ou a cinco metros dentro da cena',
impedeDemonstrar:
'que uma peça está longe demais para ser apanhada sem caminhar até ela, que é ' +
'a restrição de onde nasce a necessidade de locomoção',
},
];
export interface LimiteAferido {
readonly limite: LimiteDoRegime;
readonly afericao: Afericao;
readonly observacao: string;
}
/**
* Confronta cada limite declarado com o ambiente em execução.
*
* Dois dos três se aferem por leitura direta: a ausência de sessão imersiva e a
* contagem de vistas que a placa está desenhando. O terceiro não tem leitura
* possível de dentro do programa, e dizer isso é mais honesto do que arranjar um
* indicador que apenas pareça responder.
*/
export function aferirLimites(renderer: WebGLRenderer): LimiteAferido[] {
const emSessao: boolean = renderer.xr.isPresenting;
const camadas: number = emSessao ? 2 : 1;
return LIMITES_DA_JANELA.map((limite: LimiteDoRegime): LimiteAferido => {
switch (limite.id) {
case 'profundidade-inferida':
return {
limite,
afericao: camadas === 1 ? 'confirmado' : 'contrariado',
observacao:
camadas === 1
? 'a placa está desenhando uma vista só: não há duas imagens para os ' +
'dois olhos, logo não há disparidade de onde tirar profundidade'
: `a placa está desenhando ${camadas} vistas, o que já não é o regime em janela`,
};
case 'sem-escala-corporal':
return {
limite,
afericao: emSessao ? 'contrariado' : 'confirmado',
observacao: emSessao
? 'há sessão imersiva ativa, e com ela chega o espaço de referência do chão'
: 'não há sessão imersiva, logo não há espaço de referência ancorado no ' +
'chão físico nem altura de olhos conhecida',
};
// recorte:inicio limite-sem-resposta
default:
return {
limite,
afericao: 'sem-resposta',
observacao:
'não há leitura possível de dentro do programa: o alcance do braço é uma ' +
'propriedade de quem usa, e o cursor não tem corpo para medir',
};
// recorte:fim limite-sem-resposta
}
});
}
export interface EntregaDaJanela {
readonly regime: Regime;
readonly cenaEnquadrada: Box3;
readonly objetosAlcancaveis: number;
}
/**
* Mede o que o regime entrega: a caixa que envolve tudo o que existe na cena e
* quantos objetos do domínio cabem dentro dela.
*
* A conta serve à exigência da tarefa — alcançar todos os objetos do domínio —,
* e serve porque é ela que alimenta o enquadramento da órbita. Um objeto fora
* desta caixa é um objeto que a câmera nunca vai encontrar, e o sintoma é a peça
* que ninguém acha e que todo mundo jura ter posicionado.
*/
export function medirEntrega(raiz: Object3D, nomes: readonly string[]): EntregaDaJanela {
const caixa: Box3 = new Box3().setFromObject(raiz);
let alcancaveis: number = 0;
for (const nome of nomes) {
if (raiz.getObjectByName(nome) !== undefined) {
alcancaveis += 1;
}
}
return {
regime: regimePorId('inline'),
cenaEnquadrada: caixa,
objetosAlcancaveis: alcancaveis,
};
}
/** As linhas do regime, para a página e para a conferência cruzada entre grupos. */
export function linhasDaJanela(
entrega: EntregaDaJanela,
aferidos: readonly LimiteAferido[],
): string[] {
const tamanho: Vector3 = entrega.cenaEnquadrada.getSize(new Vector3());
const linhas: string[] = [
`Regime: ${entrega.regime.nome} — ${entrega.regime.papel}`,
`Registro contra: ${entrega.regime.registroContra}`,
`Objetos do domínio alcançáveis pela órbita: ${entrega.objetosAlcancaveis}`,
`Caixa que a órbita precisa enquadrar: ${tamanho.x.toFixed(2)} x ${tamanho.y.toFixed(2)} x ` +
`${tamanho.z.toFixed(2)} m`,
'',
];
for (const aferido of aferidos) {
linhas.push(
`${aferido.limite.enunciado} [${aferido.afericao}]`,
` Por quê: ${aferido.limite.porque}.`,
` Impede demonstrar: ${aferido.limite.impedeDemonstrar}.`,
` Aferição: ${aferido.observacao}.`,
'',
);
}
return linhas;
}1.4.1 Limite como dado, e não como parágrafo
A decisão de estrutura deste arquivo repete a que a delimitação do domínio tomou no primeiro módulo: o que precisa ser confrontado depois é escrito como dado tipado.
Cada limite carrega quatro campos. O enunciado, que quem lê consegue conferir. A razão técnica, que não é lamentação. O que a ausência impede de demonstrar — e essa é a coluna que a tutoria cobra. E, calculada em execução, a aferição.
Limite guardado num documento à parte envelhece bem demais: continua parecendo verdadeiro por anos depois de ter deixado de ser. Aqui, quando o ambiente ganhar sessão imersiva, duas das aferições mudam sozinhas de valor, e a mudança aparece na tela de quem abrir a página.
1.4.2 Os três limites, e de onde vem cada um
Nenhum dos três é defeito de implementação. Os três decorrem de o ambiente ser exibido numa superfície plana, a uma distância que não é a do corpo de quem olha.
A profundidade é inferida. A tela entrega uma imagem só para os dois olhos, e o que sugere distância é perspectiva, oclusão e tamanho relativo. Isso impede julgar a separação entre duas peças próximas sem girar a câmera, que é justamente o julgamento que o encaixe vai exigir adiante.
Não há escala corporal. A bancada está a 90 centímetros do chão da cena, e esse número não tem contra o que ser comparado: o ambiente não sabe a altura dos olhos de ninguém. Isso impede demonstrar que a bancada está na altura certa para trabalhar de pé.
Não há alcance de braço. O cursor alcança qualquer pixel com o mesmo esforço, esteja o objeto a vinte centímetros ou a cinco metros dentro da cena. Isso impede demonstrar que uma peça está longe demais para ser apanhada sem caminhar até ela — que é a restrição de onde nasce a necessidade de locomoção.
1.4.3 O que se afere, e o que se declara sem resposta
Dois dos três limites se aferem por leitura direta do ambiente.
A profundidade inferida se lê na contagem de vistas que a placa está desenhando: uma vista só significa que não há duas imagens para os dois olhos, logo não há disparidade de onde tirar profundidade. A ausência de escala corporal se lê na ausência de sessão imersiva, que é o que traria o espaço de referência ancorado no chão físico.
O terceiro não tem leitura possível de dentro do programa. O alcance do braço é propriedade de quem usa, e o cursor não tem corpo para medir. A aferição dele é sem-resposta, e dizer isso é mais honesto do que arranjar um indicador que apenas pareça responder.
Essa terceira posição é a que costuma sumir. Um relatório com duas colunas — atende e não atende — força tudo a caber numa das duas, e o que não cabe vira resposta inventada.
1.4.4 Onde é fácil errar, e como conferir
O erro previsível aqui é escrever a lista de limites e guardá-la no repositório.
A conferência cruzada deste módulo funciona porque outro grupo abre o endereço e tenta usar o ambiente sem instrução verbal. Limite que só existe num arquivo de projeto não alcança quem está usando. Por isso a lista sai na mesma página do ambiente, logo abaixo da cena.
A segunda conferência é de leitura, e ela é rápida: cada limite tem de dizer o que impede demonstrar. Um grupo que escreveu “não é imersivo” não escreveu limite nenhum — repetiu o nome do regime.
A terceira é de coerência, e vale a pena fazê-la em aula com a página aberta. Peça a aferição de cada limite e compare com o texto ao lado. Enquanto não houver sessão imersiva, os dois primeiros saem confirmado e o terceiro sai sem-resposta; qualquer outra combinação significa que a lista e o ambiente discordam, e a discordância é o achado.
1.5 O que este módulo deixa pronto
O primeiro bloco entregável do projeto fecha aqui, e ele fecha inteiro.
A Bancada agora é um ambiente que outra pessoa abre pelo endereço, percorre e usa sem instrução. Domínio delimitado, regimes declarados, sonda de capacidades, cena montada como árvore, geometria autorada, ativo externo ajustado, repetição instanciada, custo medido e navegação completa.
Vale registrar o que não entrou, porque a fronteira é a que a tutoria mais precisa defender. Não há seleção de peça, não há agarre e não há encaixe. A órbita move a câmera e nada mais; nenhum objeto da cena responde a clique. Um grupo que já implementou seleção aqui adiantou o módulo seguinte e quase sempre o adiantou errado, porque o fez com o código da câmera.
Também não entrou sessão imersiva. A sonda de capacidades continua respondendo o que o aparelho declara, e continua sem abrir sessão para trabalhar — o que ela faz desde o módulo de dispositivos. O visor ainda não recebeu a cena, e o marco em que ele a recebe é o próximo bloco do percurso.
O que sobrevive deste módulo para todos os seguintes é a lista de limites. A partir do próximo entra a camada de interação, e depois dela a sessão imersiva e a ancoragem — cada um desses passos apaga uma linha da lista, e é contra ela que o ganho vai ser medido. Sem a lista escrita agora, o regime em janela seria exibido adiante no lugar de um imersivo que ninguém construiu, e a diferença ninguém saberia nomear.