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 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
O primeiro artefato executável do percurso, e ele continua não desenhando nada.
No módulo anterior escrevemos duas declarações. Dissemos qual é o domínio da Bancada e o que cada regime pretende fazer com o mundo de quem observa. Uma delas dizia que o regime aumentado espera composição de fundo mesclada por transparência.
Neste módulo o aparelho responde. E ele pode discordar.
Essa é a diferença que organiza tudo o que vem a seguir. Até aqui, o projeto afirmava; daqui em diante, ele pergunta e escuta. As duas tarefas do módulo constroem exatamente esse canal: uma sonda que interroga o aparelho e um relatório que se lê no próprio aparelho interrogado.
O código nasce em uma pasta nova de dispositivos, ao lado das que já existiam. A consulta grossa do módulo anterior — “este aparelho entra neste regime?” — continua onde estava e é reaproveitada inteira. O que acrescentamos é a pergunta fina, que só a sessão responde.
Vale dizer de saída o que este módulo não faz, porque a tentação é grande. Ele não abre a bancada, não carrega peça, não desenha polígono. O único triângulo que este código chega perto de tocar é a superfície de composição que a API exige para entregar quadros — e nem nela desenhamos.
1.2 Graus de liberdade, e por que a API não os informa
A pergunta que todo mundo faz ao aparelho, e que a plataforma se recusa a responder.
Quantos graus de liberdade este visor rastreia? A pergunta é a mais natural do módulo, e a API não tem propriedade alguma que a responda. Não é esquecimento da especificação. O navegador não conversa com o sensor; ele conversa com o sistema do aparelho, e o que esse sistema entrega são espaços de referência.
Um espaço de referência é a origem contra a qual as poses são medidas. Pedir um deles é pedir uma promessa: entrego pose medida assim. Quando o aparelho concede, ele está dizendo que consegue sustentar aquela promessa.
Daí a inferência que o módulo de graus de liberdade faz, e ela é conservadora de propósito.
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;
}
/**
* 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.
*/
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';
}
/**
* 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.';
}
}
A regra cabe em três frases. Espaço com chão concedido exige saber onde está o chão em relação a quem observa, e um visor que apenas gira não sustenta isso. Espaço de observador sozinho é o mínimo que qualquer sessão entrega, e translação alguma é observável a partir dele. O caso do meio é genuinamente ambíguo, e devolvemos indeterminado em vez de apostar.
Por que não arriscar o palpite no caso do meio? Porque relatório que afirma seis graus com evidência fraca será citado como se fosse medida. O que diz “não dá para saber daqui” será verificado por alguém. O primeiro produz confiança falsa; o segundo produz trabalho.
Repare também na classificação do aparelho. Ela não olha para a cadeia de identificação do navegador em momento nenhum, e essa foi uma decisão custosa: a cadeia é a informação mais fácil de obter e a mais confortável de ler. Ela também é editável pelo usuário, imitada por outros aparelhos e envelhece a cada versão. O que a sessão concede é o que o aparelho faz agora, nas mãos de quem está usando.
Aqui está o caso concreto que o módulo precisa deixar assentado. Um visor que acompanha a rotação da cabeça e ignora o deslocamento produz desconforto quando quem o usa dá um passo à frente. O corpo avança, os canais do ouvido interno registram o avanço, e a imagem não se aproxima de nada. O conflito entre o que o corpo mede e o que os olhos veem é o que produz o mal-estar, e ele não é falha de renderização.
1.3 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.3.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 API não ajuda: os dois passam pelo mesmo canal de pedido opcional e os dois simplesmente não aparecem depois. A distinção precisa ser construída aqui, à mão.
Construímos assim. A sessão informa, quando quer, a lista do que concedeu. Nome presente na lista é recurso concedido. Nome ausente de uma lista que existe é recurso recusado — o aparelho respondeu não. Lista inexistente é a terceira coisa, e é a que mais rende.
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.
// ---------------------------------------------------------------------------
/**
* 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';
/** 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;
}
/**
* 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';
}
Sem esse terceiro estado, um navegador que entra em sessão sem declarar o que ligou apareceria no relatório como aparelho que negou tudo. Seria um aparelho competente descrito como incapaz, e ninguém desconfiaria — porque a tabela estaria preenchida, bonita, com sete linhas dizendo não.
O catálogo é curto por dois motivos, e o segundo não é óbvio. O primeiro é tempo: pedir tudo o que a especificação prevê alonga a sondagem sem retorno. O segundo é risco. Um pedido malformado não nega o recurso pedido — derruba a sessão inteira, e a sonda perde tudo o mais. É por isso que o recurso de leitura de profundidade ficou fora: ele exige um dicionário de configuração próprio, e configurá-lo errado custaria a sondagem completa por causa de um item de que ainda não precisamos.
1.3.2 A sonda, e as duas restrições que moldam o arquivo inteiro
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.`
);
}
A primeira restrição é que metade das respostas só existe dentro de uma sessão. Recursos concedidos, 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 é que abrir sessão imersiva exige gesto de quem usa. O navegador recusa o pedido que não venha de um toque, e a recusa chega como erro de segurança. É por isso que a sonda é função chamada por botão, e não coisa que roda ao carregar a página.
Três decisões dentro do arquivo merecem justificativa.
Todo recurso vai pedido como opcional, sem exceção. Marcar um único deles como obrigatório faria o aparelho recusar a sessão inteira por causa daquele item — e a sonda perderia exatamente a informação que veio buscar. O que queremos saber é o que ele concede, e para isso ele precisa primeiro deixar entrar.
O encerramento da sessão está no bloco que roda mesmo quando algo falha no meio. A razão é física, não estética: 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. Descobrimos isso da maneira desconfortável, e o custo foi um voluntário tateando o botão lateral.
A superfície de composição mínima existe porque a especificação só entrega quadros a uma sessão que tenha uma declarada. Não desenhamos nada nela. Ela é a condição para o laço de quadros existir, e o comentário no arquivo diz isso em voz alta — sem essa nota, alguém leria o trecho, dois módulos adiante, como o começo de um segundo renderizador.
1.3.3 O confronto que fecha o módulo anterior
A última função do arquivo é pequena e é o ponto alto do módulo. Ela pega a composição de fundo que declaramos como esperada, pega a que a sessão informou, e compara.
Quando batem, o ganho é uma confirmação. Quando não batem, o ganho é a descoberta de que a declaração estava errada, feita pela única entidade com autoridade para dizê-lo. O texto que a função devolve nesse caso registra quem tem razão, e não é o projeto.
1.3.4 Onde é fácil errar, e como conferir
O erro mais caro desta tarefa não está no código. Está na URL. Sem conexão cifrada, a API não é exposta, e todo aparelho responde exatamente como responderia um aparelho sem suporte. O relatório fica coerente, completo e falso.
Por isso a sonda registra o estado do contexto seguro antes de qualquer outra coisa, e o relatório diz, em uma linha, se o que vem abaixo é informação sobre o aparelho ou sobre o endereço. É a linha que evita o diagnóstico errado mais comum do laboratório.
O segundo erro é assumir capacidade não declarada. Um grupo escreve o módulo de ancoragem supondo detecção de plano porque o aparelho da bancada de teste a concede, e a turma inteira descobre em outro aparelho que não. A sonda existe para que essa suposição vire consulta.
A conferência é abrir a mesma página em três aparelhos e comparar as tabelas. No desktop, a sessão imersiva não abre e a metade de baixo do relatório explica por quê. No celular, a composição vem mesclada e a lista de recursos traz detecção de plano. No visor, aparecem duas fontes de entrada com pose de punho.
1.4 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.4.1 O canal que 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, não vê exceção, não sabe que houve exceção. Todo canal de erro que o desenvolvimento web trata como natural desaparece no instante em que a tela sobe para os olhos.
O diário resolve isso, e a solução é deliberadamente simples.
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.';
}
Ele acumula as entradas antes de ter onde escrevê-las, e essa ordem importa. A sondagem pode falhar durante o carregamento, e mensagem perdida por falta de destino é a pior categoria de mensagem: existiu, foi formatada, e ninguém a leu.
O espelho no console continua ali, e não por hábito. Quando o aparelho está ligado ao computador por depuração remota, ter o mesmo texto nos dois lugares permite comparar o que a página mostrou com o que o navegador registrou. Divergência entre os dois é sintoma próprio, e vale ter como notá-la.
A função que traduz a falha merece atenção. A recusa de sessão chega como erro cru — um nome de classe e uma frase curta em inglês —, e é exatamente isso que faz um grupo concluir que o código quebrou quando o que houve foi o aparelho dizendo não. Traduzimos os três casos que aparecem de verdade: aparelho que não sustenta o modo, pedido sem gesto ou fora de contexto seguro, e sessão já aberta.
1.4.2 A apresentação, e o que ela não pode ser ainda
O painel definitivo da Bancada é diegético, preso à própria bancada, lido de dentro do mundo. Ele continua impossível pelo mesmo motivo do módulo anterior: não há mundo. O que fizemos foi estender o relatório em texto comum, preservando inteiro o que já existia.
src/bancada/relatorio/relatorio.ts
// ---------------------------------------------------------------------------
// Apresentação do confronto — versão provisória, fora da cena.
//
// O painel definitivo da Bancada é diegético: um cartaz preso à própria bancada,
// dentro do mundo, lido de dentro do ambiente. Ele não pode existir ainda, pelo
// motivo mais simples possível — não há mundo. Enquanto o grafo de cena não
// chega, o relatório sai em HTML comum, e essa é uma decisão temporária que vale
// a pena declarar em vez de esconder: quando a bancada existir, este arquivo é o
// que muda de lugar, e nada mais.
// ---------------------------------------------------------------------------
import type { Dominio } from '../dominio/dominio';
import type { EstadoDeRecurso } from '../devices/recursos';
import { descreverClasse, type GrausDeLiberdade } from '../devices/graus';
import type { ResultadoDaSonda, SondaEmSessao } from '../devices/sonda';
import type { LinhaDoRelatorio, Suporte } from '../modes/verificacao';
function rotuloDoSuporte(suporte: Suporte): string {
switch (suporte) {
case 'sim':
return 'suportado';
case 'nao':
return 'não suportado';
case 'desconhecido':
return 'sem resposta';
}
}
function celula(texto: string, cabecalho: boolean = false): HTMLTableCellElement {
const elemento: HTMLTableCellElement = document.createElement(cabecalho ? 'th' : 'td');
elemento.textContent = texto;
return elemento;
}
function tabelaDeRegimes(linhas: readonly LinhaDoRelatorio[]): HTMLTableElement {
const tabela: HTMLTableElement = document.createElement('table');
const cabecalho: HTMLTableRowElement = tabela.insertRow();
for (const titulo of [
'Regime',
'O que faz com o mundo',
'Espaço de referência',
'Rastreia',
'Registro contra',
'Neste aparelho',
]) {
cabecalho.appendChild(celula(titulo, true));
}
for (const linha of linhas) {
const fileira: HTMLTableRowElement = tabela.insertRow();
fileira.appendChild(celula(linha.regime.nome));
fileira.appendChild(celula(linha.regime.tratamentoDoMundo));
fileira.appendChild(celula(linha.regime.espacoDeReferencia));
fileira.appendChild(celula(linha.regime.rastreia));
fileira.appendChild(celula(linha.regime.registroContra));
fileira.appendChild(celula(`${rotuloDoSuporte(linha.suporte)} — ${linha.observacao}`));
}
return tabela;
}
function blocoDoDominio(dominio: Dominio, problemas: readonly string[]): HTMLElement {
const bloco: HTMLElement = document.createElement('section');
const titulo: HTMLHeadingElement = document.createElement('h2');
titulo.textContent = `Domínio: ${dominio.nome}`;
bloco.appendChild(titulo);
const descricao: HTMLParagraphElement = document.createElement('p');
descricao.textContent = dominio.descricao;
bloco.appendChild(descricao);
const tarefa: HTMLParagraphElement = document.createElement('p');
tarefa.textContent = `Tarefa: ${dominio.tarefa.enunciado} Concluída quando: ${dominio.tarefa.estadoFinal}`;
bloco.appendChild(tarefa);
const inventario: HTMLParagraphElement = document.createElement('p');
inventario.textContent =
`${dominio.pecas.length} peças e ${dominio.sockets.length} encaixes declarados. ` +
(problemas.length === 0
? 'Nenhuma inconsistência entre peças e encaixes.'
: `Inconsistências: ${problemas.join(' ')}`);
bloco.appendChild(inventario);
return bloco;
}
export function montarRelatorio(
raiz: HTMLElement,
dominio: Dominio,
problemas: readonly string[],
linhas: readonly LinhaDoRelatorio[],
): void {
raiz.replaceChildren();
raiz.appendChild(blocoDoDominio(dominio, problemas));
const tituloRegimes: HTMLHeadingElement = document.createElement('h2');
tituloRegimes.textContent = 'Regimes: o que foi declarado e o que este aparelho responde';
raiz.appendChild(tituloRegimes);
raiz.appendChild(tabelaDeRegimes(linhas));
}
// ---------------------------------------------------------------------------
// Acréscimo deste módulo: a apresentação da sonda de capacidades.
//
// O relatório de regimes acima continua onde estava — ele responde "este
// aparelho entra?", que é a pergunta grossa. O que vem daqui para baixo responde
// "e uma vez dentro, o que ele concede?", que é a pergunta do módulo de
// dispositivos e que só a sessão responde.
// ---------------------------------------------------------------------------
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;
}
function paragrafo(texto: string): HTMLParagraphElement {
const elemento: HTMLParagraphElement = document.createElement('p');
elemento.textContent = texto;
return elemento;
}
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 estrutura da cena e a demonstração da ordem das operações, na página comum.
*
* Isto não substitui o painel dentro da cena, e não concorre com ele: o painel
* mostra o que muda a cada quadro e precisa ser lido de dentro do ambiente; esta
* seção mostra o que é fixo e se lê melhor com o texto parado diante dos olhos.
*/
export function montarEstruturaDaCena(
raiz: HTMLElement,
arvore: readonly string[],
frasesDaOrdem: readonly string[],
): void {
raiz.replaceChildren();
raiz.appendChild(subtitulo('Como a cena está montada'));
const bloco: HTMLPreElement = document.createElement('pre');
bloco.textContent = arvore.join('\n');
raiz.appendChild(bloco);
raiz.appendChild(subtitulo('A ordem das operações não é livre'));
for (const frase of frasesDaOrdem) {
raiz.appendChild(paragrafo(frase));
}
}
/**
* O inventário do conteúdo do módulo de modelagem: de onde veio a forma de cada
* peça, quanto ela custa em triângulos, que superfícies a cena usa e — o que
* nenhuma outra seção mostra — qual é o volume de contato de cada peça e por que
* a folga é aquela.
*
* O volume vive aqui em texto porque ele decide comportamento sem aparecer na
* tela. Ler a caixa em centímetros ao lado da razão escrita é o que permite
* discutir a escolha antes de o encaixe existir para reclamar dela.
*/
export function montarConteudo(
raiz: HTMLElement,
inventario: readonly string[],
materiais: readonly string[],
volumes: readonly string[],
comparacao: string,
): void {
raiz.replaceChildren();
raiz.appendChild(subtitulo('De onde veio a forma de cada peça'));
for (const linha of inventario) {
raiz.appendChild(paragrafo(linha));
}
raiz.appendChild(subtitulo('As superfícies, e o que cada uma cobra'));
for (const linha of materiais) {
raiz.appendChild(paragrafo(linha));
}
raiz.appendChild(subtitulo('O que se vê e o que colide'));
for (const linha of volumes) {
raiz.appendChild(paragrafo(linha));
}
raiz.appendChild(paragrafo(comparacao));
}
/**
* A folha de ativos e de custo: o laudo da importação, o que a instanciação
* comprou, os dois níveis da prateleira e a medição com a máquina ao lado.
*
* As quatro coisas ficam na mesma folha de propósito. Separá-las é o que produz
* o relatório de desempenho que ninguém consegue repetir: o número numa página,
* a máquina noutra, e a decisão tomada sem as duas à vista.
*/
export function montarAtivosECusto(
raiz: HTMLElement,
ativo: readonly string[],
repeticao: readonly string[],
niveis: readonly string[],
medicao: readonly string[],
): void {
raiz.replaceChildren();
raiz.appendChild(subtitulo('O ativo que veio de fora, e o que foi ajustado nele'));
for (const linha of ativo) {
raiz.appendChild(paragrafo(linha));
}
raiz.appendChild(subtitulo('Repetição tratada como repetição'));
for (const linha of repeticao) {
raiz.appendChild(paragrafo(linha));
}
raiz.appendChild(subtitulo('Detalhe cobrado por distância'));
for (const linha of niveis) {
raiz.appendChild(paragrafo(linha));
}
raiz.appendChild(subtitulo('A medição, e a máquina em que ela foi obtida'));
const bloco: HTMLPreElement = document.createElement('pre');
bloco.textContent = medicao.join('\n');
raiz.appendChild(bloco);
}
/**
* O regime em janela: o estado da órbita e, logo abaixo, a lista dos limites com
* a aferição de cada um.
*
* A lista de limites fica na mesma página do ambiente, e não num documento
* separado, porque é assim que ela chega a quem abre o endereço sem instrução
* verbal — que é exatamente a conferência cruzada entre grupos que este módulo
* usa. Limite guardado no repositório não alcança quem está usando o ambiente.
*/
export function montarRegimeEmJanela(
raiz: HTMLElement,
orbita: readonly string[],
regime: readonly string[],
): void {
raiz.replaceChildren();
raiz.appendChild(subtitulo('A câmera em órbita, e como percorrer a cena'));
raiz.appendChild(
paragrafo(
'Arraste com o cursor ou com um dedo para girar em volta da bancada. Use a roda, ' +
'ou dois dedos, para aproximar e afastar.',
),
);
for (const linha of orbita) {
raiz.appendChild(paragrafo(linha));
}
raiz.appendChild(subtitulo('O que este regime não oferece'));
const bloco: HTMLPreElement = document.createElement('pre');
bloco.textContent = regime.join('\n');
raiz.appendChild(bloco);
}
/**
* A camada de interação, em três blocos.
*
* O primeiro é a abstração: quais fontes alimentam o apontamento e o que cada
* campo carrega. O segundo é a fronteira das dependências, que sai na página pela
* mesma razão que os limites do regime saem — decisão de projeto guardada no
* repositório não alcança quem está usando o ambiente, nem quem o está avaliando.
* O terceiro é o estado vivo da mira, e ele é o que a tutoria lê enquanto alguém
* move o cursor: o nó que o raio acertou aparece ao lado da peça a que ele
* pertence, e a distância entre os dois é o conteúdo do módulo.
*/
export function montarInteracao(
raiz: HTMLElement,
apontamento: readonly string[],
fronteira: readonly string[],
interacao: readonly string[],
): void {
raiz.replaceChildren();
raiz.appendChild(subtitulo('A abstração de apontar, e quem a alimenta'));
raiz.appendChild(
paragrafo(
'Passe o cursor sobre as peças para ver o realce da mira, e clique para escolher. ' +
'Arrastar gira a câmera e não seleciona nada: o gesto se decide pelo tanto que o ' +
'cursor andou entre o botão descer e subir.',
),
);
for (const linha of apontamento) {
raiz.appendChild(paragrafo(linha));
}
raiz.appendChild(subtitulo('O que vem pronto, o que se escreve aqui'));
const limite: HTMLPreElement = document.createElement('pre');
limite.textContent = fronteira.join('\n');
raiz.appendChild(limite);
raiz.appendChild(subtitulo('Mira e escolha, agora'));
const estado: HTMLPreElement = document.createElement('pre');
estado.textContent = interacao.join('\n');
raiz.appendChild(estado);
}
/**
* A folha da manipulação: o que está na mão, a folga adotada e o estado da
* tarefa.
*
* A folga aparece junto do que se experimentou antes de fixá-la, e não sozinha.
* Número sem o ensaio ao lado é indistinguível de chute para quem lê, inclusive
* para quem o escolheu, seis meses depois.
*/
export function montarManipulacaoEMontagem(
raiz: HTMLElement,
manipulacao: readonly string[],
tolerancia: readonly string[],
montagem: readonly string[],
): void {
raiz.replaceChildren();
raiz.appendChild(subtitulo('Pegar, orientar e soltar'));
raiz.appendChild(
paragrafo(
'Clique na peça para apanhá-la e clique de novo para soltar: no cursor a pega ' +
'alterna, porque o botão está em disputa com a órbita e só a soltura diz se o ' +
'gesto era clique ou arrasto. Com a peça na mão, Q e E a giram em torno do eixo ' +
'vertical, R e F em torno do lateral. Onde há controle rastreado nada disso é ' +
'preciso: a orientação vem da mão.',
),
);
for (const linha of manipulacao) {
raiz.appendChild(paragrafo(linha));
}
raiz.appendChild(subtitulo('A folga do encaixe, e o que se tentou antes dela'));
const folga: HTMLPreElement = document.createElement('pre');
folga.textContent = tolerancia.join('\n');
raiz.appendChild(folga);
raiz.appendChild(subtitulo('A tarefa, e de que cada peça depende'));
const tarefa: HTMLPreElement = document.createElement('pre');
tarefa.textContent = montagem.join('\n');
raiz.appendChild(tarefa);
}
/**
* A folha da sessão: o que foi negociado com a plataforma e onde passa a linha
* entre ela e o projeto.
*
* As duas metades ficam juntas de propósito. A primeira é o resultado da
* negociação neste aparelho, que muda de aparelho para aparelho; a segunda é a
* divisão de responsabilidade, que não muda. Lidas lado a lado, a segunda deixa
* de ser declaração de intenção e passa a ter, em cada linha, a verificação
* correspondente logo acima.
*/
export function montarSessao(
raiz: HTMLElement,
sessao: readonly string[],
plataforma: readonly string[],
): void {
raiz.replaceChildren();
raiz.appendChild(subtitulo('O ciclo de sessão, neste aparelho'));
raiz.appendChild(
paragrafo(
'Entrar em sessão exige toque em um botão: o navegador recusa o pedido que não ' +
'venha de um gesto de quem usa, e a recusa se parece com defeito do código. ' +
'Fora de contexto seguro a interface sequer é exposta, e aí o sintoma é ' +
'idêntico ao de um aparelho sem suporte — a causa, nesse caso, é o endereço.',
),
);
const negociado: HTMLPreElement = document.createElement('pre');
negociado.textContent = sessao.join('\n');
raiz.appendChild(negociado);
raiz.appendChild(subtitulo('O que é da plataforma, o que se negocia e o que é deste projeto'));
const linha: HTMLPreElement = document.createElement('pre');
linha.textContent = plataforma.join('\n');
raiz.appendChild(linha);
}
/**
* A seção do ambiente imersivo: escala corporal, alcance do braço e conforto.
*
* As três saem juntas porque são a mesma pergunta feita de três ângulos — o
* ambiente cabe no corpo de quem entrou? —, e separá-las em três blocos faria
* perder justamente a leitura cruzada: o posto que resolve a folga da entrada é o
* que estraga o alcance, e o teto de quadro que basta para a média não basta para
* o engasgo.
*/
export function montarImersao(
raiz: HTMLElement,
escala: readonly string[],
alcance: readonly string[],
conforto: readonly string[],
): void {
raiz.replaceChildren();
raiz.appendChild(subtitulo('Escala corporal e os dois percursos'));
raiz.appendChild(
paragrafo(
'A conferência abaixo mede a cena e a confronta com o que ela declara. Fora de sessão ' +
'ela vale como conferência de construção; dentro dela, some o assentamento contra a ' +
'origem que o aparelho concedeu — e é aí que a diferença entre pedir o piso real e ' +
'aceitar a altura da cabeça deixa de ser detalhe.',
),
);
const primeiro: HTMLPreElement = document.createElement('pre');
primeiro.textContent = escala.join('\n');
raiz.appendChild(primeiro);
raiz.appendChild(subtitulo('O alcance do braço, medido peça por peça'));
raiz.appendChild(
paragrafo(
'O que estiver fora do braço é problema de desenho, e o conserto é trazer a peça para ' +
'perto. Instruir quem usa a dar um passo à frente transfere o problema para o outro ' +
'lado e falha na sessão que não tem espaço livre para andar.',
),
);
const segundo: HTMLPreElement = document.createElement('pre');
segundo.textContent = alcance.join('\n');
raiz.appendChild(segundo);
raiz.appendChild(subtitulo('Conforto: o que se conta e o que se provoca'));
raiz.appendChild(
paragrafo(
'As provocações duram poucos segundos e se corrigem sozinhas. Aplique-as apenas em quem ' +
'foi avisado do que vai sentir, e nunca em quem está sozinho com o visor no rosto.',
),
);
const terceiro: HTMLPreElement = document.createElement('pre');
terceiro.textContent = conforto.join('\n');
raiz.appendChild(terceiro);
}
/**
* A seção da locomoção: o salto, a borda da sala e a comparação entre as duas
* formas de se deslocar.
*
* Sai depois da seção do ambiente imersivo, e não junto dela, porque responde a
* outra pergunta. Aquela pergunta se o ambiente cabe no corpo de quem entrou;
* esta pergunta como esse corpo atravessa um ambiente maior que a sala em que
* ele está de pé.
*/
export function montarLocomocao(
raiz: HTMLElement,
locomocao: readonly string[],
area: readonly string[],
comparacao: readonly string[],
): void {
raiz.replaceChildren();
raiz.appendChild(subtitulo('O salto, o giro e a máscara'));
raiz.appendChild(
paragrafo(
'Empurrar o comando para a frente abre a mira; soltar confirma o destino. No desktop o ' +
'comando são as setas, e o salto leva o alvo da órbita — serve para conferir o gesto sem ' +
'visor, o que importa quando há três aparelhos para a turma inteira.',
),
);
const primeiro: HTMLPreElement = document.createElement('pre');
primeiro.textContent = locomocao.join('\n');
raiz.appendChild(primeiro);
raiz.appendChild(subtitulo('A borda da sala real'));
raiz.appendChild(
paragrafo(
'A área física é a única coisa deste percurso que o ambiente não tem como deduzir: ela é ' +
'propriedade do cômodo, e quem a declarou foi quem instalou o aparelho. Onde ela não é ' +
'concedida, o ambiente diz que não sabe, em vez de desenhar um retângulo plausível.',
),
);
const segundo: HTMLPreElement = document.createElement('pre');
segundo.textContent = area.join('\n');
raiz.appendChild(segundo);
raiz.appendChild(subtitulo('Contínuo e discreto, comparados por critério'));
raiz.appendChild(
paragrafo(
'Os critérios estão escritos antes de qualquer ensaio, e metade deles só uma pessoa ' +
'responde. O relato que vale é o de quem experimentou sem ter construído: quem construiu ' +
'já se habituou ao próprio movimento e deixou de sentir o que ele produz.',
),
);
const terceiro: HTMLPreElement = document.createElement('pre');
terceiro.textContent = comparacao.join('\n');
raiz.appendChild(terceiro);
}
/**
* A folha da ancoragem: composição sobre o mundo, consulta de superfície, pouso
* e comportamento sob perda.
*
* Ela é a última folha da página, e a única cujo conteúdo inteiro só tem resposta
* dentro de uma sessão aumentada. Lida no desktop, ela diz que não há composição,
* que não há consulta e que não há pouso — o que é a verdade sobre o desktop, e
* não um relatório vazio.
*/
export function montarMarcador(raiz: HTMLElement, marcador: readonly string[]): void {
raiz.replaceChildren();
raiz.appendChild(subtitulo('O papel sobre a mesa, e o que ele custa em precisão'));
raiz.appendChild(
paragrafo(
'Imprima o marcador pelo botão acima, confira o lado do quadrado preto com uma régua e ' +
'ponha o papel sobre a mesa. Ligue a câmera e aponte: a bancada nasce sobre o papel, na ' +
'escala que o número declarado no código determina.',
),
);
raiz.appendChild(
paragrafo(
'A comparação que interessa é com o registro do trecho anterior, e ela se faz no mesmo ' +
'ambiente. Aqui a bancada treme, e o tremor está medido abaixo em milímetros; ela some ' +
'quando o papel sai de quadro, porque não há mapa do cômodo a que recorrer; e a distância ' +
'estimada depende de um campo de visão que o navegador não informa. Nada disso é defeito ' +
'a corrigir: é o preço de reconhecer um gabarito numa imagem plana.',
),
);
const folha: HTMLPreElement = document.createElement('pre');
folha.textContent = marcador.join('\n');
raiz.appendChild(folha);
}
export function montarDegradacao(raiz: HTMLElement, degradacao: readonly string[]): void {
raiz.replaceChildren();
raiz.appendChild(subtitulo('Um endereço, muitos aparelhos, ninguém diante de uma tela em branco'));
raiz.appendChild(
paragrafo(
'O ambiente consulta o aparelho ao carregar e escolhe qual regime abrir, por uma ordem de ' +
'preferência decidida no projeto. A ordem está escrita abaixo com a razão de cada posição, ' +
'porque nenhuma delas é obviamente correta quando os aparelhos diferem em várias ' +
'dimensões ao mesmo tempo.',
),
);
raiz.appendChild(
paragrafo(
'Ao aparelho que não alcança um regime, o que se deve é uma mensagem que diga o que falta e ' +
'o que ele ainda consegue fazer. Botão desabilitado ensina que o ambiente não funciona ' +
'ali, e essa é a leitura errada: o regime em janela monta a bancada inteira.',
),
);
const folha: HTMLPreElement = document.createElement('pre');
folha.textContent = degradacao.join('\n');
raiz.appendChild(folha);
}
export function montarAncoragem(raiz: HTMLElement, ancoragem: readonly string[]): void {
raiz.replaceChildren();
raiz.appendChild(subtitulo('A cena sobre o mundo, e o que a mantém no lugar'));
raiz.appendChild(
paragrafo(
'Entre em realidade aumentada, mire uma superfície até o anel aparecer e acione para ' +
'pousar a bancada nela. Depois ande em volta: o que se verifica não é o instante do ' +
'pouso, é a bancada continuar onde foi posta enquanto quem observa se move.',
),
);
raiz.appendChild(
paragrafo(
'A conferência que separa registro de papel de parede leva segundos e não pede ' +
'instrumento nenhum. Se a bancada acompanhar a tela em vez de ficar sobre a mesa, a ' +
'câmera está sendo usada como fundo, e o assunto deste trecho passou ao largo.',
),
);
const folha: HTMLPreElement = document.createElement('pre');
folha.textContent = ancoragem.join('\n');
raiz.appendChild(folha);
}
// ---------------------------------------------------------------------------
// As três folhas do módulo final.
//
// Elas não acrescentam camada nenhuma ao ambiente: mostram o que ele já sabe
// sobre si mesmo. A primeira responde se o artefato contém tudo o que foi
// construído; a segunda, se o painel está sendo lido de onde se está; a
// terceira, o que ninguém reconstitui depois olhando o código.
// ---------------------------------------------------------------------------
export function montarComposicao(
raiz: HTMLElement,
auditoria: readonly string[],
percurso: readonly string[],
): void {
raiz.replaceChildren();
raiz.appendChild(subtitulo('Está tudo aqui, e tudo alcançável a partir deste endereço?'));
raiz.appendChild(
paragrafo(
'Cada módulo do percurso deixou uma camada. Nenhum deles podia responder se ela continuava ' +
'ligada dois módulos depois: quem escreve a camada olha para ela, e o que se perde é ' +
'justamente a ligação. A lista abaixo é declarada à mão e conferida por máquina a cada ' +
'carregamento, contra o que esta composição de fato ligou.',
),
);
raiz.appendChild(
paragrafo(
'O que a conferência não faz é dizer que as camadas funcionam. Ela prova ligação, não ' +
'comportamento — e é por isso que cada linha traz o que se deve VER acontecer. Essa ' +
'coluna é o roteiro da conferência a olho, e ela continua sendo de gente.',
),
);
const folhaDaAuditoria: HTMLPreElement = document.createElement('pre');
folhaDaAuditoria.textContent = auditoria.join('\n');
raiz.appendChild(folhaDaAuditoria);
raiz.appendChild(subtitulo('Entre escolher o regime e conseguir abri-lo'));
raiz.appendChild(
paragrafo(
'A consulta ao aparelho diz qual regime ele alcança. A abertura pode falhar depois disso — ' +
'permissão negada no diálogo, sessão tomada por outra aba, aparelho que declara suporte e ' +
'recusa o pedido. Quando isso acontece, a degradação continua descendo a ordem declarada ' +
'até o regime em janela, que não precisa ser aberto porque já está de pé desde que a ' +
'página subiu.',
),
);
const folhaDoPercurso: HTMLPreElement = document.createElement('pre');
folhaDoPercurso.textContent = percurso.join('\n');
raiz.appendChild(folhaDoPercurso);
}
export function montarPainelDiegetico(raiz: HTMLElement, legibilidade: readonly string[]): void {
raiz.replaceChildren();
raiz.appendChild(subtitulo('O cartaz que sobrevive à entrada na sessão'));
raiz.appendChild(
paragrafo(
'Um painel preso à janela do navegador desaparece no instante em que a sessão imersiva ' +
'começa — que é exatamente quando ele faria mais falta. O deste ambiente é objeto da cena, ' +
'preso à bancada, e por isso continua ali dentro do visor e sobre a mesa real, ancorado ao ' +
'mesmo objeto que a ancoragem move.',
),
);
raiz.appendChild(
paragrafo(
'Ser objeto tem custo, e o custo é a leitura. O cartaz visto de lado é uma linha, e o texto ' +
'a três metros não se lê. A resposta não foi aumentar o painel, que mentiria sobre a ' +
'escala do mundo: ele gira em torno do próprio eixo vertical para encarar quem lê, e MEDE ' +
'a altura aparente da letra, dizendo quando ela caiu abaixo do limiar em vez de fingir que ' +
'está sendo lida.',
),
);
const folha: HTMLPreElement = document.createElement('pre');
folha.textContent = legibilidade.join('\n');
raiz.appendChild(folha);
}
export function montarRegistroDoProjeto(
raiz: HTMLElement,
registro: readonly string[],
uso: readonly string[],
): void {
raiz.replaceChildren();
raiz.appendChild(subtitulo('O que ninguém reconstitui depois olhando o código'));
raiz.appendChild(
paragrafo(
'O código diz o que o ambiente faz. Não diz por que a folga do encaixe é de quatro ' +
'centímetros, o que se experimentou antes de fixá-la, em que máquina o número de ' +
'milissegundos foi obtido, nem em que aparelhos este endereço foi de fato aberto. As ' +
'grandezas sem medida aparecem como não medidas: preencher uma delas com valor plausível ' +
'tornaria todas as outras não confiáveis.',
),
);
const folhaDoRegistro: HTMLPreElement = document.createElement('pre');
folhaDoRegistro.textContent = registro.join('\n');
raiz.appendChild(folhaDoRegistro);
raiz.appendChild(subtitulo('Avaliar o ambiente pela tarefa que ele suporta'));
raiz.appendChild(
paragrafo(
'A pergunta "o ambiente ficou bom?" não tem resposta. A que tem é outra: alguém consegue ' +
'montar o mecanismo, e onde essa pessoa trava? A análise abaixo decompõe a tarefa em ' +
'demandas — julgar profundidade, orientar a peça, lembrar a ordem, alcançar o que está ' +
'longe — e mostra o que cada regime oferece a cada uma delas. Duas demandas desaparecem ' +
'nos regimes sem corpo, e desaparecer não é ficar mais fácil: a tarefa passa a ser outra.',
),
);
const folhaDoUso: HTMLPreElement = document.createElement('pre');
folhaDoUso.textContent = uso.join('\n');
raiz.appendChild(folhaDoUso);
}
A composição final liga as peças e divide a página em dois tempos.
src/bancada/main.ts
// ---------------------------------------------------------------------------
// Composição do estado demonstrável do percurso.
//
// A Bancada passa a desenhar. Sobre o que já existia — a delimitação do domínio,
// a declaração dos regimes e a sonda de capacidades — entra a oficina como árvore
// de nós, com o laço andando contra o relógio e o custo do quadro exibido em um
// painel preso à própria bancada.
//
// A página se divide em três tempos. A cena sobe ao carregar. O que se responde
// sem sessão aparece logo abaixo. O que só a sessão responde espera um toque no
// botão, porque o navegador recusa o pedido de sessão imersiva que não venha de
// gesto de quem usa.
// ---------------------------------------------------------------------------
import { BANCADA, inconsistenciasDoDominio } from './dominio/dominio';
import { levantarRelatorio } from './modes/verificacao';
import { conferirComposicao, sondar, type ResultadoDaSonda } from './devices/sonda';
import {
montarAtivosECusto,
montarConteudo,
montarEstruturaDaCena,
montarInteracao,
montarManipulacaoEMontagem,
montarRegimeEmJanela,
montarRelatorio,
montarSessao,
montarImersao,
montarLocomocao,
montarAncoragem,
montarMarcador,
montarDegradacao,
montarSonda,
montarComposicao,
montarPainelDiegetico,
montarRegistroDoProjeto,
} from './relatorio/relatorio';
import { Diario, explicarFalha } from './relatorio/diario';
import { frasesSobreAOrdem, iniciarOficina, type Oficina } from './app/oficina';
import type { ModoDeSessao } from './modes/sessao';
import { CASOS } from './modes/conforto';
import type { Ensaio } from './locomotion/comparacao';
import type { ComparacaoDeFolga } from './content/pecas';
import { desenharParaImpressao, inconsistenciasDoPadrao } from './anchoring/padrao';
import type { Escolha } from './app/degradacao';
function exigirCanvas(id: string): HTMLCanvasElement {
const elemento: HTMLElement = exigirElemento(id);
if (!(elemento instanceof HTMLCanvasElement)) {
throw new Error(`O elemento #${id} existe, mas não é uma superfície de desenho.`);
}
return elemento;
}
function exigirElemento(id: string): HTMLElement {
const elemento: HTMLElement | null = document.getElementById(id);
if (elemento === null) {
throw new Error(`A página não tem o elemento #${id}.`);
}
return elemento;
}
const raizRelatorio: HTMLElement = exigirElemento('relatorio');
const raizSonda: HTMLElement = exigirElemento('sonda');
const raizDiario: HTMLElement = exigirElemento('diario');
const botao: HTMLElement = exigirElemento('sondar');
const raizEstrutura: HTMLElement = exigirElemento('estrutura');
const botaoPrender: HTMLElement = exigirElemento('prender');
const raizConteudo: HTMLElement = exigirElemento('conteudo');
const botaoVolumes: HTMLElement = exigirElemento('volumes');
const raizCusto: HTMLElement = exigirElemento('custo');
const raizJanela: HTMLElement = exigirElemento('janela');
const raizInteracao: HTMLElement = exigirElemento('interacao');
const raizManipulacao: HTMLElement = exigirElemento('manipulacao');
const botaoFolgas: HTMLElement = exigirElemento('folgas');
const botaoEnquadrar: HTMLElement = exigirElemento('enquadrar');
const botaoAproximar: HTMLElement = exigirElemento('aproximar');
const botaoMedir: HTMLElement = exigirElemento('medir');
const botaoEntrarVr: HTMLElement = exigirElemento('entrar-vr');
const botaoEntrarAr: HTMLElement = exigirElemento('entrar-ar');
const botaoSair: HTMLElement = exigirElemento('sair-sessao');
const raizSessao: HTMLElement = exigirElemento('sessao');
const diario: Diario = new Diario();
diario.fixarDestino(raizDiario);
const problemas: string[] = inconsistenciasDoDominio(BANCADA);
if (problemas.length > 0) {
diario.alerta(`O domínio tem inconsistências: ${problemas.join(' ')}`);
}
// A consulta ao suporte é assíncrona porque a API XR responde por promessa: o
// navegador pode precisar consultar o runtime do aparelho antes de saber.
void levantarRelatorio().then((linhas) => {
montarRelatorio(raizRelatorio, BANCADA, problemas, linhas);
diario.nota('Consulta sem sessão concluída. A sonda completa espera um toque no botão.');
});
if (!window.isSecureContext) {
diario.alerta(
'Esta página não está em contexto seguro. A API XR não é exposta aqui, e o botão vai responder como se o aparelho não tivesse suporte — o que seria mentira sobre o aparelho.',
);
}
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, e precisa ser lida no próprio aparelho — quem está de
// visor não abre console de depuração.
diario.falha(explicarFalha(erro));
}
}
botao.addEventListener('click', () => {
void executarSonda();
});
// A oficina sobe assim que a página carrega: o regime não imersivo não pede
// gesto de ninguém, e é ele o caso base do projeto inteiro.
const oficina: Oficina = iniciarOficina(exigirCanvas('cena'));
montarEstruturaDaCena(raizEstrutura, oficina.estrutura(), frasesSobreAOrdem());
/**
* A frase da comparação de folgas. O ponto de teste fica a doze centímetros do
* centro da engrenagem, que é fora dela por uma margem confortável: a folga adotada não o alcança, e a
* folga generosa alcança. É a captura do que passou perto, medida.
*/
function frasesDaFolga(): string {
const c: ComparacaoDeFolga = oficina.folgas(0.12, 0.06);
const adotada: string = c.capturaComAFolgaAdotada ? 'captura' : 'não captura';
const generosa: string = c.capturaComAFolgaGenerosa ? 'captura' : 'não captura';
return (
`Um ponto a ${(c.distanciaDeTeste * 100).toFixed(0)} cm do centro da engrenagem grande: ` +
`com a folga adotada de ${(c.folgaAdotada * 100).toFixed(1)} cm, o volume ${adotada}; ` +
`com uma folga de ${(c.folgaGenerosa * 100).toFixed(1)} cm, ${generosa}. ` +
`O segundo caso é a peça grudando em quem só passou perto.`
);
}
montarConteudo(raizConteudo, oficina.inventario(), oficina.materiais(), oficina.volumes(), frasesDaFolga());
botaoVolumes.addEventListener('click', () => {
const exibindo: boolean = oficina.alternarVolumes();
botaoVolumes.textContent = exibindo ? 'Esconder os volumes de contato' : 'Mostrar os volumes de contato';
diario.nota(
exibindo
? 'Os volumes de contato estão à vista. O que decide o encaixe é a caixa, e não a silhueta.'
: 'Volumes escondidos. A cena volta a mostrar só o que o olho veria.',
);
});
// O ativo externo chega pela rede, e a cena já está de pé quando ele chega. Essa
// ordem não é conveniência de código: é como o ambiente se comporta de verdade,
// e escondê-la atrás de uma tela de carregamento ensinaria o contrário.
let laudoDoAtivo: string[] = ['O ativo externo ainda não foi trazido.'];
function atualizarCusto(): void {
montarAtivosECusto(
raizCusto,
laudoDoAtivo,
oficina.repeticao(),
oficina.niveisDeDetalhe(),
oficina.medicao(),
);
}
atualizarCusto();
function atualizarJanela(): void {
montarRegimeEmJanela(raizJanela, oficina.orbita(), oficina.regimeEmJanela());
}
atualizarJanela();
function atualizarInteracao(): void {
montarInteracao(raizInteracao, oficina.apontamento(), oficina.fronteira(), oficina.interacao());
}
atualizarInteracao();
function atualizarManipulacao(): void {
montarManipulacaoEMontagem(
raizManipulacao,
oficina.manipulacao(),
oficina.tolerancia(),
oficina.montagem(),
);
}
atualizarManipulacao();
// Toda resposta a quem soltou uma peça vai para o diário, e não só as recusas.
// O encaixe bem-sucedido também traz um número — a quantos centímetros do alvo a
// peça estava —, e é esse número que torna a folga discutível em vez de mágica.
oficina.aoResponder((parecer: string) => {
diario.nota(parecer);
atualizarManipulacao();
});
botaoFolgas.addEventListener('click', () => {
const exibindo: boolean = oficina.alternarFolgas();
botaoFolgas.textContent = exibindo
? 'Esconder a folga dos encaixes'
: 'Mostrar a folga dos encaixes';
diario.nota(
exibindo
? 'Cada cubo verde tem exatamente o tamanho da folga linear do encaixe. Soltar a peça com o centro dela dentro do cubo manda encaixar.'
: 'As folgas voltaram a ficar invisíveis. Elas continuam valendo: o que sumiu foi o desenho, não o número.',
);
});
// A mira muda muitas vezes por segundo, e a página não é redesenhada a cada
// mudança: quem observa o estado vivo lê o bloco depois de mexer o cursor e
// tocar em qualquer botão. O diário registra só a escolha, que é o evento raro.
oficina.aoMudarSelecao((mudanca) => {
if (mudanca.selecionada === undefined) {
return;
}
diario.nota(
`Peça escolhida: ${mudanca.selecionada}. O raio acertou "${mudanca.noAtingido ?? 'nada'}" e ` +
`subiu ${mudanca.degrausDeSubida} nível(is) da árvore até chegar nela.`,
);
atualizarInteracao();
});
// O enquadramento é a prova da exigência da tarefa: todos os objetos do domínio
// alcançáveis, e não só os que por acaso nasceram diante da câmera.
botaoEnquadrar.addEventListener('click', () => {
oficina.enquadrarTudo();
atualizarJanela();
atualizarCusto();
atualizarInteracao();
diario.nota(
'A cena inteira foi enquadrada. A distância ao alvo é a que faz a esfera envolvente caber no campo de visão.',
);
});
void oficina
.trazerAtivoExterno()
.then((linhas: string[]) => {
laudoDoAtivo = linhas;
atualizarCusto();
diario.nota(
'A morsa chegou e foi ajustada à convenção da cena. O laudo com o que foi corrigido está na folha de ativos.',
);
})
.catch((erro: unknown) => {
// Ativo que não chega é falha de conteúdo, e precisa aparecer como falha. A
// cena continua de pé sem ele, e é justamente por continuar que o silêncio
// seria perigoso: ninguém notaria a morsa faltando.
diario.falha(explicarFalha(erro));
});
function atualizarSessao(): void {
montarSessao(raizSessao, oficina.sessao(), oficina.plataforma());
}
atualizarSessao();
// A sessão pode terminar sem que este código peça: quem usa sai pelo menu do
// sistema, tira o aparelho da cabeça, deixa a bateria acabar. Por isso o estado
// da página acompanha o aviso do ciclo, e não o retorno do botão.
oficina.aoMudarSessao(
(aberta) => {
atualizarSessao();
atualizarInteracao();
diario.nota(
`Sessão ${aberta.modo} aberta, com espaço de referência ${aberta.espacoObtido} e ` +
`composição ${aberta.composicao}. O controle rastreado assumiu o apontamento, e ` +
'nenhuma linha de seleção, agarre ou encaixe mudou para isso acontecer.',
);
},
() => {
atualizarSessao();
atualizarInteracao();
atualizarJanela();
diario.nota(
'Sessão encerrada. O laço voltou à cadência da janela, o cursor voltou a ser a ' +
'fonte de apontamento e a câmera foi devolvida ao ponto em que a órbita a deixou.',
);
},
);
async function entrarEm(modo: ModoDeSessao): Promise<void> {
try {
await oficina.entrarEmSessao(modo);
} catch (erro: unknown) {
// Sessão que não abre é resultado, e precisa ser lido no próprio aparelho —
// quem está de visor não abre console de depuração.
diario.falha(explicarFalha(erro));
atualizarSessao();
}
}
botaoEntrarVr.addEventListener('click', () => {
void entrarEm('immersive-vr');
});
botaoEntrarAr.addEventListener('click', () => {
void entrarEm('immersive-ar');
});
botaoSair.addEventListener('click', () => {
void oficina.sairDaSessao();
});
botaoMedir.addEventListener('click', () => {
atualizarCusto();
atualizarInteracao();
atualizarManipulacao();
atualizarSessao();
diario.nota(
'Medição refeita. O número vale para esta máquina e para este instante — anote os dois ao lado dele.',
);
});
botaoAproximar.addEventListener('click', () => {
const perto: boolean = oficina.alternarAproximacao();
botaoAproximar.textContent = perto
? 'Voltar para a bancada'
: 'Aproximar da prateleira do fundo';
diario.nota(
perto
? 'A câmera foi para junto da prateleira: o nível detalhado entrou, e a contagem de triângulos subiu.'
: 'A câmera voltou para a bancada: a prateleira ficou longe, e o nível simplificado assumiu.',
);
atualizarCusto();
atualizarJanela();
});
botaoPrender.addEventListener('click', () => {
const antes: string = oficina.posicaoDaEngrenagem();
const desvio: number = oficina.presa() ? oficina.soltar() : oficina.prender();
const destino: string = oficina.presa() ? 'ao eixo' : 'ao tampo';
diario.nota(
`A engrenagem passou a pertencer ${destino}. Estava em ${antes}, ficou em ` +
`${oficina.posicaoDaEngrenagem()}, e o desvio medido foi de ${desvio.toExponential(1)} m.`,
);
botaoPrender.textContent = oficina.presa()
? 'Soltar a engrenagem do eixo'
: 'Prender a engrenagem ao eixo';
montarEstruturaDaCena(raizEstrutura, oficina.estrutura(), frasesSobreAOrdem());
});
// ---------------------------------------------------------------------------
// Ambiente imersivo: escala corporal, alcance do braço e conforto.
//
// Os controles ficam no fim da página de propósito. Eles só dizem alguma coisa
// depois de uma sessão ter sido aberta, e pô-los ao lado dos botões da cena
// convidaria a lê-los no desktop — onde a conferência de escala mede a
// construção da cena, e escala corporal, alcance e conforto simplesmente não
// existem.
// ---------------------------------------------------------------------------
const raizImersao: HTMLElement = exigirElemento('imersao');
const botaoAferirCorpo: HTMLElement = exigirElemento('aferir-corpo');
const botaoAproximarPecas: HTMLElement = exigirElemento('aproximar-pecas');
const botaoCorrigir: HTMLElement = exigirElemento('corrigir-desconforto');
function atualizarImersao(): void {
montarImersao(raizImersao, oficina.escalaCorporal(), oficina.alcance(), oficina.conforto());
}
atualizarImersao();
botaoAferirCorpo.addEventListener('click', () => {
atualizarImersao();
diario.nota(
'Aferição refeita. Fora de sessão isto confere a construção da cena; dentro dela, também o ' +
'assentamento contra a origem que o aparelho concedeu.',
);
});
botaoAproximarPecas.addEventListener('click', () => {
const relato: string[] = oficina.aproximarPecas();
atualizarImersao();
diario.nota(
'As peças fora do braço foram trazidas para a frente do tampo. ' +
`${relato.length} linhas de laudo abaixo, com o avanço de cada uma.`,
);
});
for (const caso of CASOS) {
const botao: HTMLElement = exigirElemento(`provocar-${caso.id}`);
botao.addEventListener('click', () => {
diario.alerta(
`Provocação em curso: ${oficina.provocar(caso.id)} Ela se corrige sozinha em poucos ` +
'segundos. Avise antes quem estiver com o visor no rosto.',
);
atualizarImersao();
});
}
botaoCorrigir.addEventListener('click', () => {
oficina.corrigirDesconforto();
atualizarImersao();
diario.nota('Provocação interrompida. O ambiente voltou ao caso bom.');
});
// ---------------------------------------------------------------------------
// Locomoção: o salto, a borda da sala e a comparação entre as duas formas.
//
// Fecha a página porque depende de tudo o que veio antes — da abstração de
// apontar, que dá a mira; do ciclo de sessão, que dá o espaço de referência a
// deslocar; e do contador de engasgos, que dá metade da evidência da comparação.
// ---------------------------------------------------------------------------
const raizLocomocao: HTMLElement = exigirElemento('locomocao');
const botaoAlternarDeslize: HTMLElement = exigirElemento('alternar-deslize');
const botaoAferirLocomocao: HTMLElement = exigirElemento('aferir-locomocao');
const botaoIniciarEnsaio: HTMLElement = exigirElemento('iniciar-ensaio');
const botaoRegistrarEnsaio: HTMLElement = exigirElemento('registrar-ensaio');
function atualizarLocomocao(): void {
montarLocomocao(
raizLocomocao,
oficina.locomocao(),
oficina.areaFisica(),
oficina.comparacao(),
);
}
atualizarLocomocao();
botaoAlternarDeslize.addEventListener('click', () => {
const deslizando: boolean = oficina.alternarDeslize();
botaoAlternarDeslize.textContent = deslizando
? 'Voltar ao salto'
: 'Trocar o salto pelo deslize contínuo';
diario.alerta(
deslizando
? 'Deslize contínuo ligado. Ele existe para ser experimentado e medido, não para ficar ' +
'ligado: avise quem for entrar, e mantenha o ensaio curto.'
: 'De volta ao salto. O comando para a frente volta a abrir a mira, e o eixo horizontal ' +
'volta a girar em passos.',
);
atualizarLocomocao();
});
botaoAferirLocomocao.addEventListener('click', () => {
atualizarLocomocao();
atualizarImersao();
diario.nota(
'Aferição refeita. Fora de sessão, o salto move o alvo da órbita e a borda física não existe ' +
'para ser consultada; dentro dela, os dois passam a valer.',
);
});
botaoIniciarEnsaio.addEventListener('click', () => {
oficina.iniciarEnsaio();
diario.nota(
'Ensaio começado. A partir de agora contam-se o tempo, os metros percorridos e os engasgos, ' +
'e no fim resta perguntar a quem experimentou o que só ele responde.',
);
});
botaoRegistrarEnsaio.addEventListener('click', () => {
// O registro se faz no desktop, com o visor já fora do rosto: uma caixa de
// diálogo do navegador dentro da sessão imersiva não aparece onde quem está lá
// dentro consegue ler.
const observador: string | null = window.prompt(
'Quem experimentou? Nunca quem construiu a locomoção — o hábito apaga o efeito.',
);
if (observador === null || observador.trim() === '') {
diario.alerta('Ensaio não registrado: sem observador, o relato não tem origem.');
return;
}
const relato: string | null = window.prompt('Como foi, nas palavras de quem experimentou?');
if (relato === null || relato.trim() === '') {
diario.alerta('Ensaio não registrado: o relato é a metade que a instrumentação não mede.');
return;
}
const ensaio: Ensaio = oficina.registrarEnsaio(observador.trim(), relato.trim());
atualizarLocomocao();
diario.nota(
`Ensaio de ${ensaio.forma} registrado: ${ensaio.duracaoS.toFixed(0)} s, ` +
`${ensaio.metrosPercorridos.toFixed(1)} m e ${ensaio.engasgosNoPeriodo} engasgos no período.`,
);
});
// ---------------------------------------------------------------------------
// Ancoragem: a cena sobre o mundo físico, o pouso e a perda de referência.
//
// Última seção da página porque é a única cujo conteúdo inteiro depende de uma
// sessão aumentada aberta. Os dois botões abaixo só têm efeito lá dentro, e é
// deliberado que eles digam isso pela leitura da folha, em vez de aparecerem
// desabilitados: botão morto ensina que o ambiente não funciona neste aparelho,
// e o que se quer ensinar é qual capacidade falta.
// ---------------------------------------------------------------------------
const raizAncoragem: HTMLElement = exigirElemento('ancoragem');
const botaoRepousar: HTMLElement = exigirElemento('repousar');
const botaoAferirAncoragem: HTMLElement = exigirElemento('aferir-ancoragem');
function atualizarAncoragem(): void {
montarAncoragem(raizAncoragem, oficina.ancoragem());
}
atualizarAncoragem();
botaoRepousar.addEventListener('click', () => {
oficina.repousar();
atualizarAncoragem();
diario.nota(
'A bancada voltou ao lugar de origem e espera outra escolha. Repousá-la sobre a mesma mesa, ' +
'de dois pontos diferentes, é o ensaio que mostra a precisão real da detecção nesta sala.',
);
});
// ---------------------------------------------------------------------------
// Registro por marcador e degradação graciosa: o parque heterogêneo resolvido.
//
// Fecha a página porque é o trecho que só faz sentido depois de todos os outros:
// ele escolhe entre os regimes que os trechos anteriores construíram, e o
// registro por papel impresso só se entende por contraste com o registro por
// superfície do trecho de cima.
// ---------------------------------------------------------------------------
const raizMarcador: HTMLElement = exigirElemento('marcador');
const raizDegradacao: HTMLElement = exigirElemento('degradacao');
const botaoImprimirMarcador: HTMLElement = exigirElemento('imprimir-marcador');
const botaoEntrarMarcador: HTMLElement = exigirElemento('entrar-marcador');
const botaoAferirMarcador: HTMLElement = exigirElemento('aferir-marcador');
const problemasDoPadrao: string[] = inconsistenciasDoPadrao();
if (problemasDoPadrao.length > 0) {
diario.alerta(`O padrão do marcador tem problemas: ${problemasDoPadrao.join(' ')}`);
}
function atualizarMarcador(): void {
montarMarcador(raizMarcador, oficina.marcador());
}
function atualizarDegradacao(): void {
montarDegradacao(raizDegradacao, oficina.degradacao());
}
atualizarMarcador();
atualizarDegradacao();
// A decisão de regime é tomada ao carregar, e não ao clicar. O que ela NÃO faz
// é abrir sessão nem ligar câmera por conta própria: as duas coisas exigem gesto
// de quem usa, e a segunda acenderia a luz do aparelho sem ninguém pedir.
void oficina.decidirRegime().then((escolha: Escolha) => {
atualizarDegradacao();
diario.nota(
`Consulta ao aparelho concluída. O melhor regime disponível aqui é ${escolha.nome}` +
(escolha.preteridos.length === 0
? ', e nada foi degradado.'
: `, depois de ${escolha.preteridos.length} regime(s) fora de alcance — a folha abaixo diz o que falta em cada um e o que este aparelho ainda faz.`),
);
});
botaoImprimirMarcador.addEventListener('click', () => {
// A imagem é aberta numa aba nova em vez de baixada. Baixar exigiria um nome
// de arquivo e um caminho, e o que se quer aqui é levar o desenho até a
// impressora com o menor número de passos possível.
const desenho: HTMLCanvasElement = desenharParaImpressao(1024);
const aba: Window | null = window.open('');
if (aba === null) {
diario.alerta(
'O navegador bloqueou a abertura da aba com o marcador. Libere as janelas para este ' +
'endereço, ou imprima o desenho a partir de outra máquina.',
);
return;
}
const imagem: HTMLImageElement = aba.document.createElement('img');
imagem.src = desenho.toDataURL('image/png');
imagem.style.width = '15cm';
aba.document.body.appendChild(imagem);
diario.nota(
'Marcador aberto em outra aba, dimensionado para 15 cm de lado. Imprima SEM o ajuste ' +
'automático à página e confira o quadrado preto com uma régua: o número está no código, e ' +
'divergência entre os dois vira erro de escala do mundo virtual.',
);
});
botaoEntrarMarcador.addEventListener('click', () => {
if (oficina.registrandoPorMarcador()) {
oficina.sairDoMarcador();
botaoEntrarMarcador.textContent = 'Registrar por marcador impresso';
atualizarMarcador();
atualizarJanela();
diario.nota('Câmera desligada e regime em janela de volta. O fundo da cena voltou a ser nosso.');
return;
}
void oficina.entrarPorMarcador().then(() => {
const ligou: boolean = oficina.registrandoPorMarcador();
botaoEntrarMarcador.textContent = ligou
? 'Desligar a câmera e voltar à janela'
: 'Registrar por marcador impresso';
atualizarMarcador();
diario.nota(
ligou
? 'Câmera ligada e detecção em curso. Aponte para o papel: a bancada nasce sobre ele, e ' +
'o tremor entre quadros está medido na folha abaixo.'
: 'A câmera não foi obtida, e a folha abaixo diz por quê. O regime em janela continua ' +
'inteiro, e ele monta a bancada do primeiro parafuso ao último.',
);
});
});
botaoAferirMarcador.addEventListener('click', () => {
atualizarMarcador();
atualizarDegradacao();
diario.nota(
oficina.registrandoPorMarcador()
? 'Aferição refeita com a câmera ligada. Apoie o aparelho numa superfície firme e leia o ' +
'tremor: com tudo parado, ele deveria ser zero, e não é.'
: 'Aferição refeita fora do regime por marcador. A folha informa a medida do papel a ' +
'conferir com régua e o que a estimativa de câmera assume.',
);
});
botaoAferirAncoragem.addEventListener('click', () => {
atualizarAncoragem();
diario.nota(
oficina.emRealidadeAumentada()
? 'Aferição refeita dentro da sessão aumentada. A correção acumulada é o número que ' +
'cresce enquanto se anda em volta da bancada.'
: 'Aferição refeita fora de sessão aumentada: a folha informa o que este aparelho responde ' +
'sem ela, que é quase nada — e essa é a resposta certa, não uma folha vazia.',
);
});
// ---------------------------------------------------------------------------
// O fecho do percurso: compor, medir a própria leitura e registrar.
//
// Este trecho é o único da página que não estreia camada. Ele pergunta ao
// ambiente três coisas que nenhum módulo anterior tinha como perguntar: se tudo
// o que foi construído continua ligado, se o painel está sendo lido de onde a
// pessoa está, e o que se sabe sobre as decisões e os aparelhos.
//
// Os botões ficam no fim por ordem de uso, e não por importância. O de abrir o
// melhor regime é o que se aperta primeiro numa demonstração — e mesmo ele fica
// aqui, porque a orientação é começar pelo regime em janela, que sempre abre e
// já mostra a cena inteira, e só depois trocar de aparelho sem trocar de
// endereço.
// ---------------------------------------------------------------------------
const raizComposicao: HTMLElement = exigirElemento('composicao');
const raizPainelDiegetico: HTMLElement = exigirElemento('painel-diegetico');
const raizRegistro: HTMLElement = exigirElemento('registro');
const botaoAbrirMelhor: HTMLElement = exigirElemento('abrir-melhor');
const botaoAuditarComposicao: HTMLElement = exigirElemento('auditar-composicao');
const botaoChamarPainel: HTMLElement = exigirElemento('chamar-painel');
const botaoAferirPainel: HTMLElement = exigirElemento('aferir-painel');
const botaoRegistrarAparelho: HTMLElement = exigirElemento('registrar-aparelho');
const botaoRegistrarObservacao: HTMLElement = exigirElemento('registrar-observacao');
const botaoExportarRegistro: HTMLElement = exigirElemento('exportar-registro');
function atualizarComposicao(): void {
montarComposicao(raizComposicao, oficina.composicao(), oficina.percurso());
}
function atualizarPainelDiegetico(): void {
montarPainelDiegetico(raizPainelDiegetico, oficina.legibilidadeDoPainel());
}
function atualizarRegistro(): void {
montarRegistroDoProjeto(raizRegistro, oficina.registro(), oficina.uso());
}
atualizarComposicao();
atualizarPainelDiegetico();
atualizarRegistro();
botaoAuditarComposicao.addEventListener('click', () => {
atualizarComposicao();
diario.nota(
'Auditoria refeita. Ela confere ligação, e não comportamento: o que cada camada deve fazer ' +
'à vista está escrito ao lado dela, e essa conferência é de gente.',
);
});
// A abertura pelo melhor regime é o gesto que faltava para a decisão do módulo
// anterior virar ação. Ela mora num botão pelo mesmo motivo de sempre: o
// navegador recusa sessão e câmera pedidas sem toque de quem usa.
botaoAbrirMelhor.addEventListener('click', () => {
void oficina.abrirMelhorRegime().then(() => {
atualizarComposicao();
atualizarSessao();
atualizarMarcador();
atualizarDegradacao();
diario.nota(
`Abertura concluída. O regime em uso é ${oficina.regimeEmUso()}, e a folha da composição ` +
'traz cada tentativa com o que o aparelho respondeu.',
);
});
});
botaoChamarPainel.addEventListener('click', () => {
const perto: boolean = oficina.alternarChamadaDoPainel();
botaoChamarPainel.textContent = perto
? 'Devolver o painel ao lugar'
: 'Chamar o painel para perto';
atualizarPainelDiegetico();
diario.nota(
perto
? 'O painel veio para a borda da bancada voltada a quem lê. Ele continua pendurado no ' +
'suporte do tampo: soltá-lo da bancada para segui-lo pela sala o faria deixar de ser ' +
'objeto do mundo justamente quando é mais útil.'
: 'O painel voltou ao lugar de origem sobre o tampo.',
);
});
botaoAferirPainel.addEventListener('click', () => {
atualizarPainelDiegetico();
diario.nota(
'Leitura aferida a partir de onde a câmera está agora. Dentro do visor, afaste-se dois ' +
'passos e refaça: é o ponto em que a altura aparente da letra cruza o limiar adotado.',
);
});
botaoRegistrarAparelho.addEventListener('click', () => {
// O registro se faz por caixa de diálogo do navegador, e portanto fora da
// sessão imersiva, com o visor já fora do rosto. Dentro dela o diálogo não
// aparece onde quem está lá consegue ler — a mesma limitação do ensaio de
// conforto, e a mesma resposta.
const aparelho: string | null = window.prompt(
'Qual aparelho? Modelo e navegador, não "meu celular" — o parque é heterogêneo, e o que se ' +
'quer saber é onde funcionou.',
);
if (aparelho === null || aparelho.trim() === '') {
diario.alerta('Aparelho não registrado: sem o modelo, a linha não diz onde o ambiente abriu.');
return;
}
const funcionou: string | null = window.prompt('O que funcionou neste aparelho?');
const naoFuncionou: string | null = window.prompt(
'E o que não funcionou? "Nada" também é resposta.',
);
const quem: string | null = window.prompt('Quem abriu?');
if (funcionou === null || naoFuncionou === null || quem === null || quem.trim() === '') {
diario.alerta('Aparelho não registrado: o registro incompleto é o que envelhece pior.');
return;
}
oficina.registrarAparelho({
aparelho: aparelho.trim(),
regimeAberto: oficina.regimeEmUso(),
oQueFuncionou: funcionou.trim(),
oQueNaoFuncionou: naoFuncionou.trim(),
quemAbriu: quem.trim(),
});
atualizarRegistro();
diario.nota(
'Aparelho registrado. Ele fica no armazenamento local DESTE navegador: o que foi anotado no ' +
'celular ficou no celular, e o destino da lista é o repositório do projeto.',
);
});
botaoRegistrarObservacao.addEventListener('click', () => {
const quem: string | null = window.prompt(
'Quem montou? Nunca quem construiu o ambiente — o hábito apaga o efeito.',
);
if (quem === null || quem.trim() === '') {
diario.alerta('Observação não registrada: sem quem experimentou, o relato não tem origem.');
return;
}
const travou: string | null = window.prompt(
'Onde essa pessoa travou? Deixe vazio se ela concluiu a montagem.',
);
if (travou === null) {
return;
}
const relato: string | null = window.prompt('Nas palavras dela, e não no seu resumo:');
if (relato === null || relato.trim() === '') {
diario.alerta('Observação não registrada: o relato é a metade que a instrumentação não mede.');
return;
}
oficina.registrarObservacao({
quem: quem.trim(),
regime: oficina.regimeEmUso(),
ondeTravou: travou.trim(),
relato: relato.trim(),
concluiu: travou.trim() === '',
});
atualizarRegistro();
diario.nota(
'Observação registrada nesta sessão. Ela some ao recarregar a página, de propósito: ' +
'transcreva-a enquanto quem montou ainda está por perto e pode ser perguntado.',
);
});
botaoExportarRegistro.addEventListener('click', () => {
// O texto vai para uma aba nova em vez de baixar como arquivo, pela mesma
// razão do desenho do marcador: baixar exigiria nome e caminho, e o que se
// quer é levar o texto até o repositório com o menor número de passos.
const aba: Window | null = window.open('');
if (aba === null) {
diario.alerta(
'O navegador bloqueou a aba com o registro. Libere as janelas para este endereço, ou copie ' +
'o texto da folha acima.',
);
return;
}
const bloco: HTMLPreElement = aba.document.createElement('pre');
bloco.textContent = oficina.exportarRegistro();
aba.document.body.appendChild(bloco);
diario.nota(
'Registro aberto em outra aba, já formatado. Cole-o no repositório do projeto: o ' +
'armazenamento local não viaja, e registro que morre no aparelho em que foi feito é a ' +
'lista escrita de memória na véspera da entrega.',
);
});
O primeiro tempo roda ao carregar e traz o que se responde sem sessão. O segundo espera o toque, porque não tem escolha. A divisão parece detalhe de interface e é consequência direta da regra de gesto do navegador.
Duas decisões de apresentação são de engenharia, e não de gosto. O botão é grande porque, no visor, a mira é um raio de controle a um metro de distância, e alvo pequeno vira exercício de pontaria. O corpo de texto é generoso pelo mesmo motivo: leitura a um braço de distância, com a resolução angular que o aparelho tem, não é leitura de tela de computador.
1.4.3 Onde é fácil errar, e como conferir
O erro previsível é o que a tutoria vai encontrar em pelo menos um grupo: o relatório escrito no console e em mais lugar nenhum. Ele funciona perfeitamente na máquina de quem programou. Ele some no aparelho, que é justamente onde precisava ser lido.
O segundo erro é mais sutil e passa por competência. O grupo captura a exceção, evita que a página quebre, e não mostra nada. A página fica intacta e silenciosa, e a conclusão de quem está de visor é que o botão não funciona.
A conferência tem duas perguntas. Alguém que nunca viu o código consegue ler o relatório no aparelho e dizer o que ele concede? E quando a sondagem falha, a página diz que falhou e por quê, sem que ninguém precise de um computador ao lado?
1.5 De onde vem a pose: por dentro e por fora
Um tópico do módulo que não vira código, e é honesto declarar isso.
Há dois arranjos para descobrir onde um aparelho está. No primeiro, os sensores estão no próprio aparelho e olham para fora, reconhecendo o ambiente. No segundo, há equipamento fixo na sala que olha para o aparelho e calcula sua posição.
Cada arranjo cobra um preço diferente. O sensor embarcado não exige instalação, funciona em qualquer sala e depende do que a sala oferece de textura e de luz. A infraestrutura externa exige montagem, calibração e um espaço dedicado, e em troca entrega precisão que não depende da aparência da sala.
O ancestral do segundo arranjo é o mesmo aparelho que abre a história da área. Em 1968, o visor de Sutherland precisava ficar pendurado no teto do laboratório por um braço mecânico articulado — e esse braço não estava ali só para segurar peso. Ele media a posição da cabeça de quem o vestia. O rastreamento por infraestrutura externa nasceu literalmente preso ao teto.
Este tópico não vira código, e forçá-lo seria fabricar conteúdo. A API não informa qual arranjo o aparelho usa, e não informa de propósito: para quem escreve o ambiente, a pose chega igual dos dois. O que o arranjo decide é o que acontece quando ele falha, e é aí que ele volta a importar.
1.6 O cartógrafo no escuro, e o quadro em que ele se perde
A pergunta que o módulo inteiro persegue: como o aparelho sabe onde está?
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. É isso que localização e mapeamento simultâneos querem dizer, e as duas metades da frase acontecem ao mesmo tempo — daí o nome.
O problema tem forma de círculo. Para saber onde está, o aparelho precisa do mapa. Para desenhar o mapa, precisa saber de onde está olhando. Ele resolve o círculo estimando os dois juntos e corrigindo os dois quando reconhece algo já visto.
Nada disso é chamada de função nossa. O mapa é construído pelo sistema do aparelho, e o que a sessão nos entrega são consequências dele: os planos que ele reconheceu, o raio que se lança contra superfícies reais, a âncora que ele mantém corrigida. Um aparelho que concede esses três está declarando que mantém um mapa; um que não os concede está declarando que não.
E quando o cartógrafo se perde? Aí a pose simplesmente não vem no quadro. A API prevê isso: o quadro entrega a pose ou não entrega nada. É o único sintoma de degradação de rastreamento que se lê de dentro do código, sem sensor adicional.
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;
}
/**
* 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 {
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 é deliberadamente burro. Conta quadros com pose e quadros sem, e o julgamento fica numa função separada. Misturar contagem e interpretação produz medidor que sempre concorda com quem o escreveu.
Um cuidado nesse contador vale por si só. 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 perda de rastreamento acusaria o aparelho de um defeito que é comportamento normal. Por isso o contador recebe também se a sessão está em primeiro plano.
As três condições que degradam o rastreamento óptico são conhecidas e se explicam pelo mecanismo. Superfície sem textura não oferece canto algum 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.
O diagnóstico que a função devolve nomeia as três e não escolhe uma. Daqui não se distingue qual delas aconteceu, e escolher seria inventar o laudo junto com a medida.
1.7 Verificação do estado deste módulo
O que precisa estar verdadeiro antes de o módulo seguinte começar.
| Verificação | Como se confere |
|---|---|
| A página está em contexto seguro | a primeira linha do relatório da sonda diz |
| Recurso negado e recurso sem resposta aparecem diferentes | ler a última coluna da tabela de recursos |
| A sessão encerra mesmo quando a sondagem falha | forçar erro e conferir que o visor volta ao navegador |
| O relatório é legível no próprio aparelho | ler no visor, sem computador ao lado |
| A falha aparece na página, e não só no console | desligar a rede e tocar o botão |
| O código analisa limpo em modo estrito | verificação de tipos do projeto, sem emissão |
| Aparelhos diferentes produzem relatórios diferentes | abrir a mesma URL no desktop, no celular e no visor |
A terceira linha é a que mais falha em sala, e falha silenciosamente. O erro acontece, a página captura, e o visor fica preso numa tela vazia até alguém achar o botão lateral. Vale forçar o caso ruim de propósito antes de a turma chegar.
O que fica pronto ao fim deste módulo é uma consulta honesta. O ambiente ainda não desenha nada, e passou a saber contra o que vai desenhar. A partir do módulo seguinte existe cena, e cada capacidade sondada aqui vira decisão de projeto lá.