1 Tecnologias de desenvolvimento e o ciclo de sessã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.
A frase acima foi escrita dois módulos atrás, quando havia uma fonte de entrada só. Ela era, naquele dia, uma promessa. Este é o módulo em que ela vira conta a pagar.
O ambiente ganha duas coisas grandes. Ganha o ciclo de sessão, que é o momento em que o programa deixa de mandar no próprio laço. E ganha o segundo plugue: o controle rastreado do visor apanha a mesma engrenagem que o cursor apanhava, com o mesmo código.
Entram três arquivos e mudam quatro. A conferência do módulo cabe numa linha: nenhum dos quatro pertence à camada de interação. Se um pertencesse, a economia dos dois módulos anteriores seria imaginária, e o lugar de descobrir isso é aqui.
Há ainda uma terceira entrega, e ela não desenha nada. É o registro de onde passa a fronteira entre o que o navegador resolve e o que este projeto escreveu — a página que se defende quando a pergunta chega.
1.2 O que muda quando a sessão assume
Três coisas deixam de ser do programa ao mesmo tempo, e a terceira é a que surpreende.
Quem pede o próximo quadro? Fora de sessão, a resposta é o programa. Dentro dela, é o aparelho: o visor tem cadência própria, e é a sessão que entrega o quadro quando a tela dele está pronta para receber.
O alvo de desenho também muda. Deixa de ser a superfície da página e passa a ser a composição que o visor consome, com uma imagem para cada olho e a correção da distorção das lentes por cima. Nada disso é escrito aqui, e é bom que não seja.
A terceira é a que costuma pegar de surpresa. A câmera deixa de ser um objeto que se posiciona e passa a ser a cabeça de uma pessoa. Escrever a posição dela é mentir sobre onde essa pessoa está, e o corpo de quem usa percebe a mentira antes de a cabeça entender.
1.2.1 O laço que não precisou mudar
Este módulo não acrescenta uma linha ao laço de renderização. A razão está escrita seis módulos atrás, no arquivo dele, e naquele dia parecia zelo excessivo.
O laço adotado desde então é o que troca de fonte sozinho quando a sessão começa. Custou a mesma linha que o laço da janela custaria e evitou o retrabalho clássico deste ponto do percurso: o ambiente inteiro escrito em torno do laço da página, funcionando no desktop, e congelado dentro do visor.
O sintoma desse retrabalho merece ser conhecido antes de acontecer. A página continua respondendo, os botões continuam funcionando, e só a imagem no visor congela. O diagnóstico aponta para todo lado menos para o laço, que é a peça que ninguém suspeita por ela estar funcionando à vista de todos.
1.3 Tarefa 1: Entrar e sair de sessão
Um ambiente perfeito servido por endereço sem cifra simplesmente não abre sessão, e o erro do navegador raramente diz isso.
Enunciado da tarefa
O ambiente aprende a entregar o controle. Entrar em sessão, negociar os recursos opcionais que a sonda encontrou, escolher o espaço de referência e devolver o controle ao sair: o laço de render deixa de ser regido pelo próprio código e passa a ser regido de fora.
O que fica pronto é o ciclo completo, com o requisito de contexto seguro atendido e com mensagem legível quando ele falta. Um ambiente perfeito servido por endereço sem cifra simplesmente não abre sessão, e o erro do navegador raramente diz isso com clareza.
src/bancada/modes/sessao.ts
// ---------------------------------------------------------------------------
// O ciclo de sessão: o módulo em que o ambiente entrega o controle.
//
// Até aqui o programa mandava em tudo. Ele pedia o próximo quadro, decidia o
// tamanho da imagem, escolhia de onde a câmera olhava. A partir daqui, dentro de
// uma sessão imersiva, nenhuma dessas três coisas é mais dele: a cadência vem do
// aparelho, o alvo de desenho é o do visor, e a câmera é a cabeça de uma pessoa.
//
// Três decisões deste arquivo pagam o percurso inteiro.
//
// A primeira é que o laço NÃO muda. Ele foi escrito, seis módulos atrás, sobre o
// laço que troca de fonte sozinho quando a sessão começa. Aquela linha custou
// nada naquele dia e é a razão de não haver, aqui, um segundo laço para o modo
// imersivo — que é o retrabalho clássico deste ponto do percurso.
//
// A segunda é que tudo o que se pede à plataforma é pedido como OPCIONAL. Um
// recurso obrigatório que o aparelho não tenha derruba a sessão inteira, e o
// sintoma que chega a quem usa é um botão que não faz nada. Pedindo como
// opcional, a sessão sobe e a ausência do recurso fica observável — que é a
// diferença entre "não funciona aqui" e "aqui falta a detecção de superfície".
//
// A terceira é o posto de trabalho. No regime em janela, quem se move é a
// câmera. No imersivo não há câmera a mover: há uma pessoa de pé, e escrever a
// posição dela é mentir sobre onde ela está. O que se move é a ORIGEM DO ESPAÇO
// DE REFERÊNCIA, e a conta do deslocamento está adiante, junto da constante.
// ---------------------------------------------------------------------------
import type { WebGLRenderer } from 'three';
import {
RECURSOS_CONSULTADOS,
estadoDoRecurso,
type EstadoDeRecurso,
} from '../devices/recursos';
/** Os dois modos de sessão que este ambiente abre. */
export type ModoDeSessao = 'immersive-vr' | 'immersive-ar';
/**
* Espaços de referência tentados, do mais desejável ao mínimo garantido.
*
* `local-floor` é o que faz a bancada nascer na altura certa, porque a origem
* dele é o chão da sala física. `local` põe a origem na altura em que a cabeça
* estava ao entrar, e a bancada aparece na cintura de quem é alto e no peito de
* quem não é. `viewer` é o último recurso e mal merece o nome de espaço: a
* origem é a própria cabeça, e a cena passa a acompanhá-la.
*/
const ESPACOS_PREFERIDOS: readonly XRReferenceSpaceType[] = ['local-floor', 'local', 'viewer'];
/**
* A quantos metros à frente da origem da bancada a pessoa começa.
*
* O tampo tem oitenta centímetros de profundidade e está centrado na origem,
* logo a borda da frente está a quarenta centímetros dela. Cinquenta e cinco
* centímetros deixam quinze de folga entre a pessoa e a borda: é a distância de
* quem TRABALHA na bancada, encostado nela.
*
* Este número já foi um metro e quinze, e a justificativa de então também era
* boa — setenta e cinco centímetros de folga, a distância de quem CHEGA. O
* critério mudou quando o alcance do braço entrou como restrição de projeto: de
* um metro e quinze, nenhuma peça do tampo cabe dentro do braço, e a auditoria de
* alcance acusa isso peça por peça. Os dois critérios mediam coisas diferentes, e
* o conflito só podia aparecer aqui.
*
* Sem deslocamento algum a pessoa nasce DENTRO da bancada, com o tampo na altura
* do peito e as peças atrás da cabeça. É o defeito mais previsível da primeira
* entrada em sessão e o que menos parece defeito: no desktop está tudo certo.
*/
export const RECUO_DO_POSTO_M: number = 0.55;
export interface RecursoNegociado {
readonly nome: string;
readonly paraQueServe: string;
readonly estado: EstadoDeRecurso;
}
/** O que a sessão entregou, depois de a negociação terminar. */
export interface SessaoAberta {
readonly modo: ModoDeSessao;
readonly espacoObtido: XRReferenceSpaceType;
readonly espacosRecusados: readonly XRReferenceSpaceType[];
readonly recursos: readonly RecursoNegociado[];
readonly composicao: XREnvironmentBlendMode;
/** Falso quando a origem não pôde ser deslocada, e a pessoa nasce na bancada. */
readonly postoAplicado: boolean;
}
export interface CicloDeSessao {
/** Abre a sessão, negocia o que dá para negociar e devolve o que ficou valendo. */
entrar(modo: ModoDeSessao): Promise<SessaoAberta>;
/** Encerra a sessão. Devolver o controle é parte do ciclo, não limpeza. */
sair(): Promise<void>;
ativa(): boolean;
aberta(): SessaoAberta | undefined;
/** Chamado depois de a sessão subir. */
aoAbrir(ouvinte: (aberta: SessaoAberta) => void): void;
/** Chamado quando a sessão termina — inclusive quando quem a encerra é o aparelho. */
aoFechar(ouvinte: () => void): void;
linhas(): string[];
}
/**
* A mensagem de contexto inseguro, escrita para ser lida por quem está com o
* visor no rosto.
*
* O navegador, nessa situação, não expõe a interface de realidade estendida — e
* o erro que ele devolve não menciona o endereço em lugar nenhum. Quem lê conclui
* que o aparelho não tem suporte, o que é falso sobre o aparelho e caro de
* descobrir: a turma passa a tarde trocando de visor.
*/
function exigirAmbiente(): XRSystem {
if (!window.isSecureContext) {
throw new Error(
'Esta página não está em contexto seguro, e por isso o navegador não expõe a ' +
'interface de realidade estendida. A causa é o endereço, e não o aparelho: ' +
'sirva por HTTPS e aceite o aviso do certificado neste aparelho antes de tentar de novo.',
);
}
const xr: XRSystem | undefined = navigator.xr;
if (xr === undefined) {
throw new Error(
'Este navegador não implementa a interface de realidade estendida. A página em ' +
'janela continua inteira; o que não existe aqui é a sessão imersiva.',
);
}
return xr;
}
/** O espaço que a sessão concedeu, e os que ela recusou antes dele. */
interface EscolhaDeEspaco {
readonly obtido: XRReferenceSpaceType;
readonly recusados: readonly XRReferenceSpaceType[];
}
/**
* Tenta os espaços em ordem e devolve o primeiro concedido.
*
* A rejeição é a resposta, e não a falha: pedir um espaço que o aparelho não
* oferece é como se pergunta a ele o que oferece. `viewer` é garantido pela
* especificação em toda sessão, então a lista sempre termina com resposta.
*/
async function escolherEspaco(sessao: XRSession): Promise<EscolhaDeEspaco> {
const recusados: XRReferenceSpaceType[] = [];
for (const tipo of ESPACOS_PREFERIDOS) {
try {
await sessao.requestReferenceSpace(tipo);
return { obtido: tipo, recusados };
} catch {
recusados.push(tipo);
}
}
throw new Error(
'A sessão abriu e não concedeu espaço de referência algum, nem o mínimo. ' +
'Não há como registrar a cena contra coisa nenhuma, e insistir produziria uma ' +
'bancada colada na cara de quem entrou.',
);
}
/**
* Desloca a origem do espaço de referência para o posto de trabalho.
*
* A conta do sinal é a que se erra, e ela é curta. O deslocamento recebe onde
* fica a origem do espaço NOVO dentro do espaço atual; as poses lidas no espaço
* novo saem, portanto, com o deslocamento invertido. Quem entra está na origem
* do espaço atual, e queremos que apareça o recuo do posto ADIANTE da bancada,
* isto é, em z positivo — logo a origem nova vai para z negativo do mesmo tanto.
* A mesma conta volta no módulo de locomoção, e lá ela é composta a cada salto.
*
* Não se aplica a `viewer`: ali a origem é a cabeça, e deslocá-la produziria uma
* bancada que foge quando a pessoa anda em direção a ela.
*/
function deslocarParaOPosto(renderer: WebGLRenderer, espaco: XRReferenceSpaceType): boolean {
if (espaco === 'viewer') {
return false;
}
const base: XRReferenceSpace | null = renderer.xr.getReferenceSpace();
if (base === null) {
return false;
}
const posto: XRReferenceSpace = base.getOffsetReferenceSpace(
new XRRigidTransform({ x: 0, y: 0, z: -RECUO_DO_POSTO_M }),
);
renderer.xr.setReferenceSpace(posto);
return true;
}
export function montarCicloDeSessao(renderer: WebGLRenderer): CicloDeSessao {
const ouvintesDeAbertura: ((aberta: SessaoAberta) => void)[] = [];
const ouvintesDeFechamento: (() => void)[] = [];
let sessaoAtual: XRSession | undefined = undefined;
let resumo: SessaoAberta | undefined = undefined;
let ultimaFalha: string | undefined = undefined;
function encerrada(): void {
sessaoAtual = undefined;
resumo = undefined;
// A biblioteca deixa de desenhar para o visor, mas a câmera fica onde a
// cabeça parou. Quem devolve a câmera ao lugar é a composição, e é por isso
// que este aviso existe: sem ele, sair da sessão deixa o desktop olhando
// para o teto.
for (const ouvinte of ouvintesDeFechamento) {
ouvinte();
}
}
return {
async entrar(modo: ModoDeSessao): Promise<SessaoAberta> {
if (sessaoAtual !== undefined) {
throw new Error('Já há uma sessão aberta. Encerre a atual antes de abrir outra.');
}
const xr: XRSystem = exigirAmbiente();
// Tudo opcional, e a lista é a mesma que a sonda consulta. Pedir menos do
// que se pode usar não economiza nada; pedir qualquer item como
// obrigatório troca uma ausência observável por uma sessão que não abre.
const sessao: XRSession = await xr.requestSession(modo, {
optionalFeatures: RECURSOS_CONSULTADOS.map((recurso) => recurso.nome),
});
try {
const escolha: EscolhaDeEspaco = await escolherEspaco(sessao);
// A ordem destas duas linhas não é livre: o tipo precisa estar declarado
// antes de a biblioteca assumir a sessão, porque é ela quem pede o
// espaço ao entregar o primeiro quadro.
renderer.xr.setReferenceSpaceType(escolha.obtido);
renderer.xr.enabled = true;
await renderer.xr.setSession(sessao);
const postoAplicado: boolean = deslocarParaOPosto(renderer, escolha.obtido);
const concedidos: readonly string[] | undefined = sessao.enabledFeatures;
const aberta: SessaoAberta = {
modo,
espacoObtido: escolha.obtido,
espacosRecusados: escolha.recusados,
recursos: RECURSOS_CONSULTADOS.map((recurso) => ({
nome: recurso.nome,
paraQueServe: recurso.paraQueServe,
estado: estadoDoRecurso(recurso.nome, concedidos),
})),
composicao: sessao.environmentBlendMode,
postoAplicado,
};
// O fim da sessão não é sempre nosso: quem usa sai pelo menu do sistema,
// o aparelho é tirado da cabeça, a bateria acaba. Ouvir o evento é o que
// separa um ambiente que volta inteiro de um que fica preso no estado de
// dentro da sessão sem estar mais nela.
sessao.addEventListener('end', encerrada, { once: true });
sessaoAtual = sessao;
resumo = aberta;
ultimaFalha = undefined;
for (const ouvinte of ouvintesDeAbertura) {
ouvinte(aberta);
}
return aberta;
} catch (erro: unknown) {
// A sessão subiu e a preparação falhou. Deixá-la viva prenderia o visor
// numa tela vazia, e quem está com o aparelho no rosto só sai pelo menu
// do sistema.
ultimaFalha = erro instanceof Error ? erro.message : String(erro);
await sessao.end();
throw erro;
}
},
async sair(): Promise<void> {
if (sessaoAtual === undefined) {
return;
}
await sessaoAtual.end();
},
ativa(): boolean {
return sessaoAtual !== undefined;
},
aberta(): SessaoAberta | undefined {
return resumo;
},
aoAbrir(ouvinte: (aberta: SessaoAberta) => void): void {
ouvintesDeAbertura.push(ouvinte);
},
aoFechar(ouvinte: () => void): void {
ouvintesDeFechamento.push(ouvinte);
},
linhas(): string[] {
const atual: SessaoAberta | undefined = resumo;
if (atual === undefined) {
const estado: string =
ultimaFalha === undefined
? 'Nenhuma sessão foi aberta ainda. O regime em janela não precisa de sessão alguma.'
: `A última tentativa não abriu sessão: ${ultimaFalha}`;
return [
estado,
`Contexto seguro: ${window.isSecureContext ? 'sim' : 'não — e sem ele não há interface de realidade estendida'}.`,
`Interface de realidade estendida exposta: ${navigator.xr === undefined ? 'não' : 'sim'}.`,
];
}
const linhas: string[] = [
`Sessão ${atual.modo}, composição informada ${atual.composicao}.`,
`Espaço de referência obtido: ${atual.espacoObtido}.`,
atual.espacosRecusados.length === 0
? 'Nenhum espaço mais exigente foi recusado: veio o primeiro que se pediu.'
: `Recusados antes dele: ${atual.espacosRecusados.join(', ')}.`,
atual.postoAplicado
? `Origem deslocada ${RECUO_DO_POSTO_M.toFixed(2)} m à frente da bancada: quem entra ` +
'chega de pé diante do tampo, e não dentro dele.'
: 'A origem NÃO foi deslocada, e quem entrar nasce sobre a bancada. É limite do ' +
'espaço concedido, e está aqui para ser lido, não escondido.',
'',
];
for (const recurso of atual.recursos) {
linhas.push(`${recurso.nome} [${recurso.estado}] — ${recurso.paraQueServe}.`);
}
return linhas;
},
};
}O arquivo não conhece a cena, não conhece peça alguma e não decide nada sobre a montagem. Ele pede a sessão, negocia o que dá para negociar, e avisa quem quiser saber. Quem liga o aviso ao resto do ambiente é a composição, e são duas linhas.
1.3.1 A mensagem que nomeia o endereço
Fora de contexto seguro, o navegador não expõe a interface de realidade estendida. Não é que ela responda “não”; ela não existe, e o objeto que a carregaria vem indefinido.
Repare no que isso produz. O sintoma é idêntico ao de um aparelho sem suporte algum, e a mensagem de erro do navegador não menciona o endereço em lugar nenhum. Quem lê conclui que o visor não serve, troca de visor, e o segundo se comporta igual.
Por isso a mensagem escrita aqui nomeia a causa em vez do sintoma. Ela diz que o problema é o endereço, manda servir por HTTPS e lembra do aviso do certificado, que precisa ser aceito em cada aparelho. O tempo que essa frase economiza numa turma de 40 estudantes é a tarde inteira.
1.3.2 Tudo opcional, e a razão não é modéstia
Todo recurso vai no pedido como opcional. Nenhum vai como obrigatório, e essa decisão tem consequência visível.
Um recurso obrigatório que o aparelho não tenha derruba a sessão inteira. O que chega a quem usa, nesse caso, é um botão que não faz nada — e a diferença entre “não funciona aqui” e “aqui falta a detecção de superfície” é a diferença entre desistir e saber o que fazer.
Pedindo tudo como opcional, a sessão sobe e a ausência fica observável. O relatório então lista cada recurso como concedido, negado ou indeterminado, e o terceiro estado é o que mais evita erro. A sessão não é obrigada a reportar o que ligou, e um aparelho calado apareceria, sem ele, como aparelho que negou tudo.
1.3.3 O espaço de referência, do melhor ao mínimo
Onde fica a origem do mundo virtual? A pergunta parece técnica e o efeito dela é imediatamente físico.
Com origem no chão da sala, a bancada nasce na altura certa, porque a altura declarada dela passa a ser medida contra o mesmo piso em que a pessoa está de pé. Com origem na cabeça de quem entrou, a mesma bancada aparece na cintura de quem é alto e no peito de quem não é. O ambiente é o mesmo, e a experiência é outra.
A escolha é feita por tentativa, do mais exigente ao mínimo garantido, e a recusa é a resposta. Pedir um espaço que o aparelho não oferece é o modo de perguntar a ele o que oferece. O último da fila é garantido pela especificação, então a sequência sempre termina com resposta — e o relatório mostra qual veio e quais foram recusados antes dela.
1.3.4 O posto de trabalho, e por que se move o mundo
Quem entra pela primeira vez nasce dentro da bancada. O tampo fica na altura do peito, as peças ficam atrás da cabeça, e a primeira reação de todo grupo é achar que a cena está errada.
A cena não está errada. A origem do espaço de referência é o ponto em que a pessoa está, e a origem da bancada é o centro do tampo, então os dois coincidem. No desktop isso nunca apareceu porque lá a câmera nasce a um metro e meio de distância, posicionada por código.
E aqui está a decisão que separa este módulo dos anteriores. No regime em janela, quem se move é a câmera. No imersivo não há câmera a mover, e a saída é deslocar a origem do espaço de referência: a pessoa passa a estar a 1,15 m à frente da bancada, com folga de 75 cm até a borda do tampo. A conta do sinal está comentada no arquivo, porque é ela que se erra — o deslocamento informa onde fica a origem nova, e as poses saem com ele invertido.
1.3.5 A saída que nem sempre é nossa
Sair da sessão é parte do ciclo, e a metade dele que menos se testa.
O fim da sessão frequentemente não é pedido pelo programa. A pessoa sai pelo menu do sistema, tira o aparelho da cabeça, deixa a bateria acabar. Um ambiente que só reaja ao próprio botão fica preso no estado de dentro da sessão sem estar mais nela, e volta quebrado.
Há ainda um efeito que só se descobre saindo. Durante a sessão, 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. A órbita continua achando que a deixou olhando para a bancada, e o desktop volta olhando para o teto. O primeiro arrasto conserta sozinho, e é isso que faz o defeito parecer intermitente.
1.4 Tarefa 2: Ligar o segundo back-end à mesma abstração
A tarefa fecha quando a mesma peça é apanhada pelo raio do controle com o código que já a apanhava pelo cursor.
Enunciado da tarefa
O controle rastreado entra como mais uma implementação da abstração de apontar, e a lógica de seleção, agarre e encaixe permanece sem alteração. É aqui que a economia do capítulo anterior se paga ou se revela ilusória.
A tarefa fecha quando a mesma peça é apanhada pelo raio do controle exatamente com o código que já a apanhava pelo cursor. Qualquer ramificação por aparelho dentro da lógica de manipulação é sinal de que a abstração ficou pela metade.
src/bancada/input/controle.ts
// ---------------------------------------------------------------------------
// O segundo plugue: o controle rastreado.
//
// Este arquivo é a cobrança da aposta feita dois módulos atrás. A abstração de
// apontar foi desenhada quando havia uma fonte só, e o argumento para desenhá-la
// assim mesmo era este momento: ligar o controle sem tocar em nenhuma linha de
// seleção, de agarre ou de montagem.
//
// A conta se confere pela lista de arquivos alterados no módulo. Se a camada de
// interação aparecer nela, a economia era imaginária.
//
// Duas coisas mudam de verdade em relação ao cursor, e as duas são do aparelho,
// não do desenho.
//
// A pose passa a existir. O controle é um objeto rastreado no espaço, com
// posição e orientação, e é por isso que a peça apanhada por ele herda os seis
// graus de liberdade em vez de deslizar sobre uma esfera. O campo opcional da
// abstração deixa de estar vazio, e é a primeira vez no percurso em que isso
// acontece.
//
// E passam a existir DOIS ponteiros ao mesmo tempo. A camada de seleção lê o
// primeiro ponteiro ativo da lista, e a regra de qual deles manda foi anunciada
// lá como sendo escrita aqui: a lista sai ordenada pelo acionamento mais
// recente. Quem apertou o gatilho por último está na frente. Escrever essa
// regra na camada de cima faria a seleção passar a contar controles, que é
// exatamente o conhecimento que ela não pode ter.
// ---------------------------------------------------------------------------
import {
Object3D,
Quaternion,
Vector3,
type WebGLRenderer,
type XRTargetRaySpace,
} from 'three';
import type { FonteDePonteiro, Ponteiro } from './ponteiro';
/** Quantos controles a fonte acompanha. Dois é o baseline dos aparelhos da turma. */
const CONTROLES_ACOMPANHADOS: number = 2;
/**
* O eixo do raio de mira, no espaço do próprio controle.
*
* A especificação define o raio saindo pelo eixo negativo de profundidade do
* espaço de mira — a mesma convenção da câmera. Escrever o vetor aqui, com o
* sinal à vista, evita o erro que faz a mira apontar para trás e que quem testa
* atribui ao rastreamento do aparelho.
*/
const EIXO_DA_MIRA: Vector3 = new Vector3(0, 0, -1);
/** Estado de um controle entre um quadro e o seguinte. */
interface Plugue {
readonly indice: number;
readonly no: XRTargetRaySpace;
conectado: boolean;
lado: string;
/** Gatilho apertado. Estado mantido, ao contrário do pulso do cursor. */
acionado: boolean;
/** Botão de pega, quando o aparelho o separa do gatilho. */
apertando: boolean;
/** Ordem de acionamento. Quem acionou por último tem o número maior. */
ordem: number;
}
// Temporários de leitura. A leitura acontece uma vez por quadro e por controle,
// e alocar vetor novo a cada uma delas põe trabalho no coletor de lixo bem no
// laço que menos pode engasgar — no visor, engasgo é desconforto físico.
const posicao: Vector3 = new Vector3();
const orientacao: Quaternion = new Quaternion();
const direcao: Vector3 = new Vector3();
/**
* Instala a fonte dos controles rastreados sobre o renderizador e a raiz da cena.
*
* Os nós precisam pertencer à árvore desenhada, e não por serem visíveis — eles
* não desenham nada. É a travessia da cena que atualiza a matriz de mundo deles,
* e um controle fora da árvore devolve pose parada no lugar em que nasceu.
*/
export function montarFonteDeControles(
renderer: WebGLRenderer,
raiz: Object3D,
): FonteDePonteiro {
let contadorDeOrdem: number = 0;
const plugues: Plugue[] = [];
const desligadores: (() => void)[] = [];
for (let indice: number = 0; indice < CONTROLES_ACOMPANHADOS; indice += 1) {
const no: XRTargetRaySpace = renderer.xr.getController(indice);
const plugue: Plugue = {
indice,
no,
conectado: false,
lado: 'desconhecido',
acionado: false,
apertando: false,
ordem: 0,
};
function aoConectar(evento: { data: XRInputSource }): void {
// A declaração do modo de raio entrou quando o terceiro plugue chegou.
// Enquanto só havia controles, toda fonte de entrada da sessão era
// controle e a verificação seria ruído; com o dedo na mesma sessão
// aumentada, cada plugue passa a dizer o que atende — sem isso o mesmo
// toque seria lido duas vezes, uma como dedo e outra como controle
// rastreado que ninguém está segurando.
if (evento.data.targetRayMode !== 'tracked-pointer') {
return;
}
plugue.conectado = true;
plugue.lado = evento.data.handedness;
}
function aoDesconectar(): void {
// Controle que sai do rastreamento não larga a peça sozinho: quem decide
// isso é a camada de manipulação, e ela decide pelo estado do agarre. O
// que a fonte faz é parar de responder, e o ponteiro simplesmente some da
// lista.
plugue.conectado = false;
plugue.acionado = false;
plugue.apertando = false;
}
function aoAcionar(): void {
plugue.acionado = true;
contadorDeOrdem += 1;
plugue.ordem = contadorDeOrdem;
}
function aoSoltar(): void {
plugue.acionado = false;
}
function aoApertar(): void {
plugue.apertando = true;
contadorDeOrdem += 1;
plugue.ordem = contadorDeOrdem;
}
function aoAfrouxar(): void {
plugue.apertando = false;
}
no.addEventListener('connected', aoConectar);
no.addEventListener('disconnected', aoDesconectar);
no.addEventListener('selectstart', aoAcionar);
no.addEventListener('selectend', aoSoltar);
no.addEventListener('squeezestart', aoApertar);
no.addEventListener('squeezeend', aoAfrouxar);
desligadores.push((): void => {
no.removeEventListener('connected', aoConectar);
no.removeEventListener('disconnected', aoDesconectar);
no.removeEventListener('selectstart', aoAcionar);
no.removeEventListener('selectend', aoSoltar);
no.removeEventListener('squeezestart', aoApertar);
no.removeEventListener('squeezeend', aoAfrouxar);
raiz.remove(no);
});
raiz.add(no);
plugues.push(plugue);
}
function lerPlugue(plugue: Plugue): Ponteiro {
plugue.no.getWorldPosition(posicao);
plugue.no.getWorldQuaternion(orientacao);
direcao.copy(EIXO_DA_MIRA).applyQuaternion(orientacao).normalize();
return {
id: `controle-${plugue.indice}-${plugue.lado}`,
regime: 'imersivo',
origem: posicao.clone(),
direcao: direcao.clone(),
// Estado mantido, e não pulso: o gatilho fica verdadeiro enquanto o dedo
// aperta. A camada de seleção lê a borda de subida e produz uma escolha
// só, exatamente como produz com o pulso do cursor — e é por isso que ela
// não precisa saber qual dos dois está ligado.
acionado: plugue.acionado,
// A pega vem do gatilho mantido, e o botão de pega soma a ele onde o
// aparelho o separa. Um aperto qualquer segura a peça, e não há gesto a
// decorar por aparelho.
agarre: plugue.acionado || plugue.apertando,
estado: plugue.acionado ? 'acionado' : 'apontando',
// O campo que estava vazio desde o módulo da abstração. Com ele, a peça
// apanhada acompanha os seis graus de liberdade da mão.
pose: {
posicao: posicao.clone(),
orientacao: orientacao.clone(),
},
};
}
return {
nome: 'controles rastreados do visor',
regime: 'imersivo',
ler(): readonly Ponteiro[] {
// Fora de sessão os nós existem e não são rastreados. Devolver a pose
// deles aqui entregaria dois ponteiros parados na origem do mundo, e o
// realce ficaria aceso em cima do que estivesse perto dela.
if (!renderer.xr.isPresenting) {
return [];
}
const ativos: Plugue[] = plugues.filter((plugue) => plugue.conectado);
// O acionamento mais recente vai para a frente da lista. É esta linha, e
// não um ramo na camada de interação, que responde a "com dois controles,
// qual deles manda".
ativos.sort((a: Plugue, b: Plugue) => b.ordem - a.ordem);
return ativos.map(lerPlugue);
},
encerrar(): void {
for (const desligar of desligadores) {
desligar();
}
desligadores.length = 0;
plugues.length = 0;
},
};
}O arquivo traduz aparelho em raio de mundo, e nada mais. A estrutura dele é a mesma do cursor: instala ouvintes, guarda estado entre quadros, devolve uma leitura. Quem lê os dois lado a lado encontra a mesma forma com outro conteúdo, e é isso que a abstração deveria produzir.
1.4.1 O campo que estava vazio
A pose deixa de ser opcional na prática. Ela era, desde o módulo da abstração, um campo que a tabela declarava existir só no controle rastreado, e até agora nenhuma fonte o preenchia.
O efeito é imediato na camada que não mudou. Com pose, o nó portador do agarre passa a ser a mão de verdade, e a peça herda os seis graus de liberdade sem uma linha nova. Sem pose, ela continua deslizando sobre a esfera de raio fixo, como fazia no cursor.
Vale insistir num ponto que o módulo anterior já anunciou. As duas coisas não são equivalentes, e a tabela de campos existe justamente para dizer isso por escrito antes de alguém descobrir na demonstração. A tomada uniformiza a interface, e não os aparelhos.
1.4.2 Dois ponteiros, e quem manda
O visor traz dois controles. A camada de seleção lê o primeiro ponteiro ativo da lista, e essa regra foi escrita quando não havia dois de nada para exercitá-la.
Ela continua valendo sem alteração, porque a regra de qual controle manda foi escrita aqui, e não lá. A lista sai ordenada pelo acionamento mais recente: quem apertou o gatilho por último aparece na frente. Trocar de mão no meio da montagem funciona, e a seleção nunca soube que havia mão alguma.
A alternativa mediria mal o custo. Bastaria uma comparação na camada de seleção para resolver o caso hoje, e essa comparação obrigaria a camada a contar controles — o conhecimento que ela não pode ter. O terceiro plugue chegaria depois, com outra regra, e a comparação viraria uma cadeia.
1.4.3 O cursor que não sabe que saiu de cena
Há um defeito silencioso escondido aqui, e ele não aparece no desktop nem no visor isoladamente. Aparece nos dois ao mesmo tempo.
Com a sessão aberta, o cursor continua achando que está sobre a superfície de desenho. A última posição conhecida dele é de antes de a pessoa pôr o visor, e nada lhe disse que a cena mudou de lugar. Ele segue devolvendo um ponteiro ativo a cada quadro, e ele disputa a mira com o controle rastreado.
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}`
);
}A correção é uma linha no conjunto de fontes, e a escolha do lugar é a de sempre. A tomada aprende qual plugue está vivo e passa a ler apenas as fontes do regime em uso. A lógica de montagem continua sem ter como perguntar quantas fontes existem, e o cursor não ganhou um teste sobre sessão dentro de si.
1.5 Tarefa 3: Desenhar a fronteira com a plataforma
A pergunta chega sempre na mesma forma: o que exatamente vocês implementaram?
Enunciado da tarefa
Parte do trabalho é feita pelo navegador e parte é escrita à mão, e confundir as duas é o que impede alguém de responder ao que exatamente o projeto implementou.
O que fica pronto é o registro dessa fronteira: o que a plataforma resolve, o que foi negociado com ela e o que o ambiente faz por conta própria. Essa página é a que se defende quando a pergunta chega.
src/bancada/modes/plataforma.ts
// ---------------------------------------------------------------------------
// A fronteira com a plataforma: o que o navegador resolve, o que foi negociado
// com ele e o que este projeto escreveu.
//
// Existe uma fronteira parecida escrita dois módulos atrás, e as duas não são a
// mesma. Aquela separa biblioteca de projeto — o que se instala do que se
// digita. Esta separa PLATAFORMA de projeto, e a diferença é que a plataforma
// não se instala, não se troca e não se lê: ela está no aparelho, e o que se faz
// com ela é pedir.
//
// A pergunta que este arquivo responde é a que chega na arguição, e ela é sempre
// a mesma: o que exatamente vocês implementaram? Quem não sabe traçar esta linha
// erra para os dois lados, e os dois erros custam. Superestimar leva a defender
// como conquista o rastreamento da cabeça, que veio pronto. Subestimar leva a
// tratar como detalhe a abstração de apontar, que é o trabalho de verdade.
//
// A terceira coluna é a que mais se esquece e a que mais vale. Negociado não é
// nem uma coisa nem outra: é o aparelho que decide, o projeto que pede, e a
// resposta que muda de aparelho para aparelho na mesma turma.
// ---------------------------------------------------------------------------
/** De quem é cada parte do que o ambiente faz. */
export type Responsavel =
| 'plataforma' // vem do navegador e do aparelho; não se escreve nem se troca
| 'negociado' // o projeto pede, o aparelho concede ou recusa
| 'projeto'; // escrito aqui, linha por linha
export interface ItemDaPlataforma {
readonly assunto: string;
readonly responsavel: Responsavel;
/** Como se comprova, no ambiente em execução, que a linha está no lugar certo. */
readonly comoSeVerifica: string;
/** O que se vê quando esta parte falta ou é recusada. */
readonly quandoFalta: string;
}
/**
* A fronteira, item a item.
*
* A ordem é a do fluxo de uma sessão: primeiro o que precisa existir antes de
* qualquer pedido, depois o pedido, depois o quadro, e por último o que o
* projeto faz com o quadro que recebeu.
*/
export const FRONTEIRA_COM_A_PLATAFORMA: readonly ItemDaPlataforma[] = [
{
assunto: 'expor a interface de realidade estendida à página',
responsavel: 'plataforma',
comoSeVerifica: 'a folha da sessão informa se a interface está exposta e se o contexto é seguro',
quandoFalta:
'não há sessão possível, e o sintoma é idêntico ao de um aparelho sem suporte — ' +
'por isso a mensagem escrita aqui nomeia o endereço, e não o aparelho',
},
{
assunto: 'exigir gesto de quem usa para abrir sessão imersiva',
responsavel: 'plataforma',
comoSeVerifica: 'a sessão só é pedida dentro do tratamento do toque no botão',
quandoFalta:
'o pedido feito ao carregar a página é recusado, e a recusa se parece com defeito do código',
},
{
assunto: 'conceder ou recusar os recursos opcionais pedidos',
responsavel: 'negociado',
comoSeVerifica:
'a folha da sessão lista cada recurso como concedido, negado ou indeterminado, ' +
'a partir do que a própria sessão reportou',
quandoFalta:
'a sessão sobe sem o recurso e o ambiente segue de pé — que é o motivo de tudo ' +
'ser pedido como opcional',
},
{
assunto: 'conceder o espaço de referência com origem no chão da sala',
responsavel: 'negociado',
comoSeVerifica: 'a folha da sessão nomeia o espaço obtido e os que foram recusados antes dele',
quandoFalta:
'a origem passa a ser a altura em que a cabeça estava ao entrar, e a bancada nasce ' +
'na cintura de quem é alto e no peito de quem não é',
},
{
assunto: 'rastrear a pose da cabeça e a dos controles',
responsavel: 'plataforma',
comoSeVerifica: 'a folha do apontamento mostra pose preenchida enquanto a sessão está aberta',
quandoFalta:
'não há o que este projeto possa fazer: rastreamento é sensor e algoritmo do aparelho, ' +
'e nenhuma linha daqui o substitui',
},
{
assunto: 'compor a imagem para os dois olhos e corrigir a distorção das lentes',
responsavel: 'plataforma',
comoSeVerifica:
'a aferição dos limites do regime em janela passa de uma vista para duas assim que ' +
'a sessão abre, sem uma linha de projeção escrita aqui',
quandoFalta:
'nada: não existe caminho em que isso falte e a sessão exista — é a plataforma que ' +
'define o que é uma sessão imersiva',
},
{
assunto: 'entregar o próximo quadro na cadência do visor',
responsavel: 'plataforma',
comoSeVerifica:
'o laço não mudou uma linha entre os dois regimes, e o painel continua medindo o custo do quadro',
quandoFalta:
'o ambiente congela dentro do visor com a página respondendo normalmente — o defeito ' +
'que o laço escrito seis módulos atrás existe para não acontecer',
},
{
assunto: 'deslocar a origem para o posto de trabalho diante da bancada',
responsavel: 'projeto',
comoSeVerifica: 'quem entra chega de pé diante do tampo, e não dentro dele',
quandoFalta: 'a pessoa nasce sobre a bancada, com as peças atrás da cabeça',
},
{
assunto: 'a abstração de apontar e as implementações que a alimentam',
responsavel: 'projeto',
comoSeVerifica:
'a lista de arquivos alterados neste módulo não inclui nenhum da camada de interação',
quandoFalta:
'um caminho de código por aparelho, descoberto quando o terceiro chegar e já for tarde',
},
{
assunto: 'decidir o que foi mirado, escolhido, apanhado e encaixado',
responsavel: 'projeto',
comoSeVerifica: 'a mesma peça é apanhada pelo raio do controle com o código que a apanhava pelo cursor',
quandoFalta: 'a interação passa a ter o formato do exemplo da dependência, e não o da montagem',
},
{
assunto: 'seguir de pé no aparelho que não entra em sessão alguma',
responsavel: 'projeto',
comoSeVerifica: 'a bancada é montável inteira no regime em janela, sem qualquer sessão aberta',
quandoFalta:
'o ambiente vira demonstração de visor, e a maior parte da turma assiste em vez de usar',
},
];
/** Uma linha por item, com o responsável, a verificação e o custo da ausência. */
export function linhasDaPlataforma(): string[] {
return FRONTEIRA_COM_A_PLATAFORMA.map(
(item: ItemDaPlataforma) =>
`${item.assunto} — ${item.responsavel}. ` +
`Verifica-se assim: ${item.comoSeVerifica}. ` +
`Faltando: ${item.quandoFalta}.`,
);
}
/** Quantos itens cabem a cada responsável. É o resumo que a arguição pede primeiro. */
export function contagemDaFronteira(): string {
const conta = (quem: Responsavel): number =>
FRONTEIRA_COM_A_PLATAFORMA.filter((item) => item.responsavel === quem).length;
return (
`${conta('plataforma')} itens são da plataforma, ${conta('negociado')} são negociados com ela ` +
`e ${conta('projeto')} foram escritos neste projeto.`
);
}Já existe uma fronteira escrita dois módulos atrás, e as duas não são a mesma. Aquela separa biblioteca de projeto — o que se instala do que se digita. Esta separa plataforma de projeto, e a diferença é que a plataforma não se instala nem se troca: ela está no aparelho, e o que se faz com ela é pedir.
1.5.1 Os dois erros, e o que cada um custa
Quem não sabe traçar a linha erra para os dois lados, e os dois erros aparecem na mesma banca.
Superestimar leva a defender como conquista o rastreamento da cabeça, que veio pronto no aparelho e não custou uma linha. Quem ouve percebe, e a partir dali passa a duvidar do resto — inclusive do que é mérito de verdade.
Subestimar custa o mesmo e é mais comum entre os grupos bons. Eles tratam como detalhe a abstração de apontar, porque ela não impressiona em vídeo, e entregam como trabalho o que a plataforma fez. É a decisão de arquitetura mais cara do percurso, e ela desaparece justamente por funcionar em silêncio.
1.5.2 A coluna que ninguém escreve
Três valores, e não dois. O terceiro é o que dá utilidade ao registro.
Negociado não é da plataforma nem do projeto: o projeto pede, o aparelho concede ou recusa, e a resposta muda de aparelho para aparelho na mesma turma. Espaço de referência com origem no chão é negociado. Detecção de superfície é negociada. Permissão para abrir a sessão é negociada com quem usa, e não com o aparelho.
Sem essa coluna, cada linha teria de escolher um lado errado. O grupo escreveria que a origem no chão é da plataforma, o que é falso no aparelho que a recusa, ou que é do projeto, o que é falso em qualquer aparelho.
1.5.3 Cada linha com a verificação ao lado
Cada item declara como se comprova, no ambiente em execução, que ele está do lado em que foi posto.
A razão é a mesma que fez os limites do regime em janela virarem dado tipado, e não parágrafo em documento à parte. Declaração que ninguém confronta envelhece bem demais: continua parecendo certa depois de ter deixado de ser. Aqui, a composição estereoscópica se verifica pela contagem de vistas que a placa desenha, e o laço se verifica pela lista de arquivos que este módulo alterou.
Escada rolante parada ainda é escada; ambiente que só existe com o visor no rosto não é nada sem ele. O último item da fronteira é esse, e ele é do projeto. A bancada continua montável inteira em janela, sem sessão alguma — o que, com três visores para a turma, não é virtude teórica.
1.6 A composição, e as duas linhas que ela acrescenta
O módulo inteiro entra no ambiente por duas linhas, e é assim que se sabe quanto ele custou.
O ciclo de sessão avisa quando abre e quando fecha. A composição escuta os dois avisos e faz, em cada um, uma coisa só.
Ao abrir, ela diz à tomada que o regime passou a ser o imersivo. Ao fechar, diz que voltou a ser o da janela e manda a órbita reescrever a câmera a partir do estado que ela sempre guardou. Nada mais muda: nem o laço, nem a seleção, nem o agarre, nem a montagem.
A fonte dos controles se registra ao lado da fonte do cursor, numa linha, exatamente onde o módulo anterior previu que ela se registraria. Ela fica em silêncio enquanto o regime for o da janela, e passa a falar quando a sessão abrir.
Vale registrar por que o teclado continua na composição. Girar por tecla foi o remendo do regime em janela, onde não há pose de mão; com o controle ligado, ele deixa de ser necessário e não é removido. Fica útil onde era útil e irrelevante onde a orientação vem da mão, que é a forma correta de um remendo envelhecer.
1.7 O que este módulo deixa pronto
A aposta dos dois módulos anteriores foi cobrada, e o recibo é a lista de arquivos que não mudaram.
A Bancada entra em sessão e sai dela. Negocia o que pode negociar, informa o que foi concedido e o que foi recusado, e diz por que não abriu quando não abre. Dentro do visor, a mesma engrenagem é apanhada pelo raio do controle, girada pela mão e encaixada pela máquina de estados que nunca soube da existência de um visor.
Vale registrar o que não entrou, porque a lista é grande e nada nela é pendência deste módulo. Não há locomoção: a pessoa alcança o que está ao alcance do braço, e caminhar pela sala física é o que existe até o módulo que trata disso. Não há conforto medido, não há vinheta, não há teleporte. A entrada em realidade aumentada abre a sessão e ainda não registra nada contra superfície real — o retículo, a âncora e o marcador impresso são trabalho adiante.
E há um limite honesto sobre a verificação. O que se confere no desktop e no emulador é o ciclo de sessão e a geometria da cena. Conforto, escala corporal e registro contra superfície física não se conferem ali, e tratá-los como verificados porque o emulador não reclamou seria o engano que o percurso inteiro tenta desmontar.