flowchart TD
A["executarSonda()"] --> B["sondar()"]
B --> C["sondarSemSessao()"]
C -->|"reusa o módulo anterior"| D["levantarRelatorio()"]
D --> E["três linhas: inline 'sim', immersive-vr 'nao', immersive-ar 'nao'"]
E --> F["modosSuportados = ['inline']"]
F --> G["modoPreferido()"]
G --> H{"modo === undefined"}
H -->|"true — o caminho desta execução"| I["classificarAparelho()"]
H -->|"false — ramo com sessão, não tomado aqui"| J["ramo imersivo, que exige visor na sala"]
I --> K["classe = 'somente-janela'"]
K --> L["ResultadoDaSonda com emSessao undefined<br/>e motivoSemSessao preenchido"]
L --> M["montarSonda()"]
M --> N["descreverClasse()"]
N --> O{"resultado.emSessao === undefined"}
O -->|"true"| P["paragrafo() com motivoSemSessao, e retorno"]
1 Dispositivos, rastreamento e graus de liberdade — 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 contestado — nunca para ser copiado. O que está aqui é uma solução defensável, com cada decisão explicada e assinada; a solução de cada grupo será outra, e terá de ser defendida do mesmo jeito.
1.1 Visão geral
Segundo módulo, segundo artefato que não desenha um triângulo. Desta vez é o assunto que exige isso.
O módulo anterior perguntou ao navegador quais regimes ele dizia suportar, e a pergunta foi feita de fora de qualquer sessão. A resposta que voltou era grossa e tinha três valores: sim, não, não sei. Aqui a sessão abre de verdade, e o que se descobre lá dentro tem outra granularidade — quais espaços de referência o aparelho entrega quando alguém os pede, quais recursos opcionais foram concedidos, o que a plataforma faz com o fundo, quantos quadros chegam sem que o aparelho saiba onde está a cabeça de quem observa.
A distância entre as duas respostas é o conteúdo deste módulo. Um aparelho declara suporte a um modo e, uma vez dentro dele, nega metade do que se pediu, sem justificativa — a especificação não obriga ninguém a dar uma. Quem projeta contra o que supõe existir descobre o engano três módulos adiante, quando o teleporte não encontra chão e o rastro do defeito já passou por quatro arquivos.
As duas tarefas do módulo resolvem exatamente isso. A primeira constrói a consulta e guarda o resultado numa estrutura que o resto do ambiente possa ler. A segunda coloca esse resultado onde alguém consiga lê-lo com o aparelho no rosto, que é uma condição menos óbvia do que parece e mais decisiva do que a primeira.
Além delas, o módulo tem teoria que não cabe em tarefa nenhuma: graus de liberdade, a diferença entre rastrear por dentro e rastrear por fora, e o mapeamento simultâneo do ambiente. Nenhuma das três se resolve com uma função, e as três aparecem abaixo — duas encarnadas em código que já existe por outro motivo, uma declarada como tópico sem implementação em vez de disfarçada num exemplo fabricado.
1.2 Graus de liberdade, e o que a plataforma se recusa a responder
Seis graus de liberdade: três de rotação, três de translação. O visor que acompanha apenas os três primeiros gira com a cabeça de quem o veste e ignora o passo à frente — a cena permanece imóvel enquanto o corpo se desloca, e o desacordo entre o que o ouvido interno relata e o que os olhos veem produz mal-estar em minutos. É o defeito da geração de visores que fez a área ganhar má fama antes de merecê-la.
Agora a parte incômoda. A API não informa quantos graus o aparelho rastreia. Não há propriedade a ler, e a omissão tem razão: o navegador não sabe o que o sensor faz, sabe o que o sistema do aparelho aceitou entregar. O que sobra para inferir é o conjunto de espaços de referência concedidos, e a inferência tem limite.
a regra da inferência, em src/bancada/devices/graus.ts
/**
* Regra da inferência, escrita por extenso porque é ela que você precisa
* poder contestar:
*
* - `local-floor` ou `bounded-floor` concedidos exigem que o aparelho saiba onde
* está o chão em relação a quem observa. Um visor que só gira não tem como
* sustentar isso, então a concessão é evidência forte de posição rastreada.
* - `viewer` sozinho é o mínimo que qualquer sessão entrega: a origem acompanha
* quem observa, e translação alguma é observável a partir dela. Evidência forte
* da ausência.
* - `local` no meio do caminho é ambíguo de verdade. A especificação o descreve
* como origem próxima de quem observa no início da sessão, e um aparelho de três
* graus pode concedê-lo mantendo a posição sempre na origem. Daí
* `indeterminado`, e não uma aposta.
*/A regra está escrita por extenso dentro do próprio arquivo, e essa escolha é de projeto: quem estuda o código precisa poder contestar a inferência, e para contestá-la precisa vê-la. Um espaço de referência com origem no chão exige que o aparelho saiba onde o chão está em relação a quem observa, e um visor que apenas gira não tem como sustentar isso. Um espaço cuja origem acompanha quem observa é o mínimo que qualquer sessão entrega, e translação alguma se observa a partir dele.
O caso do meio é o que decide a qualidade do relatório. O espaço local, com origem próxima de quem observa no início da sessão, pode ser concedido por um aparelho de três graus que mantenha a posição sempre na origem. A evidência ali é fraca dos dois lados, e a função devolve isso em vez de escolher um lado.
a inferência, em src/bancada/devices/graus.ts
export function grausDeLiberdade(leitura: LeituraDeEspacos): GrausDeLiberdade {
const temChao: boolean =
leitura.concedidos.includes('local-floor') ||
leitura.concedidos.includes('bounded-floor') ||
leitura.concedidos.includes('unbounded');
if (temChao) {
return 'seis';
}
if (leitura.concedidos.length === 1 && leitura.concedidos[0] === 'viewer') {
return 'tres';
}
return 'indeterminado';
}O terceiro valor de retorno é a decisão que mais rende no módulo inteiro. Um relatório que afirma “seis graus de liberdade” sobre evidência fraca será citado por quem o ler; um relatório que diz “daqui não dá para saber” será verificado. A segunda reação é a que se quer produzir, e ela custa um caso a mais no tipo de retorno.
O arquivo completo classifica também a classe do aparelho, e o faz sem consultar a cadeia de identificação do navegador. Aquela cadeia é editável por quem usa, imitada por aparelhos que querem passar por outros e envelhece a cada versão do sistema — construir sobre ela um relatório de capacidades seria escolher a fonte menos confiável disponível havendo uma melhor à mão. O que a sessão concede é o que o aparelho faz agora, na mão de quem está usando.
src/bancada/devices/graus.ts
// ---------------------------------------------------------------------------
// Graus de liberdade e classe do aparelho, inferidos do que foi concedido.
//
// A API XR não expõe um número de graus de liberdade. Não há propriedade a ler,
// e isso não é omissão: o navegador não sabe o que o sensor faz, sabe o que o
// runtime do aparelho aceitou entregar. O que existe para inferir é o conjunto de
// espaços de referência concedidos, e a inferência tem limites que este arquivo
// registra em vez de esconder.
//
// O porquê de a inferência ser conservadora: um relatório que afirma "seis graus
// de liberdade" com base em evidência fraca é pior que um relatório que diz "não
// dá para saber daqui". O primeiro será citado; o segundo, verificado.
// ---------------------------------------------------------------------------
/**
* O que se pode afirmar sobre o rastreamento de posição a partir do que a sessão
* concedeu — três graus (só orientação) contra seis (orientação e posição).
*/
export type GrausDeLiberdade = 'tres' | 'seis' | 'indeterminado';
/**
* Classe do aparelho deduzida da capacidade declarada, e não do nome que o
* navegador diz ter.
*
* A escolha é deliberada e é o conteúdo do módulo: a cadeia de identificação do
* navegador é editável, imitada por outros aparelhos e envelhece a cada versão.
* O que a sessão concede é o que o aparelho faz agora, na mão de quem está
* usando.
*/
export type ClasseDeAparelho =
| 'sem-api'
| 'somente-janela'
| 'visor-sem-posicao'
| 'visor-com-posicao'
| 'aparelho-de-mao-com-camera';
export interface LeituraDeEspacos {
/** Espaços de referência que a sessão de fato entregou quando pedidos. */
readonly concedidos: readonly string[];
/** O modo de sessão em que a leitura foi feita. */
readonly modo: 'immersive-vr' | 'immersive-ar';
/** Havia alguma fonte de entrada com pose de punho declarada. */
readonly comPoseDePunho: boolean;
}
// recorte:inicio regra-da-inferencia-por-extenso
/**
* Regra da inferência, escrita por extenso porque é ela que o estudante precisa
* poder contestar:
*
* - `local-floor` ou `bounded-floor` concedidos exigem que o aparelho saiba onde
* está o chão em relação a quem observa. Um visor que só gira não tem como
* sustentar isso, então a concessão é evidência forte de posição rastreada.
* - `viewer` sozinho é o mínimo que qualquer sessão entrega: a origem acompanha
* quem observa, e translação alguma é observável a partir dela. Evidência forte
* da ausência.
* - `local` no meio do caminho é ambíguo de verdade. A especificação o descreve
* como origem próxima de quem observa no início da sessão, e um aparelho de três
* graus pode concedê-lo mantendo a posição sempre na origem. Daí
* `indeterminado`, e não uma aposta.
*/
// recorte:fim regra-da-inferencia-por-extenso
// recorte:inicio inferir-graus-sem-apostar
export function grausDeLiberdade(leitura: LeituraDeEspacos): GrausDeLiberdade {
const temChao: boolean =
leitura.concedidos.includes('local-floor') ||
leitura.concedidos.includes('bounded-floor') ||
leitura.concedidos.includes('unbounded');
if (temChao) {
return 'seis';
}
if (leitura.concedidos.length === 1 && leitura.concedidos[0] === 'viewer') {
return 'tres';
}
return 'indeterminado';
}
// recorte:fim inferir-graus-sem-apostar
/**
* Classifica o aparelho pela combinação de modo de sessão, posição rastreada e
* tipo de mira das fontes de entrada.
*
* `modosSuportados` vem da consulta feita sem sessão alguma — a mesma do módulo
* anterior —, e por isso esta função responde mesmo quando nenhuma sessão chegou
* a abrir.
*/
export function classificarAparelho(
modosSuportados: readonly string[],
graus: GrausDeLiberdade,
temApiXr: boolean,
): ClasseDeAparelho {
if (!temApiXr) {
return 'sem-api';
}
const suportaVr: boolean = modosSuportados.includes('immersive-vr');
const suportaAr: boolean = modosSuportados.includes('immersive-ar');
if (!suportaVr && !suportaAr) {
return 'somente-janela';
}
// Aparelho que faz realidade aumentada e não faz sessão imersiva completa é o
// celular: a câmera vê o mundo, mas ninguém veste a tela no rosto.
if (suportaAr && !suportaVr) {
return 'aparelho-de-mao-com-camera';
}
return graus === 'tres' ? 'visor-sem-posicao' : 'visor-com-posicao';
}
/** Frase curta e legível para cada classe, usada no relatório. */
export function descreverClasse(classe: ClasseDeAparelho): string {
switch (classe) {
case 'sem-api':
return 'Navegador sem a API XR, ou página fora de contexto seguro.';
case 'somente-janela':
return 'Aparelho que só sustenta o regime de janela — é o caso do desktop do laboratório.';
case 'visor-sem-posicao':
return 'Visor que acompanha a rotação da cabeça e não acompanha o deslocamento.';
case 'visor-com-posicao':
return 'Visor que acompanha rotação e deslocamento, com o chão do ambiente como referência.';
case 'aparelho-de-mao-com-camera':
return 'Aparelho de mão que compõe o virtual sobre a imagem da própria câmera.';
}
}1.3 De onde vem a pose: por dentro e por fora
Há dois arranjos para descobrir onde um aparelho está, e a diferença entre eles decide preço, mobilidade e modo de falhar.
No rastreamento por infraestrutura externa, o ambiente é instrumentado: emissores fixos nas paredes, câmeras montadas em tripés ou, no caso mais antigo de todos, um braço mecânico preso ao teto. O ancestral direto do arranjo é o visor que Ivan Sutherland montou em 1968, pesado a ponto de precisar ficar suspenso do teto do laboratório por um braço mecânico articulado. O braço não estava ali apenas para segurar peso: ele media a posição da cabeça de quem vestia o aparelho. Geometria rígida conhecida no lugar de sensor que estima, com a precisão paga em imobilidade — e um apelido, “espada de Dâmocles”, que descreve com exatidão a imagem de quem entrava embaixo dele.
No rastreamento por sensores embarcados, o aparelho olha para fora e se localiza sozinho, cruzando o que as câmeras veem com o que acelerômetros e giroscópios relatam. Ele anda para onde quiser e dispensa sala preparada. Em troca, erra quando o ambiente não coopera, e os três aparelhos do laboratório são todos deste tipo.
Este tópico não vira código, e forçar um exemplo executável para ele seria fabricar conteúdo. A API XR não expõe o arranjo de rastreamento; expõe o resultado. O papel dele no projeto está na seção seguinte, como explicação do que a sonda mede: um aparelho que se localiza sozinho tem como se perder, e o braço de 1968 não tinha.
1.4 O cartógrafo no escuro, e o quadro em que ele se perde
O aparelho desenha a planta da sala enquanto atravessa a sala, e corrige o desenho toda vez que reconhece um canto por onde já passou.
A frase resolve um círculo aparente. Para saber onde está, o aparelho precisa do mapa; para desenhar o mapa, precisa saber onde está. A saída é fazer as duas coisas ao mesmo tempo, aceitando que ambas sejam estimativas revisáveis: a posição é a melhor aposta atual, o mapa é a melhor aposta atual, e o reencontro de um canto já visto corrige as duas retroativamente. Nada disso a plataforma entrega diretamente. O que ela entrega são os efeitos — o raio lançado contra uma superfície real, os planos reconhecidos no ambiente, a âncora que o aparelho corrige sozinho quando muda de ideia sobre onde as coisas estavam.
O cartógrafo também se perde, e é aqui que o módulo cobra a antecipação da falha. Superfície sem textura não oferece canto a reconhecer; iluminação pobre não oferece contraste para extrair canto algum; movimento brusco borra a imagem e o canto some entre um quadro e o seguinte. Uma parede branca bem iluminada é o pior cenário possível para um aparelho que se orienta por cantos, o que faz do laboratório recém-pintado um adversário à altura.
O sintoma disso se lê de dentro do código, sem instrumento e sem sensor extra: o quadro chega e a pose de quem observa não vem. É o único indício de degradação que a plataforma oferece, e por isso ele virou contagem.
src/bancada/devices/estabilidade.ts
// ---------------------------------------------------------------------------
// A falha como medida, e não como anedota.
//
// A pose de quem observa pode simplesmente não vir. A API prevê isso: o quadro
// entrega a pose ou entrega nada, e nada significa que o aparelho perdeu, naquele
// instante, a conta de onde está. É o único sintoma de degradação de rastreamento
// que se lê de dentro do código, sem instrumento e sem sensor extra.
//
// Este contador existe porque o módulo cobra prever a falha, e previsão que não
// se confronta com contagem alguma é opinião. Ele é deliberadamente burro: conta
// quadros com pose e quadros sem, e mais nada. A interpretação fica na função de
// diagnóstico, separada de propósito — misturar contagem e julgamento é o que
// produz medidor que sempre concorda com quem o escreveu.
// ---------------------------------------------------------------------------
export interface Estabilidade {
readonly quadros: number;
readonly quadrosSemPose: number;
/** Maior sequência ininterrupta de quadros sem pose. */
readonly maiorLacuna: number;
/** Quadros descartados por a sessão não estar em primeiro plano. */
readonly quadrosOcultos: number;
}
// recorte:inicio perda-de-pose-nao-e-menu-aberto
/**
* Acumula a contagem quadro a quadro.
*
* O parâmetro `visivel` é o que separa perda de rastreamento de sessão que saiu
* de foco. Quando quem usa abre o menu do sistema do visor, a sessão continua
* viva, os quadros continuam chegando e a pose deixa de vir — e contar isso como
* falha de sensor acusaria o aparelho de um defeito que é comportamento normal
* do sistema operacional.
*/
export class ContadorDeEstabilidade {
private quadros: number = 0;
private quadrosSemPose: number = 0;
private quadrosOcultos: number = 0;
private maiorLacuna: number = 0;
private lacunaCorrente: number = 0;
public registrar(temPose: boolean, visivel: boolean): void {
// recorte:fim perda-de-pose-nao-e-menu-aberto
this.quadros += 1;
if (!visivel) {
this.quadrosOcultos += 1;
this.lacunaCorrente = 0;
return;
}
if (temPose) {
this.lacunaCorrente = 0;
return;
}
this.quadrosSemPose += 1;
this.lacunaCorrente += 1;
if (this.lacunaCorrente > this.maiorLacuna) {
this.maiorLacuna = this.lacunaCorrente;
}
}
public resultado(): Estabilidade {
return {
quadros: this.quadros,
quadrosSemPose: this.quadrosSemPose,
maiorLacuna: this.maiorLacuna,
quadrosOcultos: this.quadrosOcultos,
};
}
}
/**
* Traduz a contagem numa frase, com as condições que a bibliografia da disciplina
* aponta como causas de degradação de rastreamento óptico: superfície sem textura
* (não há canto a reconhecer), iluminação pobre (não há contraste para extrair
* canto algum) e movimento brusco (a imagem borra e o canto some entre quadros).
*
* A frase não afirma qual das três aconteceu. Não dá para saber daqui, e escolher
* uma seria inventar o diagnóstico junto com a medida.
*/
export function diagnosticar(estabilidade: Estabilidade): string {
if (estabilidade.quadros === 0) {
return 'Nenhum quadro foi entregue — a sessão não chegou a produzir imagem.';
}
if (estabilidade.quadrosSemPose === 0) {
return 'A pose veio em todos os quadros observados. A janela de observação é curta, e ausência de falha aqui não é promessa de estabilidade em uso prolongado.';
}
const proporcao: number = Math.round(
(estabilidade.quadrosSemPose / estabilidade.quadros) * 100,
);
return (
`A pose faltou em ${proporcao}% dos quadros, com lacuna máxima de ` +
`${estabilidade.maiorLacuna} quadros seguidos. As causas prováveis são superfície ` +
'sem textura, iluminação pobre ou movimento brusco — e daqui não se distingue qual delas.'
);
}O contador é burro por decisão. Ele conta quadros com pose e quadros sem pose, e mais nada; o julgamento fica numa função separada. Misturar medição e interpretação no mesmo lugar produz o medidor que sempre concorda com quem o escreveu, e um medidor assim não serve de argumento contra ninguém — a começar por quem o programou.
a distinção que salva o diagnóstico, em src/bancada/devices/estabilidade.ts
/**
* Acumula a contagem quadro a quadro.
*
* O parâmetro `visivel` é o que separa perda de rastreamento de sessão que saiu
* de foco. Quando quem usa abre o menu do sistema do visor, a sessão continua
* viva, os quadros continuam chegando e a pose deixa de vir — e contar isso como
* falha de sensor acusaria o aparelho de um defeito que é comportamento normal
* do sistema operacional.
*/
export class ContadorDeEstabilidade {
private quadros: number = 0;
private quadrosSemPose: number = 0;
private quadrosOcultos: number = 0;
private maiorLacuna: number = 0;
private lacunaCorrente: number = 0;
public registrar(temPose: boolean, visivel: boolean): void {O parâmetro de visibilidade separa perda de rastreamento de sessão que saiu de foco. Quando quem usa abre o menu do sistema do visor, a sessão continua viva, os quadros continuam chegando e a pose deixa de vir. Contar isso como falha de sensor acusaria o aparelho de um defeito que é comportamento normal do sistema operacional, e o relatório passaria a reprovar o visor toda vez que alguém conferisse a hora.
A frase de diagnóstico também se recusa a apontar qual das três causas ocorreu. Daqui não se distingue textura de luz e de movimento, e escolher uma seria inventar o diagnóstico junto com a medida.
1.5 Tarefa 1: Construir a sonda de capacidades
Enunciado da tarefa
A primeira peça executável do ambiente não desenha coisa alguma. Ela pergunta ao aparelho o que ele oferece — que tipos de sessão suporta, que recursos opcionais concede, que fontes de entrada declara, quantos graus de liberdade rastreia — e guarda a resposta numa estrutura que o resto do ambiente possa consultar.
O que fica pronto é essa consulta feita de verdade contra o aparelho, com a distinção preservada entre recurso ausente e recurso negado. Assumir capacidade que o aparelho nunca declarou é a origem da maior parte das falhas silenciosas dos capítulos seguintes.
1.5.1 A distinção que o enunciado cobra, e que a API não entrega pronta
O enunciado pede que recurso ausente e recurso negado sejam tratados de forma diferente. A plataforma não ajuda: os dois passam pela mesma lista de recursos opcionais no pedido de sessão e, depois, simplesmente não aparecem entre os concedidos. A distinção precisa ser feita à mão, e é o que este arquivo faz.
os três estados, em src/bancada/devices/recursos.ts
/**
* O que se sabe sobre um recurso depois de a sessão abrir.
*
* - `concedido`: o nome está em `enabledFeatures`, e o recurso pode ser usado.
* - `negado`: a sessão reportou a lista e o nome não está nela — o aparelho ou o
* navegador recusou, e a razão não é exposta.
* - `indeterminado`: a sessão não reportou lista alguma. Não é negativa; é
* ausência de resposta, e tratá-la como negativa produz relatório confiante e
* errado.
*/
export type EstadoDeRecurso = 'concedido' | 'negado' | 'indeterminado';O terceiro estado dá mais trabalho que os dois primeiros somados e evita mais erro que eles. A sessão só informa o que concedeu através de uma propriedade que a especificação declara opcional — um navegador pode entrar em sessão sem dizer o que ligou. Sem o terceiro estado, esse navegador apareceria no relatório como aparelho que negou tudo, e o grupo passaria a tarde caçando um defeito de hardware inexistente.
O catálogo de recursos consultados carrega, ao lado de cada nome, o motivo de ele estar ali.
o recurso e o motivo, em src/bancada/devices/recursos.ts
O campo do motivo parece cortesia e é trava. Ele obriga quem acrescenta um recurso ao catálogo a escrever para que ele serve no percurso, e um recurso sem resposta a essa pergunta é um recurso que entrou porque o nome soava interessante. A lista é curta pela mesma razão: pedir tudo o que a especificação prevê faria a sonda demorar mais e algumas plataformas recusarem a sessão inteira por causa de um item exótico.
Um recurso ficou de fora com justificativa escrita no arquivo. A detecção de profundidade exige um dicionário próprio de configuração dentro do pedido de sessão, e um pedido malformado derruba a sessão toda em vez de negar apenas aquele item. Consultá-lo custaria o risco de perder tudo o mais, o que é um preço alto por uma linha de relatório.
src/bancada/devices/recursos.ts
// ---------------------------------------------------------------------------
// Catálogo dos recursos opcionais que a sonda consulta, e a classificação do
// que o aparelho respondeu sobre cada um.
//
// Este arquivo existe para separar duas coisas que o vocabulário corrente
// confunde: o recurso que o aparelho NÃO TEM e o recurso que ele TEM e NÃO
// CONCEDEU. A API XR trata os dois de um jeito só na hora de pedir — passam
// ambos por `optionalFeatures` e simplesmente não aparecem depois —, e é por
// isso que a distinção precisa ser feita aqui, à mão, em vez de esperada da
// plataforma.
//
// O terceiro estado é o que mais dá trabalho e o que mais evita erro:
// `indeterminado`. A sessão só reporta o que concedeu através de
// `enabledFeatures`, e essa propriedade é opcional na especificação — um
// navegador pode entrar em sessão sem dizer o que ligou. Sem o terceiro estado,
// esse navegador apareceria no relatório como aparelho que negou tudo.
// ---------------------------------------------------------------------------
// recorte:inicio ausente-negado-e-a-terceira-coisa
/**
* O que se sabe sobre um recurso depois de a sessão abrir.
*
* - `concedido`: o nome está em `enabledFeatures`, e o recurso pode ser usado.
* - `negado`: a sessão reportou a lista e o nome não está nela — o aparelho ou o
* navegador recusou, e a razão não é exposta.
* - `indeterminado`: a sessão não reportou lista alguma. Não é negativa; é
* ausência de resposta, e tratá-la como negativa produz relatório confiante e
* errado.
*/
export type EstadoDeRecurso = 'concedido' | 'negado' | 'indeterminado';
// recorte:fim ausente-negado-e-a-terceira-coisa
// recorte:inicio recurso-com-motivo-no-catalogo
/** Um recurso opcional da API XR, com o motivo de ele estar no catálogo. */
export interface RecursoOpcional {
/** O nome exato aceito por `optionalFeatures` — não traduzir. */
readonly nome: string;
/** O que ele habilita no percurso, em uma frase. */
readonly paraQueServe: string;
}
// recorte:fim recurso-com-motivo-no-catalogo
/**
* Os recursos que a Bancada consulta.
*
* A lista é curta de propósito: pedir tudo o que a especificação prevê faria a
* sonda demorar mais e algumas plataformas recusarem a sessão inteira por causa
* de um item exótico. Cada entrada aqui é um recurso de que algum módulo adiante
* realmente depende.
*
* `depth-sensing` ficou de fora, e a razão é técnica: ele exige um dicionário de
* configuração próprio no pedido de sessão (formato de dado e ordem de uso), e um
* pedido malformado derruba a sessão inteira em vez de apenas negar o recurso.
* Consultá-lo custaria, aqui, o risco de perder tudo o mais.
*/
export const RECURSOS_CONSULTADOS: readonly RecursoOpcional[] = [
{
nome: 'local-floor',
paraQueServe:
'origem no chão do espaço físico — é o que faz a bancada nascer na altura certa',
},
{
nome: 'bounded-floor',
paraQueServe:
'origem no chão mais os limites da área livre que o aparelho conhece',
},
{
nome: 'unbounded',
paraQueServe: 'espaço sem fronteira declarada, para percursos longos',
},
{
nome: 'hit-test',
paraQueServe:
'lançar um raio contra as superfícies reais que o aparelho encontrou',
},
{
nome: 'anchors',
paraQueServe:
'prender um objeto virtual a um ponto do mapa e deixar o aparelho corrigi-lo',
},
{
nome: 'plane-detection',
paraQueServe: 'receber os planos que o aparelho reconheceu no ambiente',
},
{
nome: 'hand-tracking',
paraQueServe:
'pose das mãos sem controle — fora do núcleo do percurso, e consultado só para registro',
},
];
/**
* Classifica um recurso contra a lista que a sessão reportou.
*
* `concedidos` vem de `XRSession.enabledFeatures`, que é opcional na
* especificação: `undefined` significa "esta sessão não diz", e é o que produz
* `indeterminado`.
*/
export function estadoDoRecurso(
nome: string,
concedidos: readonly string[] | undefined,
): EstadoDeRecurso {
if (concedidos === undefined) {
return 'indeterminado';
}
return concedidos.includes(nome) ? 'concedido' : 'negado';
}1.5.2 A sonda, e as duas restrições que moldam o arquivo inteiro
Duas restrições da plataforma decidem a forma deste arquivo, e nenhuma delas se contorna.
A primeira: metade das respostas só existe dentro de uma sessão. Recursos concedidos, modo de composição do fundo, espaços de referência entregues e fontes de entrada são propriedades da sessão, não do navegador. Sondar de fora devolve uma lista de suposições bem formatadas.
A segunda: abrir sessão imersiva exige gesto de quem usa. O navegador recusa o pedido que não venha de um clique, e a recusa chega como exceção com nome em inglês — o disfarce perfeito para um defeito de código que não existe. Daí a sonda ser uma função chamada por botão, em vez de algo que roda ao carregar a página.
src/bancada/devices/sonda.ts
// ---------------------------------------------------------------------------
// A sonda de capacidades — a Tarefa 1 deste módulo.
//
// Ela pergunta ao aparelho o que ele oferece e guarda a resposta numa estrutura
// que o resto do ambiente possa consultar. Não desenha, não carrega malha e não
// monta cena: o que ela produz é conhecimento sobre o aparelho, e é isso que os
// módulos seguintes consomem para decidir o que sequer tentar.
//
// Duas restrições da plataforma moldam o arquivo inteiro, e nenhuma delas é
// contornável:
//
// 1. Metade das respostas só existe DENTRO de uma sessão. Recursos concedidos,
// modo de composição do fundo, espaços de referência entregues e fontes de
// entrada são propriedades da sessão, não do navegador. Sondar de fora
// devolve uma lista de suposições.
// 2. Abrir sessão imersiva exige gesto de quem usa. O navegador recusa o pedido
// que não venha de um clique, e a recusa se parece com defeito do código.
// Daí a sonda ser função chamada por botão, e não coisa que roda ao carregar.
// ---------------------------------------------------------------------------
import { REGIMES, type Regime, type RegimeId } from '../modes/regimes';
import { levantarRelatorio, type LinhaDoRelatorio } from '../modes/verificacao';
import {
RECURSOS_CONSULTADOS,
estadoDoRecurso,
type EstadoDeRecurso,
} from './recursos';
import {
ContadorDeEstabilidade,
diagnosticar,
type Estabilidade,
} from './estabilidade';
import {
classificarAparelho,
grausDeLiberdade,
type ClasseDeAparelho,
type GrausDeLiberdade,
} from './graus';
/** Modos em que uma sessão de sondagem pode ser aberta. */
export type ModoSondavel = 'immersive-vr' | 'immersive-ar';
/** Espaços de referência que a sonda tenta obter, do mais exigente ao mínimo. */
const ESPACOS_TENTADOS: readonly XRReferenceSpaceType[] = [
'bounded-floor',
'local-floor',
'unbounded',
'local',
'viewer',
];
/** Quantos quadros a sonda observa antes de encerrar a sessão. */
const QUADROS_OBSERVADOS: number = 90;
export interface RecursoSondado {
readonly nome: string;
readonly paraQueServe: string;
readonly estado: EstadoDeRecurso;
}
export interface FonteDeEntradaSondada {
/** Lado declarado: `left`, `right` ou `none`. */
readonly lado: string;
/** Como a mira é produzida: raio de controle, olhar ou toque na tela. */
readonly mira: string;
/** Há pose de punho — objeto rastreado no espaço, não apenas uma direção. */
readonly temPoseDePunho: boolean;
/** Há pose de mão articulada. */
readonly temMao: boolean;
/** Perfis declarados pelo aparelho, do mais específico ao mais genérico. */
readonly perfis: readonly string[];
}
/** O que a sonda descobre sem abrir sessão alguma. */
export interface SondaSemSessao {
readonly temApiXr: boolean;
readonly contextoSeguro: boolean;
readonly regimes: readonly LinhaDoRelatorio[];
readonly modosSuportados: readonly string[];
}
/** O que só a sessão responde. */
export interface SondaEmSessao {
readonly modo: ModoSondavel;
readonly recursos: readonly RecursoSondado[];
readonly espacosConcedidos: readonly string[];
readonly composicaoObservada: XREnvironmentBlendMode;
readonly fontesDeEntrada: readonly FonteDeEntradaSondada[];
readonly graus: GrausDeLiberdade;
readonly estabilidade: Estabilidade;
readonly diagnostico: string;
}
export interface ResultadoDaSonda {
readonly semSessao: SondaSemSessao;
readonly emSessao: SondaEmSessao | undefined;
/** Por que não houve sessão, quando não houve. */
readonly motivoSemSessao: string | undefined;
readonly classe: ClasseDeAparelho;
}
// A API XR não é exposta fora de contexto seguro. O sintoma é idêntico ao de um
// aparelho sem suporte, e a causa é a URL — o erro de laboratório mais frequente
// do percurso, e o que mais custa tempo de aula por parecer defeito de código.
function contextoSeguro(): boolean {
return window.isSecureContext;
}
export async function sondarSemSessao(): Promise<SondaSemSessao> {
const regimes: LinhaDoRelatorio[] = await levantarRelatorio();
const suportados: string[] = regimes
.filter((linha) => linha.suporte === 'sim')
.map((linha) => linha.regime.id);
return {
temApiXr: navigator.xr !== undefined,
contextoSeguro: contextoSeguro(),
regimes,
modosSuportados: suportados,
};
}
/**
* Tenta obter cada espaço de referência e devolve os que vieram.
*
* O pedido rejeita quando o espaço não é concedido, e é essa rejeição que
* informa — o `catch` vazio aqui não esconde erro algum: ele é a leitura.
*/
async function espacosConcedidos(sessao: XRSession): Promise<string[]> {
const obtidos: string[] = [];
for (const tipo of ESPACOS_TENTADOS) {
try {
await sessao.requestReferenceSpace(tipo);
obtidos.push(tipo);
} catch {
// Espaço não concedido. É resposta, não falha.
}
}
return obtidos;
}
function lerFontesDeEntrada(sessao: XRSession): FonteDeEntradaSondada[] {
const fontes: FonteDeEntradaSondada[] = [];
for (const fonte of sessao.inputSources) {
fontes.push({
lado: fonte.handedness,
mira: fonte.targetRayMode,
temPoseDePunho: fonte.gripSpace !== undefined,
temMao: fonte.hand !== undefined,
perfis: [...fonte.profiles],
});
}
return fontes;
}
/**
* A camada de composição mínima.
*
* A especificação só entrega quadros a uma sessão que tenha superfície de
* composição declarada. Não desenhamos nada nela: ela é a condição para o laço de
* quadros existir. A distinção entre a superfície e a cena é exatamente o que
* este módulo ainda não tem, e declará-la aqui evita que alguém leia este trecho,
* dois módulos adiante, como início de um renderizador paralelo.
*/
function camadaMinima(sessao: XRSession): void {
const tela: HTMLCanvasElement = document.createElement('canvas');
const gl: WebGL2RenderingContext | null = tela.getContext('webgl2', {
xrCompatible: true,
});
if (gl === null) {
throw new Error('Este navegador não entregou contexto WebGL 2 compatível com XR.');
}
sessao.updateRenderState({ baseLayer: new XRWebGLLayer(sessao, gl) });
}
/** Observa alguns quadros, contando os que vieram sem pose de quem observa. */
function observarQuadros(
sessao: XRSession,
referencia: XRReferenceSpace,
): Promise<Estabilidade> {
return new Promise<Estabilidade>((resolver) => {
const contador: ContadorDeEstabilidade = new ContadorDeEstabilidade();
let restantes: number = QUADROS_OBSERVADOS;
const passo: XRFrameRequestCallback = (_tempo: number, quadro: XRFrame): void => {
const pose: XRViewerPose | undefined = quadro.getViewerPose(referencia);
contador.registrar(pose !== undefined, sessao.visibilityState === 'visible');
restantes -= 1;
if (restantes > 0) {
sessao.requestAnimationFrame(passo);
return;
}
resolver(contador.resultado());
};
sessao.requestAnimationFrame(passo);
});
}
export async function sondarEmSessao(modo: ModoSondavel): Promise<SondaEmSessao> {
const xr: XRSystem | undefined = navigator.xr;
if (xr === undefined) {
throw new Error('Não há API XR neste navegador.');
}
// Todo recurso vai como opcional. Passar qualquer um deles como obrigatório
// faria o aparelho recusar a sessão inteira por causa de um item — e a sonda
// perderia justamente a informação que veio buscar.
const sessao: XRSession = await xr.requestSession(modo, {
optionalFeatures: RECURSOS_CONSULTADOS.map((recurso) => recurso.nome),
});
try {
camadaMinima(sessao);
const concedidos: readonly string[] | undefined = sessao.enabledFeatures;
const espacos: string[] = await espacosConcedidos(sessao);
const referencia: XRReferenceSpace = await sessao.requestReferenceSpace(
espacos.includes('local-floor') ? 'local-floor' : 'viewer',
);
const estabilidade: Estabilidade = await observarQuadros(sessao, referencia);
const fontes: FonteDeEntradaSondada[] = lerFontesDeEntrada(sessao);
return {
modo,
recursos: RECURSOS_CONSULTADOS.map((recurso) => ({
nome: recurso.nome,
paraQueServe: recurso.paraQueServe,
estado: estadoDoRecurso(recurso.nome, concedidos),
})),
espacosConcedidos: espacos,
composicaoObservada: sessao.environmentBlendMode,
fontesDeEntrada: fontes,
graus: grausDeLiberdade({
concedidos: espacos,
modo,
comPoseDePunho: fontes.some((fonte) => fonte.temPoseDePunho),
}),
estabilidade,
diagnostico: diagnosticar(estabilidade),
};
} finally {
// A sessão precisa terminar mesmo quando a sondagem falha no meio. Sessão
// imersiva viva com página parada prende o visor numa tela vazia, e quem está
// com o aparelho no rosto só sai pelo menu do sistema.
await sessao.end();
}
}
/** Escolhe o modo mais informativo entre os que o aparelho declara suportar. */
export function modoPreferido(
modosSuportados: readonly string[],
): ModoSondavel | undefined {
const ordem: readonly ModoSondavel[] = ['immersive-ar', 'immersive-vr'];
return ordem.find((modo) => modosSuportados.includes(modo));
}
export async function sondar(): Promise<ResultadoDaSonda> {
const semSessao: SondaSemSessao = await sondarSemSessao();
const modo: ModoSondavel | undefined = modoPreferido(semSessao.modosSuportados);
if (modo === undefined) {
return {
semSessao,
emSessao: undefined,
motivoSemSessao: semSessao.temApiXr
? 'Este aparelho não declara sessão imersiva alguma, e metade da sonda não tem onde acontecer. É informação sobre o aparelho, não defeito do código.'
: 'Sem API XR neste navegador. Se a página não está em contexto seguro, a causa é a URL, e não o aparelho.',
classe: classificarAparelho(
semSessao.modosSuportados,
'indeterminado',
semSessao.temApiXr,
),
};
}
const emSessao: SondaEmSessao = await sondarEmSessao(modo);
return {
semSessao,
emSessao,
motivoSemSessao: undefined,
classe: classificarAparelho(
semSessao.modosSuportados,
emSessao.graus,
semSessao.temApiXr,
),
};
}
/**
* Confronta a composição declarada no módulo anterior com a que a sessão
* informou. É o primeiro ponto do percurso em que uma declaração nossa pode ser
* desmentida pelo aparelho, e o desmentido é o resultado mais valioso dos dois.
*/
export function conferirComposicao(emSessao: SondaEmSessao): string {
const id: RegimeId = emSessao.modo;
const regime: Regime | undefined = REGIMES.find((candidato) => candidato.id === id);
if (regime === undefined) {
return 'O regime sondado não consta da declaração de regimes.';
}
if (regime.composicaoEsperada === emSessao.composicaoObservada) {
return `A composição declarada (${regime.composicaoEsperada}) foi confirmada pela sessão.`;
}
return (
`Declaramos composição ${regime.composicaoEsperada} e a sessão informou ` +
`${emSessao.composicaoObservada}. A declaração estava errada, e quem tem razão é o aparelho.`
);
}Três decisões dentro do arquivo merecem justificativa. A primeira é passar todo recurso como opcional. Marcar qualquer um deles como obrigatório faria o aparelho recusar a sessão inteira por causa de um item, e a sonda perderia justamente a informação que veio buscar — o levantamento de capacidades morreria pela primeira capacidade ausente.
A segunda é a captura vazia em torno do pedido de espaço de referência. Um bloco que engole exceção costuma ser sinal de descuido; aqui a rejeição é a leitura, e o comentário no código diz isso para que ninguém o remova achando que está limpando sujeira.
A terceira é a camada mínima de composição. A especificação só entrega quadros a uma sessão com superfície de composição declarada, e sem quadros não há como observar a estabilidade da pose. Nada é desenhado nela. Declarar isso em comentário evita que o trecho seja lido, dois módulos adiante, como o começo de um renderizador paralelo ao que ainda vai nascer.
O encerramento da sessão fica num bloco que roda mesmo quando a sondagem falha no meio. A razão é física: sessão imersiva viva com a página parada prende o visor numa tela vazia, e quem está com o aparelho no rosto só escapa pelo menu do sistema.
1.5.3 O confronto que fecha a declaração do módulo anterior
O módulo anterior declarou, por regime, o modo de composição do fundo esperado. Aquele campo foi escrito como expectativa justamente para poder ser desmentido, e agora existe sessão para desmenti-lo.
A função de conferência compara o valor declarado com o que a sessão informou e devolve uma frase. Quando os dois batem, a declaração foi confirmada. Quando não batem, a frase diz sem eufemismo qual dos dois estava errado, e não é o aparelho.
Este é o primeiro ponto do percurso em que uma afirmação nossa é submetida ao veredito da plataforma. O resultado mais valioso dos dois é o desmentido — ele custa uma correção de uma linha agora e evita a construção de dois módulos sobre uma premissa falsa. Um grupo cuja declaração inicial sobreviva ao confronto sem uma correção sequer merece desconfiança antes de merecer aplauso.
1.5.4 Onde é fácil errar, e como conferir
O erro mais comum desta tarefa é sondar de fora e declarar vitória. A consulta sem sessão responde rápido, funciona no computador de mesa e produz uma tabela bonita, o que a torna irresistível. Ela apenas não responde a pergunta do módulo, que é o que o aparelho concede, e não o que ele diz suportar.
O segundo erro é tratar ausência de resposta como negativa, e ele se detecta lendo o relatório num navegador que não reporta a lista de recursos concedidos: se todos aparecem como negados, o terceiro estado não está sendo respeitado em algum ponto do caminho.
A conferência executável cabe em três passos. Abrir a página em contexto seguro e tocar o botão em cada classe de aparelho; comparar as linhas de recurso entre o visor e o celular, que devem divergir; e ler a frase de confronto de composição, que é a única linha do relatório capaz de acusar quem a escreveu.
1.6 Tarefa 2: Tornar o relatório visível
Enunciado da tarefa
Um relatório que só existe no console de depuração serve a quem escreveu o código e a mais ninguém. O resultado da sonda precisa aparecer em algum lugar que qualquer pessoa consiga ler no próprio aparelho.
A tarefa está cumprida quando o mesmo endereço, aberto em aparelhos de classes diferentes, produz relatórios diferentes e legíveis. É o instante em que a palavra dispositivo deixa de ser tópico narrado e passa a ser algo que se lê na tela.
1.6.1 O canal que não existe dentro de um visor
Esta é a metade menos vistosa do módulo e a que mais decide se a aula funciona. Não há console de depuração dentro de um visor. Quem está com o aparelho no rosto não abre painel de desenvolvedor, não vê aviso de rede e não lê exceção — para essa pessoa, um erro registrado no console é indistinguível de um programa que decidiu não fazer nada.
Tudo o que a sondagem tem a dizer precisa aparecer na própria página, em corpo de texto que se leia a um braço de distância.
src/bancada/relatorio/diario.ts
// ---------------------------------------------------------------------------
// O diário da sondagem — o que aconteceu, escrito onde se possa ler.
//
// Este arquivo é a metade menos vistosa da Tarefa 2, e a que mais decide se o
// módulo funciona em sala. O console de depuração não existe dentro de um visor:
// quem está com o aparelho no rosto não abre painel de desenvolvedor, não lê
// aviso de rede e não vê exceção. Tudo o que a sondagem tiver a dizer precisa
// aparecer na própria página, em corpo de texto que se leia a um braço de
// distância.
//
// O espelho no console continua existindo, e não por hábito: quando o aparelho
// está ligado ao computador por depuração remota, o mesmo texto em dois lugares
// é o que permite comparar o que a página mostrou com o que o navegador
// registrou.
// ---------------------------------------------------------------------------
export type Severidade = 'nota' | 'alerta' | 'falha';
export interface Entrada {
readonly severidade: Severidade;
readonly texto: string;
}
/**
* Acumula as entradas e as escreve num elemento da página.
*
* O acúmulo é o que permite ao diário existir antes de o elemento existir: a
* sondagem pode falhar durante o carregamento, e uma mensagem perdida por não ter
* onde ser escrita é a pior das mensagens.
*/
export class Diario {
private readonly entradas: Entrada[] = [];
private destino: HTMLElement | undefined = undefined;
public fixarDestino(destino: HTMLElement): void {
this.destino = destino;
this.redesenhar();
}
public nota(texto: string): void {
this.registrar({ severidade: 'nota', texto });
}
public alerta(texto: string): void {
this.registrar({ severidade: 'alerta', texto });
}
public falha(texto: string): void {
this.registrar({ severidade: 'falha', texto });
}
private registrar(entrada: Entrada): void {
this.entradas.push(entrada);
// O espelho no console serve à depuração remota, quando o aparelho está
// ligado ao computador. Nunca é o canal principal.
console.info(`[bancada:${entrada.severidade}] ${entrada.texto}`);
this.redesenhar();
}
private redesenhar(): void {
const destino: HTMLElement | undefined = this.destino;
if (destino === undefined) {
return;
}
destino.replaceChildren();
for (const entrada of this.entradas) {
const linha: HTMLParagraphElement = document.createElement('p');
linha.className = `diario diario-${entrada.severidade}`;
linha.textContent = entrada.texto;
destino.appendChild(linha);
}
}
}
/**
* Traduz o que veio de um `catch` em texto legível.
*
* A recusa de sessão chega como erro, e o erro cru — nome da classe e uma frase
* em inglês — é exatamente o que faz um grupo concluir que o código quebrou
* quando o que houve foi o aparelho dizendo não.
*/
export function explicarFalha(erro: unknown): string {
if (erro instanceof DOMException && erro.name === 'NotSupportedError') {
return 'O aparelho recusou a sessão neste modo. Ele não a sustenta, e o pedido foi respondido.';
}
if (erro instanceof DOMException && erro.name === 'SecurityError') {
return 'O navegador recusou o pedido por falta de gesto de quem usa ou por contexto inseguro. O botão precisa ser tocado, e a página precisa estar em conexão cifrada.';
}
if (erro instanceof DOMException && erro.name === 'InvalidStateError') {
return 'Já existe uma sessão aberta neste navegador. Encerre a anterior antes de sondar de novo.';
}
if (erro instanceof Error) {
return `A sondagem parou: ${erro.message}`;
}
return 'A sondagem parou por um motivo que o navegador não descreveu.';
}O diário acumula as entradas antes de saber onde escrevê-las, e o acúmulo tem uso preciso: a sondagem pode falhar durante o carregamento, antes de o elemento de destino existir. Mensagem perdida por não ter onde ser escrita é a pior categoria de mensagem, porque some justamente no caso em que era mais necessária.
O espelho no console continua existindo, e não por hábito. Quando o aparelho está ligado ao computador por depuração remota, o mesmo texto em dois lugares permite comparar o que a página mostrou com o que o navegador registrou. O canal principal, porém, é a página.
A tradução de exceção em frase legível é o outro serviço do arquivo. Recusa de sessão chega como erro, e o erro cru — nome de classe e uma frase em inglês — é exatamente o que faz um grupo concluir que o código quebrou quando o que houve foi o aparelho respondendo que não. Cada nome de exceção previsto vira uma frase que diz o que aconteceu e o que fazer a respeito, incluindo os dois casos de laboratório: falta de gesto e contexto inseguro.
1.6.2 A apresentação, e o que ela ainda não pode ser
O painel definitivo da Bancada é diegético: um cartaz preso à própria bancada, dentro do mundo, lido de dentro do ambiente. Ele continua sem poder existir, pela mesma razão do módulo anterior — não há mundo. Enquanto o grafo de cena não chega, o relatório sai em tabela comum na página.
src/bancada/relatorio/capacidades.ts
import type { EstadoDeRecurso } from '../devices/recursos';
import { descreverClasse, type GrausDeLiberdade } from '../devices/graus';
import type { ResultadoDaSonda, SondaEmSessao } from '../devices/sonda';
import { celula } from './comum';
// ---------------------------------------------------------------------------
// Acréscimo deste módulo: a apresentação da sonda de capacidades.
//
// O relatório de regimes, em relatorio.ts, continua onde estava — ele responde
// "este aparelho entra?", que é a pergunta grossa. O que está neste arquivo
// responde "e uma vez dentro, o que ele concede?", que é a pergunta do módulo de
// dispositivos e que só a sessão responde.
//
// A apresentação aqui é provisória pelo mesmo motivo que a de relatorio.ts: o
// painel definitivo da Bancada é diegético — um cartaz preso à própria bancada,
// lido de dentro do mundo —, e não há mundo enquanto o grafo de cena não chega.
// Até lá, tabela em HTML comum. Vale declarar em vez de esconder: quando a
// bancada existir, é este arquivo que muda de lugar, e nada mais.
// ---------------------------------------------------------------------------
function rotuloDoEstado(estado: EstadoDeRecurso): string {
switch (estado) {
case 'concedido':
return 'concedido';
case 'negado':
return 'não concedido';
case 'indeterminado':
return 'sem resposta';
}
}
function rotuloDosGraus(graus: GrausDeLiberdade): string {
switch (graus) {
case 'tres':
return 'três graus de liberdade — o aparelho acompanha para onde a cabeça aponta e não acompanha para onde ela vai';
case 'seis':
return 'seis graus de liberdade — o aparelho acompanha orientação e deslocamento';
case 'indeterminado':
return 'indeterminado — os espaços concedidos não bastam para afirmar nem uma coisa nem outra';
}
}
function tabelaDeRecursos(sonda: SondaEmSessao): HTMLTableElement {
const tabela: HTMLTableElement = document.createElement('table');
const cabecalho: HTMLTableRowElement = tabela.insertRow();
for (const titulo of ['Recurso', 'Para que serve', 'Neste aparelho']) {
cabecalho.appendChild(celula(titulo, true));
}
for (const recurso of sonda.recursos) {
const fileira: HTMLTableRowElement = tabela.insertRow();
fileira.appendChild(celula(recurso.nome));
fileira.appendChild(celula(recurso.paraQueServe));
fileira.appendChild(celula(rotuloDoEstado(recurso.estado)));
}
return tabela;
}
function tabelaDeFontes(sonda: SondaEmSessao): HTMLElement {
if (sonda.fontesDeEntrada.length === 0) {
const vazio: HTMLParagraphElement = document.createElement('p');
vazio.textContent =
'Nenhuma fonte de entrada foi declarada durante a sondagem. Num visor, isso costuma significar controle desligado ou fora de alcance; num aparelho de mão, é o esperado até a primeira toque na tela.';
return vazio;
}
const tabela: HTMLTableElement = document.createElement('table');
const cabecalho: HTMLTableRowElement = tabela.insertRow();
for (const titulo of ['Lado', 'Mira', 'Pose de punho', 'Mão articulada', 'Perfis']) {
cabecalho.appendChild(celula(titulo, true));
}
for (const fonte of sonda.fontesDeEntrada) {
const fileira: HTMLTableRowElement = tabela.insertRow();
fileira.appendChild(celula(fonte.lado));
fileira.appendChild(celula(fonte.mira));
fileira.appendChild(celula(fonte.temPoseDePunho ? 'sim' : 'não'));
fileira.appendChild(celula(fonte.temMao ? 'sim' : 'não'));
fileira.appendChild(celula(fonte.perfis.join(', ')));
}
return tabela;
}
// ---------------------------------------------------------------------------
// O parágrafo e o subtítulo nascem aqui, e por isso são exportados daqui.
//
// Da folha da sonda em diante toda folha do relatório é feita destas duas
// peças, e os módulos seguintes as importam deste arquivo. Mudá-las de lugar
// para um arquivo neutro faria elas aparecerem no módulo anterior àquele que as
// introduziu, que é exatamente o que o mapa de marcos existe para impedir.
// ---------------------------------------------------------------------------
export function paragrafo(texto: string): HTMLParagraphElement {
const elemento: HTMLParagraphElement = document.createElement('p');
elemento.textContent = texto;
return elemento;
}
export function subtitulo(texto: string): HTMLHeadingElement {
const elemento: HTMLHeadingElement = document.createElement('h3');
elemento.textContent = texto;
return elemento;
}
/**
* Escreve o resultado da sonda no elemento indicado.
*
* `confronto` é a frase que compara a composição declarada no módulo anterior com
* a que a sessão informou, e ela vem pronta de fora porque quem a produz é o
* módulo de dispositivos, não a apresentação.
*/
export function montarSonda(
raiz: HTMLElement,
resultado: ResultadoDaSonda,
confronto: string | undefined,
): void {
raiz.replaceChildren();
const titulo: HTMLHeadingElement = document.createElement('h2');
titulo.textContent = 'Sonda de capacidades';
raiz.appendChild(titulo);
raiz.appendChild(paragrafo(descreverClasse(resultado.classe)));
raiz.appendChild(
paragrafo(
resultado.semSessao.contextoSeguro
? 'A página está em contexto seguro, então a ausência de um recurso é resposta do aparelho.'
: 'A página NÃO está em contexto seguro. Nada abaixo é informação sobre o aparelho: é a URL impedindo a pergunta.',
),
);
const sonda: SondaEmSessao | undefined = resultado.emSessao;
if (sonda === undefined) {
raiz.appendChild(
paragrafo(resultado.motivoSemSessao ?? 'Não houve sessão, e o motivo não foi registrado.'),
);
return;
}
raiz.appendChild(subtitulo(`Recursos opcionais pedidos em ${sonda.modo}`));
raiz.appendChild(tabelaDeRecursos(sonda));
raiz.appendChild(subtitulo('Espaços de referência e graus de liberdade'));
raiz.appendChild(
paragrafo(
sonda.espacosConcedidos.length === 0
? 'Nenhum espaço de referência foi concedido.'
: `Concedidos: ${sonda.espacosConcedidos.join(', ')}.`,
),
);
raiz.appendChild(paragrafo(rotuloDosGraus(sonda.graus)));
raiz.appendChild(subtitulo('Fontes de entrada declaradas'));
raiz.appendChild(tabelaDeFontes(sonda));
raiz.appendChild(subtitulo('Composição do fundo'));
raiz.appendChild(paragrafo(`A sessão informou composição ${sonda.composicaoObservada}.`));
if (confronto !== undefined) {
raiz.appendChild(paragrafo(confronto));
}
raiz.appendChild(subtitulo('Estabilidade do rastreamento na janela observada'));
raiz.appendChild(
paragrafo(
`${sonda.estabilidade.quadros} quadros observados, ` +
`${sonda.estabilidade.quadrosSemPose} sem pose, ` +
`${sonda.estabilidade.quadrosOcultos} com a sessão fora de primeiro plano.`,
),
);
raiz.appendChild(paragrafo(sonda.diagnostico));
}A função de apresentação recebe tudo pronto e não sabe nada sobre sessões, recursos ou inferência de graus. Essa ignorância é o que permite trocá-la inteira, três módulos adiante, sem tocar em uma linha da sonda: quando a bancada existir, este arquivo é o que muda de lugar, e nada mais.
Declarar o provisório como provisório, em comentário no próprio arquivo, tem função além da boa educação. Sem essa nota, alguém leria a tabela como decisão de projeto a preservar e gastaria uma tarde tentando conciliar dois painéis que nunca deveriam coexistir. O provisório sobrevive a três módulos exatamente quando ninguém escreve que ele é provisório.
1.6.3 Onde é fácil errar, e como conferir
O erro previsível é o que o próprio enunciado antecipa: escrever o relatório no console e declarar a tarefa cumprida. Ele funciona na máquina de quem programou e some no aparelho, que é onde precisava ser lido. O sintoma é sempre o mesmo em aula — o grupo insiste que a sonda funciona, e no visor não aparece nada.
O segundo erro é mais sutil e aparece na leitura em pé. Texto pequeno demais, contraste baixo e tabela larga que exige rolagem horizontal transformam um relatório correto em relatório inútil dentro de um aparelho. A régua é simples: quem está de visor lê o que caberia num cartaz a um braço de distância.
A conferência é a do enunciado, e ela é a mais barata do percurso. Abrir o mesmo endereço no computador de mesa, no celular e no visor, e ler as três respostas. Se as três forem iguais, ou a página não está em contexto seguro, ou a sonda está respondendo de fora da sessão — e as duas causas se distinguem pela primeira linha do diário.
1.7 A página que se abre em aula
O ponto de entrada deste módulo monta três coisas na página: um título, um botão e dois lugares para escrever. O botão existe por imposição da plataforma, e o diário começa a falar antes de qualquer sondagem.
src/demos/02_demo.ts
// ---------------------------------------------------------------------------
// Marco 02 — o que este aparelho concede, e não o que ele declara.
//
// O módulo anterior perguntou ao navegador quais regimes ele diz suportar. Este
// abre uma sessão de verdade e confere: quais espaços de referência o aparelho
// entrega, quantos graus de liberdade ele rastreia, quais recursos foram
// concedidos e quais foram pedidos em vão. A diferença entre as duas respostas é
// o assunto do módulo.
//
// O diário existe porque quem está de visor não abre console de depuração:
// mensagem que só aparece lá não aparece.
//
// Ponto de entrada do MARCO 02, imutável (`specs/marcos-executaveis.md`).
// ---------------------------------------------------------------------------
import { sondar, conferirComposicao, type ResultadoDaSonda } from '../bancada/devices/sonda';
import { Diario, explicarFalha } from '../bancada/relatorio/diario';
import { montarSonda } from '../bancada/relatorio/capacidades';
function criarNo<T extends HTMLElement>(tag: string, pai: HTMLElement, id?: string): T {
const no: T = document.createElement(tag) as T;
if (id !== undefined) {
no.id = id;
}
pai.appendChild(no);
return no;
}
const raiz: HTMLElement = document.getElementById('bancada') ?? document.body;
const titulo: HTMLHeadingElement = criarNo<HTMLHeadingElement>('h1', raiz);
titulo.textContent = 'Bancada — o que este aparelho concede quando lhe perguntam de dentro';
const botao: HTMLButtonElement = criarNo<HTMLButtonElement>('button', raiz, 'sondar');
botao.type = 'button';
botao.textContent = 'Sondar este aparelho';
const raizDiario: HTMLElement = criarNo<HTMLElement>('div', raiz, 'diario');
const raizSonda: HTMLElement = criarNo<HTMLElement>('div', raiz, 'sonda');
const diario: Diario = new Diario();
diario.fixarDestino(raizDiario);
// Contexto inseguro não expõe a API XR, e o aparelho responderia como se não
// tivesse suporte — o que seria mentira sobre o aparelho, não sobre a página.
if (!window.isSecureContext) {
diario.alerta(
'Esta página não está em contexto seguro. A API XR não é exposta aqui, e a sonda vai responder como se o aparelho não tivesse suporte.',
);
}
async function executarSonda(): Promise<void> {
diario.nota('Sondando. Se um visor pedir permissão, aceite: sem ela a sessão não abre.');
try {
const resultado: ResultadoDaSonda = await sondar();
const confronto: string | undefined =
resultado.emSessao === undefined ? undefined : conferirComposicao(resultado.emSessao);
montarSonda(raizSonda, resultado, confronto);
diario.nota('Sondagem concluída e sessão encerrada.');
} catch (erro: unknown) {
// A falha é resultado, não acidente: ela responde a pergunta do módulo.
diario.falha(explicarFalha(erro));
}
}
// A sonda espera um toque porque o navegador recusa pedido de sessão imersiva
// que não venha de gesto de quem usa. Não é cerimônia da página: é regra da API.
botao.addEventListener('click', () => {
void executarSonda();
});
diario.nota('A sonda completa espera um toque no botão.');O aviso de contexto inseguro é a primeira coisa que o arquivo faz depois de fixar o destino do diário, e a ordem foi escolhida. Sem conexão cifrada a API XR não é exposta, e o aparelho responderia como se não tivesse suporte algum — uma mentira sobre o aparelho, causada pela URL. Esse é o erro que mais custa tempo de aula em todo o percurso, e ele se apresenta com a cara de um defeito de código.
A demonstração em sala tem uma coreografia. A mesma página é aberta no computador de mesa, onde nenhuma sessão imersiva abre e o relatório diz isso com todas as letras; depois no celular, onde a sessão aumentada abre e a composição do fundo muda de valor; depois no visor, com espelhamento no projetor, onde os espaços de referência com origem no chão aparecem e a inferência de graus finalmente devolve seis. Três aparelhos, um endereço, três relatórios diferentes.
Este ponto de entrada pertence ao módulo 02 e não será tocado por módulo posterior algum. O seguinte escreve o seu ao lado, e esta demonstração continuará rodando no fim do semestre exatamente como roda hoje.
1.8 Por dentro da implementação
Oito coisas entraram neste módulo, e cada uma muda o caminho que a execução percorre:
| alteração | o que ela passou a fazer |
|---|---|
sonda-sem-sessao |
a consulta fora de sessão escolhe o modo a abrir, ou conclui que não há nenhum |
sessao-de-sondagem |
a sessão abre de verdade, recebe uma camada mínima e encerra no finally |
estado-do-recurso |
recurso não concedido e recurso sem resposta deixam de ser a mesma coisa |
graus-inferidos |
os graus de liberdade saem dos espaços concedidos, e admitem não saber |
estabilidade-medida |
a perda de pose vira contagem quadro a quadro, separada do menu do sistema |
confronto-de-composicao |
a composição declarada no módulo anterior é confrontada com a informada |
diario-na-pagina |
as mensagens passam a ser escritas onde quem está de visor consiga lê-las |
folha-da-sonda |
o resultado da sonda vira uma folha do relatório, com tabelas próprias |
A costura com o que já existia é uma só, e ela é literal. sondarSemSessao() chama levantarRelatorio(), a mesma função que montava a tabela de regimes, e filtra dela os regimes cujo suporte veio como 'sim' — é assim que modosSuportados nasce. Nada do módulo anterior foi reescrito para caber aqui.
Duas das oito têm cenário nas páginas que seguem, e as outras seis esperam o aparelho. As execuções descritas abaixo são as que a página do marco produz num computador de mesa, que é a máquina que toda a turma tem à mão. As seis restantes, da abertura da sessão ao confronto de composição, só executam dentro de uma sessão imersiva, e no desktop elas não chegam a ser chamadas. Desenhá-las a partir da leitura do código produziria um percurso plausível e não conferido, que é justamente o desenho que parece verificado por estar desenhado. O cenário delas sai da execução no visor, e é ali que os dois diagramas de cada uma se fecham.
1.8.1 A sonda que decide antes de pedir qualquer coisa
O cenário é o desktop do laboratório, a página aberta em contexto seguro, sem visor conectado, e alguém aperta o botão Sondar este aparelho. A execução terminou sem abrir sessão alguma e escreveu três frases na página: Aparelho que só sustenta o regime de janela — é o caso do desktop do laboratório., depois A página está em contexto seguro, então a ausência de um recurso é resposta do aparelho., e por fim Este aparelho não declara sessão imersiva alguma, e metade da sonda não tem onde acontecer. Um resultado pobre, e é o resultado que interessa: metade do código deste módulo não roda, e a página explica por quê em vez de ficar em branco.
O losango divide o módulo em dois. À esquerda, o ramo que esta execução tomou, com três chamadas; à direita, o ramo com sessão, que só existe quando há visor na sala. A decisão é de modoPreferido(), que procura 'immersive-ar' e depois 'immersive-vr' na lista recebida, nessa ordem, e devolve o primeiro que encontrar. Aqui a lista trazia apenas 'inline', e o retorno foi undefined.
sequenceDiagram
participant demo as demos/02_demo
participant sonda as devices/sonda
participant ver as modes/verificacao
participant xr as navigator.xr
participant graus as devices/graus
participant cap as relatorio/capacidades
demo->>sonda: sondar()
sonda->>sonda: sondarSemSessao()
sonda->>ver: levantarRelatorio()
ver->>xr: isSessionSupported('inline')
xr-->>ver: true
ver->>xr: isSessionSupported('immersive-vr')
xr-->>ver: false
ver->>xr: isSessionSupported('immersive-ar')
xr-->>ver: false
ver-->>sonda: três linhas, suporte 'sim', 'nao', 'nao'
sonda->>sonda: modoPreferido(['inline'])
sonda-->>sonda: undefined
sonda->>graus: classificarAparelho(['inline'], 'indeterminado', true)
graus-->>sonda: 'somente-janela'
sonda-->>demo: emSessao undefined, motivoSemSessao preenchido
demo->>cap: montarSonda(raizSonda, resultado, undefined)
cap->>graus: descreverClasse('somente-janela')
graus-->>cap: 'Aparelho que só sustenta o regime de janela...'
cap-->>demo: três parágrafos escritos na página
A ordem das mensagens mostra que a decisão acontece cedo, antes de qualquer permissão ser pedida a quem usa o aparelho. Repare que classificarAparelho() recebe 'indeterminado' no lugar dos graus de liberdade e responde mesmo assim, porque a classificação se apoia nos modos declarados, e esses vieram sem sessão. Um aparelho que não abre sessão continua sendo classificável; o que se perde é a distinção entre visor com posição e visor sem posição, que exige justamente a leitura que não aconteceu.
A escolha do modo mais informativo, e o que ela custou. A alternativa era tentar 'immersive-vr' primeiro, que é o modo que quase todo exemplo introdutório abre. O custo aparece no celular: ele sustenta a sessão aumentada e não a imersiva completa, e a ordem invertida faria a sonda desistir num aparelho que tinha o que responder. A ordem escolhida pergunta primeiro pelo modo que devolve mais informação, e cai para o outro quando ele não existe.
1.8.2 O diário, na execução em que ele é o único canal
O cenário é a mesma página, agora aberta pelo endereço de rede do computador, sem conexão cifrada. A API XR não é exposta fora de contexto seguro, e a execução respondeu como se o aparelho não tivesse suporte nenhum — que é o erro de laboratório mais caro do percurso, porque tem a cara de um defeito de código. O diário disse o contrário antes de qualquer sondagem: Esta página não está em contexto seguro. A API XR não é exposta aqui, e a sonda vai responder como se o aparelho não tivesse suporte. Depois do clique, a folha da sonda abriu com Navegador sem a API XR, ou página fora de contexto seguro. e seguiu com A página NÃO está em contexto seguro. Nada abaixo é informação sobre o aparelho: é a URL impedindo a pergunta.
flowchart TD
A["fixarDestino(raizDiario)"] --> B["redesenhar()"]
C["alerta(texto) — contexto inseguro, esta execução"] --> D["registrar(entrada)"]
E["nota(texto) — três vezes nesta execução"] --> D
F["falha(texto) — não ocorreu aqui"] --> D
D --> G["entradas.push(entrada)"]
G --> H["console.info espelha a entrada para depuração remota"]
H --> B
B --> I{"destino === undefined"}
I -->|"true"| J["a entrada fica acumulada, e nada é escrito"]
I -->|"false"| K["destino.replaceChildren, e um p por entrada acumulada"]
As três portas de entrada terminam na mesma função privada, e o desenho fecha um laço: registrar() acumula, espelha no console e chama redesenhar(), que reescreve a lista inteira. A guarda de redesenhar() é o que permite ao diário existir antes do elemento existir — mensagem produzida cedo demais fica acumulada em vez de se perder, e aparece assim que houver destino.
sequenceDiagram
participant demo as demos/02_demo
participant diario as relatorio/diario
participant pagina as div do diário
participant sonda as devices/sonda
participant cap as relatorio/capacidades
participant graus as devices/graus
demo->>diario: fixarDestino(raizDiario)
diario->>pagina: redesenhar(), com zero entradas acumuladas
demo->>diario: alerta('Esta página não está em contexto seguro...')
diario->>pagina: uma entrada, classe diario-alerta
demo->>diario: nota('A sonda completa espera um toque no botão.')
diario->>pagina: duas entradas, a lista inteira reescrita
demo->>diario: nota('Sondando. Se um visor pedir permissão, aceite...')
diario->>pagina: três entradas
demo->>sonda: sondar()
sonda-->>demo: classe 'sem-api', modosSuportados vazio
demo->>cap: montarSonda(raizSonda, resultado, undefined)
cap->>graus: descreverClasse('sem-api')
graus-->>cap: 'Navegador sem a API XR, ou página fora de contexto seguro.'
cap-->>demo: três parágrafos escritos na página
demo->>diario: nota('Sondagem concluída e sessão encerrada.')
diario->>pagina: quatro entradas
Quatro entradas foram escritas nesta execução, e a primeira delas é a que resolve o caso. Nenhuma das quatro chegou por exceção: sondar() não falhou aqui, apenas respondeu pouco.
O diário na página, e o que ele custou. A alternativa era escrever as mensagens no console de depuração, que é onde elas naturalmente iriam. O custo é simples de enunciar e fácil de esquecer enquanto se programa sentado: quem está de visor não abre console, e mensagem que só aparece lá não aparece. O espelho no console continua, e serve à depuração remota, quando o aparelho está ligado ao computador por cabo.
1.8.3 As decisões do ramo que o desktop não alcança
As seis alterações sem cenário aqui têm as decisões registradas, e elas se conferem no fonte mesmo sem execução.
Todo recurso pedido como opcional, e o que isso custou. A alternativa era listar os recursos em requiredFeatures, que é o campo que a maioria dos exemplos de introdução usa. O ganho seria uma garantia: sessão aberta significa recurso disponível. O custo inviabiliza a sonda — o aparelho recusa a sessão inteira quando um único item obrigatório não pode ser concedido, e o que se perde na recusa é exatamente a informação que a sonda foi buscar.
O encerramento no finally, e o que isso custou. A alternativa era chamar sessao.end() no fim do caminho feliz, que é mais curto de escrever e funciona em toda demonstração bem-comportada. O custo aparece quando a sondagem falha no meio: a sessão imersiva continua viva com a página parada, e quem está com o visor no rosto vê uma tela vazia da qual só se sai pelo menu do sistema.
A rejeição lida como resposta, e o que isso custou. Em espacosConcedidos() há um bloco de captura vazio, que em qualquer revisão de código honesta acenderia uma luz. Ele está certo aqui: o pedido de um espaço de referência rejeita quando o espaço não é concedido, e a rejeição é a leitura. A alternativa era deixar a rejeição propagar, e o custo é que o primeiro espaço negado abortaria a consulta dos demais.
A inferência que se recusa a apostar, e o que isso custou. grausDeLiberdade() devolve 'seis' quando algum espaço com chão foi concedido, 'tres' quando só o espaço do observador veio, e 'indeterminado' em todo o resto. A alternativa era tratar a ausência de chão como três graus, que acerta na maioria dos aparelhos. O custo é o mesmo do módulo anterior, agora um nível acima: a tabela afirmaria sobre o aparelho o que ele não disse, e a afirmação chegaria ao relatório com a mesma cara de fato que as medidas verdadeiras.
A contagem separada do julgamento, e o que isso custou. ContadorDeEstabilidade conta quadros com pose e quadros sem, e mais nada; diagnosticar() interpreta depois. A alternativa era o contador já devolver a frase pronta. O custo é conhecido: medidor que julga enquanto mede é medidor que concorda com quem o escreveu, e a contagem deixa de poder ser conferida contra outra leitura.
1.9 O que precisa estar verdadeiro antes do módulo seguinte
| Verificação | Como se confere |
|---|---|
| A sonda abre sessão de verdade | o relatório traz recursos concedidos, e não apenas modos suportados |
| Recurso negado e recurso sem resposta aparecem distintos | ler a coluna de estado num navegador que não reporta a lista |
| A inferência de graus admite não saber | espaço local sozinho produz resultado indeterminado |
| A sessão encerra mesmo quando a sondagem falha | interromper a sondagem e conferir que o visor volta ao menu |
| O relatório se lê dentro do aparelho | abrir no visor, sem console e sem depuração remota |
| A composição declarada foi confrontada | a frase de confronto aparece, confirmando ou desmentindo |
| O código analisa limpo em modo estrito | verificação de tipos do projeto, sem emissão |
A quarta linha é a que costuma passar despercebida e a que mais irrita quem usa o aparelho. Uma sondagem que falha no meio sem encerrar a sessão deixa o visor numa tela preta de onde só se sai pelo menu do sistema, e o grupo que descobre isso descobre no colega que estava com o aparelho no rosto.
O que fica pronto ao fim deste módulo continua sem desenhar nada, e agora por um motivo diferente do anterior. No módulo passado a razão era contenção de escopo; aqui a razão é o assunto, porque o aparelho responde antes de qualquer cena existir. O grafo de cena entra no módulo seguinte, e entrará sabendo em que máquina está sendo montado — que era, desde o início, a única coisa que a sonda precisava garantir.