1 Implementação e integração de ambientes virtuais e aumentados — 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
Treze módulos deixaram treze camadas. Quantas delas ainda estão ligadas?
Não sabíamos. Essa é a resposta honesta, e é o que este trecho veio resolver. Cada módulo do percurso entregou a sua camada funcionando, com o seu botão e a sua folha de diagnóstico, e cada uma foi conferida no dia em que nasceu. Nenhum deles tinha como responder pela ligação dois módulos adiante: quem escreve a camada olha para a camada, e o que se perde de vista é justamente o fio.
Este trecho não estreia camada nenhuma. Ele acrescenta cinco arquivos que perguntam ao ambiente coisas sobre ele mesmo — se tudo continua alcançável a partir do mesmo endereço, se o painel está sendo lido de onde a pessoa está, e o que se sabe sobre as decisões, os números e os aparelhos. É o módulo em que o artefato deixa de ser uma soma de demonstrações e passa a ser uma coisa só.
Duas medidas explicam por que ele existe. Ao ligar a auditoria pela primeira vez, o catálogo declarava dezoito camadas e a composição registrava dezesseis: a sonda de capacidades e o contador de engasgos estavam ligados, mas alcançáveis só pela folha de diagnóstico, e isso é legítimo — são instrumentação. O que a auditoria não achou, e é o resultado que interessa, foi camada de tarefa em caminho morto. O percurso resistiu. Se não tivesse resistido, o lugar de descobrir seria aqui, e não na demonstração final.
1.2 Tarefa 1: Compor o artefato e rotear o regime
A degradação não termina na escolha, e foi um diálogo de permissão negado que ensinou isso.
Enunciado da tarefa
Um endereço, três regimes, e a decisão de qual abrir tomada pelo próprio ambiente a partir do que o aparelho declarou. A última camada costura o que veio antes num artefato só.
O que fica pronto é a composição completa, com a degradação percorrendo a ordem declarada até o regime em janela, que sempre abre. Cada peça construída ao longo do percurso precisa estar alcançável a partir daqui, sem caminho morto e sem trecho que só rodava isolado.
Resolvemos a tarefa em duas partes, e elas atacam dois defeitos que não se parecem. O primeiro é de inventário: a camada que existe no pacote e que ninguém ligou. O segundo é de execução: o regime que a consulta prometeu e a abertura recusou.
1.2.1 O caminho morto, e o que a auditoria não pega
O verificador de tipos aprova código que nada chama. É a categoria inteira de defeito que ele não vê.
Comece pelo defeito, porque ele é discreto. Uma camada foi escrita no módulo sete, foi conferida naquela semana, e no módulo nove a composição mudou de forma. A linha que a ligava saiu junto. O arquivo continua ali, compila, analisa limpo sob o modo estrito, e não está no artefato que se entrega. Nada acusa — nem o compilador, nem o empacotador, nem a folha de diagnóstico, que continua exibindo o que aquela camada informava porque a folha lê o objeto, e o objeto continua sendo construído.
Nosso composicao.ts compara duas coisas: uma lista declarada à mão, com as dezoito camadas que o artefato deve conter, e o que a composição registra enquanto liga cada uma. Camada declarada e nunca registrada é caminho morto, e sai nomeada, com o módulo de origem junto — porque a primeira pergunta que se faz diante de um defeito é de quem ele é.
src/bancada/app/composicao.ts
// ---------------------------------------------------------------------------
// O inventário da composição, e o defeito que ele existe para pegar.
//
// Catorze módulos construíram camadas. Cada uma nasceu com a sua demonstração,
// o seu botão e a sua folha de relatório, e cada uma funcionava no dia em que
// foi escrita. O módulo final não acrescenta camada nenhuma: ele responde a uma
// pergunta que nenhum dos anteriores tinha como responder sozinho — todas elas
// continuam alcançáveis a partir do mesmo endereço?
//
// A pergunta não é retórica. Duas coisas acontecem num percurso longo, e nenhuma
// das duas dá erro de compilação. A primeira é o CAMINHO MORTO: a camada existe
// no pacote, o verificador de tipos a aprova, e nada na composição a liga — ela
// foi escrita, foi esquecida, e o artefato entregue não a contém. A segunda é o
// TRECHO ISOLADO: a camada roda, mas só a partir de um botão da folha de
// diagnóstico, e ninguém que abra o ambiente para montar o mecanismo passa por
// ela. As duas produzem a mesma frase na arguição — "isso está implementado" —
// e nas duas a demonstração ao vivo não chega lá.
//
// O que este arquivo faz é comparar uma lista DECLARADA com o que a composição
// REGISTRA ao subir. Camada declarada e não registrada é caminho morto. Camada
// de tarefa que só se alcança pela folha de diagnóstico é trecho isolado.
//
// O limite é grande e precisa estar escrito: isto prova que a camada foi LIGADA,
// não que ela funciona. Um agarre ligado e quebrado passa nesta auditoria. O que
// se ganha é outra coisa — a lista de tudo o que o artefato deveria conter, num
// lugar só, conferida por máquina a cada carregamento em vez de por memória na
// véspera da entrega.
// ---------------------------------------------------------------------------
/** Cada camada construída ao longo do percurso, pelo nome que ela tem no pacote. */
export type CamadaId =
| 'dominio'
| 'regimes'
| 'sonda'
| 'cena'
| 'conteudo'
| 'orbita'
| 'apontamento'
| 'selecao'
| 'manipulacao'
| 'montagem'
| 'sessao'
| 'corpo'
| 'conforto'
| 'locomocao'
| 'ancoragem'
| 'marcador'
| 'degradacao'
| 'painel';
/**
* Por onde se chega à camada a partir do endereço único.
*
* A distinção entre os três valores é o coração da auditoria. `tarefa` é a
* camada que quem monta o mecanismo atravessa sem saber que ela existe; `gesto`
* é a que espera um botão da própria cena; `folha` é a que só se alcança pela
* página de diagnóstico. A terceira não é ilegítima — parte do percurso é
* instrumentação, e instrumentação vive na folha —, mas camada de tarefa que
* caiu para `folha` é o trecho isolado que este módulo veio caçar.
*/
export type Alcance = 'tarefa' | 'gesto' | 'folha';
export interface Camada {
readonly id: CamadaId;
readonly nome: string;
/** O módulo em que ela passou a existir. Serve para achar o dono do defeito. */
readonly moduloDeOrigem: number;
readonly alcance: Alcance;
/** O que quem confere a olho deve ver acontecer para acreditar que ela está viva. */
readonly comoSeVe: string;
}
/**
* O catálogo do que o artefato deve conter.
*
* A lista é escrita à mão, e isso é decisão, não preguiça. Derivá-la do sistema
* de arquivos acharia todo arquivo que existe — inclusive o que existe por
* engano — e a auditoria passaria a confirmar o pacote contra ele mesmo. O que
* se quer conferir é o pacote contra a INTENÇÃO, e intenção não se descobre
* listando diretório.
*/
export const CATALOGO: readonly Camada[] = [
{
id: 'dominio',
nome: 'domínio delimitado',
moduloDeOrigem: 1,
alcance: 'tarefa',
comoSeVe: 'as cinco peças e os encaixes existem, e a conferência de peça órfã roda ao carregar',
},
{
id: 'regimes',
nome: 'declaração dos três regimes',
moduloDeOrigem: 1,
alcance: 'folha',
comoSeVe:
'a tabela de regimes confronta o que o projeto declarou com o que o navegador informa',
},
{
id: 'sonda',
nome: 'sonda de capacidades',
moduloDeOrigem: 1,
alcance: 'folha',
comoSeVe: 'a consulta responde antes da sessão, e a sondagem completa abre e fecha uma sessão',
},
{
id: 'cena',
nome: 'árvore da cena e laço',
moduloDeOrigem: 2,
alcance: 'tarefa',
comoSeVe: 'a bancada aparece e o eixo gira contra o relógio, não contra a contagem de quadros',
},
{
id: 'conteudo',
nome: 'forma, superfície e ativo importado',
moduloDeOrigem: 3,
alcance: 'tarefa',
comoSeVe:
'as peças têm volume e material próprios, e a morsa chega pela rede com a cena já de pé',
},
{
id: 'orbita',
nome: 'regime em janela',
moduloDeOrigem: 6,
alcance: 'tarefa',
comoSeVe: 'arrastar o cursor gira a câmera em torno da bancada, sem sessão nenhuma aberta',
},
{
id: 'apontamento',
nome: 'abstração de apontar',
moduloDeOrigem: 7,
alcance: 'tarefa',
comoSeVe: 'a mesma origem e direção chegam da fonte ativa, seja ela cursor, controle ou toque',
},
{
id: 'selecao',
nome: 'seleção por lançamento de raio',
moduloDeOrigem: 7,
alcance: 'tarefa',
comoSeVe: 'a peça sob a mira acende, e o tampo oclui o que está atrás dele',
},
{
id: 'manipulacao',
nome: 'agarre e transporte',
moduloDeOrigem: 8,
alcance: 'tarefa',
comoSeVe:
'a peça acompanha a mão com a orientação preservada e volta ao tampo quando solta longe',
},
{
id: 'montagem',
nome: 'encaixe e ordem da tarefa',
moduloDeOrigem: 8,
alcance: 'tarefa',
comoSeVe:
'a peça solta dentro da folga encaixa, e a solta fora de ordem é recusada pelo nome do que falta',
},
{
id: 'sessao',
nome: 'ciclo de sessão imersiva',
moduloDeOrigem: 9,
alcance: 'gesto',
comoSeVe:
'o botão abre a sessão, o espaço de referência obtido é dito, e sair pelo menu do sistema também fecha',
},
{
id: 'corpo',
nome: 'escala corporal e alcance do braço',
moduloDeOrigem: 10,
alcance: 'tarefa',
comoSeVe:
'dentro do visor a bancada tem altura de bancada, e as peças caem dentro do braço de quem trabalha',
},
{
id: 'conforto',
nome: 'contador de engasgos e provocações',
moduloDeOrigem: 10,
alcance: 'folha',
comoSeVe:
'as três provocações declaradas se corrigem sozinhas no prazo, e o pior intervalo da sessão fica registrado',
},
{
id: 'locomocao',
nome: 'salto, giro e borda física',
moduloDeOrigem: 11,
alcance: 'tarefa',
comoSeVe:
'o arco abre com o comando para a frente e o salto se confirma na soltura, com a peça na mão indo junto',
},
{
id: 'ancoragem',
nome: 'consulta de superfície, âncora e perda de rastreamento',
moduloDeOrigem: 12,
alcance: 'tarefa',
comoSeVe:
'na sessão aumentada o retículo encontra a mesa, o primeiro acionamento pousa a bancada, e a perda congela em vez de continuar',
},
{
id: 'marcador',
nome: 'registro por marcador impresso',
moduloDeOrigem: 13,
alcance: 'gesto',
comoSeVe:
'com a câmera ligada e o papel na mesa, a bancada nasce sobre ele — tremendo, com o tremor medido',
},
{
id: 'degradacao',
nome: 'ordem de preferência e mensagem honesta',
moduloDeOrigem: 13,
alcance: 'tarefa',
comoSeVe:
'ao carregar, o ambiente diz qual regime este aparelho alcança e o que falta nos que ele não alcança',
},
{
id: 'painel',
nome: 'painel diegético',
moduloDeOrigem: 14,
alcance: 'tarefa',
comoSeVe:
'o cartaz preso à bancada informa o passo, o aparelho e o custo, e continua legível de dentro do visor',
},
];
export interface Auditoria {
readonly declaradas: number;
readonly registradas: number;
/** Declaradas e nunca registradas: o caminho morto. */
readonly mortas: readonly Camada[];
/** Instrumentação, alcançável só pela folha de diagnóstico. */
readonly naFolha: readonly Camada[];
/** Registradas sem constar do catálogo: alguém ligou o que ninguém declarou. */
readonly forasteiras: readonly CamadaId[];
}
/**
* O registro que a composição alimenta enquanto liga cada camada.
*
* A chamada fica ao lado da linha que de fato compõe a camada, e não numa lista
* no fim do arquivo. Lista no fim é a primeira coisa a divergir do que o código
* faz, e uma auditoria que confere a lista contra a lista não confere nada.
*/
export class Composicao {
private readonly ligadas: Set<CamadaId> = new Set<CamadaId>();
registrar(id: CamadaId): void {
this.ligadas.add(id);
}
registrada(id: CamadaId): boolean {
return this.ligadas.has(id);
}
auditar(): Auditoria {
const mortas: Camada[] = CATALOGO.filter((camada) => !this.ligadas.has(camada.id));
const naFolha: Camada[] = CATALOGO.filter(
(camada) => this.ligadas.has(camada.id) && camada.alcance === 'folha',
);
const conhecidas: Set<CamadaId> = new Set<CamadaId>(CATALOGO.map((camada) => camada.id));
const forasteiras: CamadaId[] = [...this.ligadas].filter((id) => !conhecidas.has(id));
return {
declaradas: CATALOGO.length,
registradas: this.ligadas.size,
mortas,
naFolha,
forasteiras,
};
}
linhas(): string[] {
const auditoria: Auditoria = this.auditar();
const linhas: string[] = [
`Camadas declaradas: ${auditoria.declaradas}. Ligadas nesta composição: ${auditoria.registradas}.`,
'',
];
for (const camada of CATALOGO) {
const estado: string = this.ligadas.has(camada.id) ? 'ligada' : 'NÃO LIGADA';
linhas.push(
`[${estado}] ${camada.nome} (módulo ${camada.moduloDeOrigem}, alcance: ${camada.alcance}) — ${camada.comoSeVe}.`,
);
}
linhas.push('');
if (auditoria.mortas.length === 0) {
linhas.push('Nenhum caminho morto: toda camada declarada foi ligada por esta composição.');
} else {
linhas.push(
'CAMINHO MORTO. As camadas abaixo existem no pacote e não foram ligadas. Elas não estão ' +
'no artefato, embora o verificador de tipos as aprove:',
);
for (const camada of auditoria.mortas) {
linhas.push(` · ${camada.nome}, do módulo ${camada.moduloDeOrigem}.`);
}
}
linhas.push(
'',
`Alcançáveis só pela folha de diagnóstico: ${auditoria.naFolha.length}. São instrumentação, ` +
'e é onde instrumentação deve estar; a conferência a fazer é outra — que nenhuma camada de ' +
'tarefa tenha caído para cá.',
);
if (auditoria.forasteiras.length > 0) {
linhas.push(
'',
`Ligadas sem constar do catálogo: ${auditoria.forasteiras.join(', ')}. Ou a camada é nova e ` +
'o catálogo ficou para trás, ou o nome foi digitado errado.',
);
}
linhas.push(
'',
'O que esta auditoria NÃO diz: que as camadas funcionam. Ela confere ligação, não ' +
'comportamento. Um agarre ligado e quebrado passa aqui inteiro, e é por isso que o que se ' +
'vê acontecer está escrito ao lado de cada linha — é o roteiro da conferência a olho, que ' +
'continua sendo de gente.',
);
return linhas;
}
}Duas decisões dentro dele merecem defesa. A lista é escrita à mão em vez de ser derivada da pasta de arquivos, e isso parece preguiça invertida — derivar seria automático. Seria automático e inútil: a varredura acharia todo arquivo que existe, inclusive o que existe por engano, e a auditoria passaria a conferir o pacote contra ele mesmo. O que se quer conferir é o pacote contra a intenção, e intenção não se descobre listando diretório.
A segunda é que o registro fica ao lado da linha que de fato liga a camada, e não numa lista no fim do arquivo de composição. Lista no fim é a primeira coisa a divergir do código, e uma auditoria que confere a lista contra a lista não confere coisa alguma.
O limite é grande e está escrito no topo do arquivo, porque quem lê a saída vai querer confiar mais nela do que ela merece. A auditoria prova ligação, não comportamento. Um agarre ligado e quebrado passa por ela inteiro. É por isso que cada linha do catálogo traz o que se deve ver acontecer para acreditar que aquela camada está viva — essa coluna é o roteiro da conferência a olho, e essa continua sendo de gente.
1.2.2 Entre escolher o regime e conseguir abri-lo
A consulta disse que sim. O diálogo de permissão disse que não. Quem tem razão?
O trecho anterior escolhe: dada a lista do que o aparelho respondeu, a ordem de preferência aponta o melhor regime disponível. Escolher é barato e acontece ao carregar. Abrir é outra coisa — pede gesto de quem usa, demora, e pode falhar depois de a escolha ter sido dada como certa.
Entre uma coisa e outra cabem quatro casos, e os quatro foram vistos no laboratório: a permissão de câmera negada no diálogo, a sessão imersiva recusada porque outra aba já a tinha, o visor que respondeu “suporto realidade aumentada” e devolveu erro ao pedido, e o aparelho que entrou em sessão e saiu sozinho. Nos quatro, a sonda tinha respondido certo. O que falhou foi a abertura.
src/bancada/app/roteamento.ts
// ---------------------------------------------------------------------------
// A diferença entre escolher e abrir.
//
// O módulo anterior escolheu: dada a lista do que o aparelho respondeu, a ordem
// de preferência diz qual regime é o melhor disponível aqui. Escolher é barato e
// acontece ao carregar. Abrir é outra coisa — pede gesto de quem usa, pode
// demorar, e PODE FALHAR depois de ter sido dada como certa.
//
// Entre a escolha e a abertura cabem quatro coisas verificadas em laboratório: a
// permissão de câmera negada no diálogo, a sessão imersiva recusada porque outra
// aba já a tinha, o visor que respondeu "suporto realidade aumentada" e devolveu
// erro ao pedido, e o aparelho que entrou em sessão e saiu sozinho por bateria.
// Nos quatro casos a sonda tinha respondido certo. O que falhou foi a abertura.
//
// Por isso a degradação não termina na escolha. Ela percorre a ordem declarada
// PARA BAIXO, tentando abrir, e cada recusa é anotada com o motivo que o
// navegador deu — até chegar ao regime em janela, que não se abre porque já está
// aberto desde o carregamento. Esse é o sentido preciso de "sempre abre": não é
// que ele nunca falhe, é que ele não depende de abertura alguma.
//
// O percurso inteiro é devolvido, e não só o destino. Quem apresenta o trabalho
// precisa poder dizer por que está no terceiro regime da lista, e "o aparelho
// não deixou" não é resposta enquanto não vier acompanhada do que ele respondeu.
// ---------------------------------------------------------------------------
import {
ORDEM_DE_PREFERENCIA,
type Escolha,
type PosicaoDaOrdem,
type RegimeExecutavel,
} from './degradacao';
/**
* Como se abre um regime, e como se confirma que ele abriu.
*
* Os dois membros existem porque nem toda abertura falha por exceção. O registro
* por marcador resolve a promessa mesmo quando a câmera não veio: ele trata a
* recusa como estado, não como erro, e faz bem — quem negou a permissão não
* cometeu um erro de programa. Sem `abriu()`, o roteamento acreditaria na
* promessa cumprida e pararia num regime que não está de pé.
*/
export interface Abertura {
abrir(): Promise<void>;
abriu(): boolean;
/** O que dizer quando `abriu()` for falso sem exceção nenhuma ter sido lançada. */
motivoDaRecusa(): string;
}
export type ResultadoDaTentativa = 'aberto' | 'recusado' | 'fora de alcance' | 'já estava de pé';
export interface Tentativa {
readonly regime: RegimeExecutavel;
readonly nome: string;
readonly resultado: ResultadoDaTentativa;
/** Vazio quando o regime abriu. Nunca vazio quando não abriu. */
readonly motivo: string;
}
export interface Percurso {
readonly regimeAberto: RegimeExecutavel;
readonly nome: string;
readonly tentativas: readonly Tentativa[];
/** Verdadeiro quando a abertura falhou onde a escolha tinha dado certo. */
readonly degradouNaAbertura: boolean;
}
function posicaoDe(regime: RegimeExecutavel): number {
return ORDEM_DE_PREFERENCIA.findIndex((item) => item.regime === regime);
}
/**
* Traduz o que o navegador lançou numa frase que se lê no aparelho.
*
* Quem está de visor no rosto não abre console de depuração, e a mensagem crua
* de uma promessa rejeitada costuma ser uma palavra só. A tradução acrescenta o
* que fazer, porque a metade que falta numa mensagem de erro é sempre essa.
*/
function explicar(erro: unknown, posicao: PosicaoDaOrdem): string {
const cru: string = erro instanceof Error ? erro.message : String(erro);
if (cru.includes('NotAllowedError') || cru.includes('permission')) {
return (
`${cru} — a permissão foi negada, ou o pedido não veio de um toque. Toque no botão de novo ` +
'e aceite o pedido do navegador.'
);
}
if (cru.includes('InvalidStateError')) {
return `${cru} — já há uma sessão aberta neste aparelho, possivelmente em outra aba.`;
}
return `${cru} — o que falta, segundo a ordem declarada, é ${posicao.oQueFalta}.`;
}
/**
* Desce a ordem de preferência abrindo, e devolve o percurso inteiro.
*
* O laço começa na posição da escolha, e não no topo: os regimes acima dela já
* foram descartados pela sonda, e tentar abrir o que o aparelho declarou não
* suportar produziria um diálogo de permissão para um regime impossível. Eles
* entram no percurso como `fora de alcance`, com o motivo que a ordem declarada
* já trazia escrito.
*/
export async function abrirDescendo(
escolha: Escolha,
aberturas: ReadonlyMap<RegimeExecutavel, Abertura>,
): Promise<Percurso> {
const tentativas: Tentativa[] = [];
for (const preterido of escolha.preteridos) {
tentativas.push({
regime: preterido.regime,
nome: preterido.nome,
resultado: 'fora de alcance',
motivo: `o que falta é ${preterido.oQueFalta}`,
});
}
const inicio: number = posicaoDe(escolha.regime);
for (let indice: number = inicio; indice < ORDEM_DE_PREFERENCIA.length; indice += 1) {
const posicao: PosicaoDaOrdem | undefined = ORDEM_DE_PREFERENCIA[indice];
if (posicao === undefined) {
break;
}
const abertura: Abertura | undefined = aberturas.get(posicao.regime);
if (abertura === undefined) {
// Sem abertura registrada, o regime é o que já está de pé. É o caso da
// janela, e é deliberado que ele não tenha uma: um regime que precisasse
// ser aberto para servir de destino da degradação não serviria de destino.
tentativas.push({
regime: posicao.regime,
nome: posicao.nome,
resultado: 'já estava de pé',
motivo: '',
});
return {
regimeAberto: posicao.regime,
nome: posicao.nome,
tentativas,
degradouNaAbertura: indice > inicio,
};
}
try {
await abertura.abrir();
} catch (erro: unknown) {
tentativas.push({
regime: posicao.regime,
nome: posicao.nome,
resultado: 'recusado',
motivo: explicar(erro, posicao),
});
continue;
}
if (!abertura.abriu()) {
tentativas.push({
regime: posicao.regime,
nome: posicao.nome,
resultado: 'recusado',
motivo: abertura.motivoDaRecusa(),
});
continue;
}
tentativas.push({
regime: posicao.regime,
nome: posicao.nome,
resultado: 'aberto',
motivo: '',
});
return {
regimeAberto: posicao.regime,
nome: posicao.nome,
tentativas,
degradouNaAbertura: indice > inicio,
};
}
// Inalcançável enquanto a janela for a última posição e não tiver abertura
// registrada. O retorno existe porque a lista é editável, e um ambiente sem
// destino de degradação precisa dizer isso em vez de devolver ausência.
return {
regimeAberto: 'janela',
nome: 'realidade virtual não imersiva',
tentativas,
degradouNaAbertura: true,
};
}
/** O percurso em texto, para a folha e para o diário. */
export function linhasDoPercurso(percurso: Percurso | undefined): string[] {
if (percurso === undefined) {
return [
'Nenhuma abertura foi pedida ainda. O ambiente está no regime em janela, que sobe com a ' +
'página; entrar num regime imersivo ou ligar a câmera exige um toque, e o navegador ' +
'recusa qualquer um dos dois sem ele.',
];
}
const linhas: string[] = [`Regime em uso: ${percurso.nome}.`, ''];
for (const tentativa of percurso.tentativas) {
const cauda: string = tentativa.motivo === '' ? '' : ` — ${tentativa.motivo}`;
linhas.push(`${tentativa.nome}: ${tentativa.resultado}${cauda}.`);
}
linhas.push('');
if (percurso.degradouNaAbertura) {
linhas.push(
'A degradação aconteceu NA ABERTURA, e não na consulta: a sonda disse que este aparelho ' +
'alcançava um regime acima, e a abertura foi recusada. É o caso mais confuso de todos ' +
'para quem apresenta o trabalho, e por isso o motivo da recusa está escrito acima em vez ' +
'de ser resumido como falta de suporte.',
);
} else {
linhas.push(
'A abertura correspondeu à consulta: o regime que o aparelho declarou alcançar foi o que ' +
'de fato abriu.',
);
}
return linhas;
}Por isso a degradação continua descendo a ordem declarada, tentando abrir, até chegar ao regime em janela. Repare no mecanismo que faz o destino ser destino: a janela é o único regime sem abertura registrada, e o laço para no primeiro que não tem uma. É a ausência funcionando como decisão. Um regime que precisasse ser aberto para servir de destino da degradação não serviria de destino, e essa é a diferença entre “sempre abre” e “não depende de abrir”.
A segunda decisão do arquivo custou uma tarde de depuração. Nem toda abertura falha por exceção: o registro por marcador cumpre a promessa mesmo quando a câmera não veio, porque ele trata a recusa como estado e não como erro — e faz bem, já que quem negou a permissão não cometeu falha de programa. Sem o segundo membro da interface, o abriu(), o roteamento acreditaria na promessa cumprida e pararia num regime que não está de pé, com a tela mostrando a bancada em fundo cinza e ninguém sabendo por quê.
O que se devolve é o percurso inteiro, e não só o destino. Quem apresenta o trabalho precisa poder dizer por que está no terceiro regime da lista, e “o aparelho não deixou” não é resposta enquanto não vier com o que ele respondeu.
1.3 Tarefa 2: Colocar o painel dentro da cena
Escada rolante parada ainda é escada; ambiente que só existe com o visor no rosto não é nada sem ele.
Enunciado da tarefa
A informação sobre o próprio ambiente passa a viver dentro dele: o passo atual da tarefa, o aparelho detectado, as capacidades concedidas e o custo do quadro. Painel preso à janela do navegador desaparece assim que a sessão imersiva começa, justamente quando ele faria mais falta.
A tarefa fecha quando o painel é legível nos três regimes, ancorado a algo que existe na cena. Ele fecha o arco aberto pela sonda de capacidades e transforma o relatório do início em instrumento de uso permanente.
O cartaz preso à bancada existe desde os primeiros módulos, e até aqui informava uma coisa só: o custo do quadro. Fazia sentido — ele nasceu no módulo em que o orçamento era o assunto. O que este trecho descobriu é que a ordem estava invertida.
1.3.1 A ordem de descarte
Seis linhas e meio metro de largura, para quem está com as mãos ocupadas.
A folha de diagnóstico da página tem rolagem e centenas de linhas. O painel tem seis linhas, e quem o lê está montando um mecanismo. É outra seleção, feita sob restrição, e a restrição é o que dá ao informe.ts a única decisão que ele toma.
src/bancada/ui/informe.ts
// ---------------------------------------------------------------------------
// O que cabe num cartaz de bancada.
//
// A folha de diagnóstico da página tem rolagem e centenas de linhas. O painel
// diegético tem seis linhas e meio metro de largura, e quem o lê está com as
// mãos ocupadas montando um mecanismo. Não é a mesma informação em corpo menor:
// é outra seleção, feita sob restrição, e a restrição é o que dá a este arquivo
// a única decisão que ele toma — a ORDEM DE DESCARTE.
//
// A ordem está escrita abaixo e é defensável linha a linha. O que ela não é: o
// resultado de encher o painel na ordem em que os números foram sendo escritos
// ao longo do percurso. Essa foi a primeira versão, e ela punha o custo do
// quadro no topo porque o orçamento era o assunto do módulo em que o painel
// nasceu. Quem estava montando o mecanismo lia dois números de milissegundos e
// não achava o nome da peça que faltava.
// ---------------------------------------------------------------------------
/**
* O que cada fonte do ambiente entrega ao painel.
*
* Os campos chegam prontos. O painel não sabe de onde vêm os números, e este
* arquivo não sabe como eles foram medidos — quem os apresenta não é quem os
* produz, e essa separação é a mesma desde o módulo em que o painel nasceu.
*/
export interface FonteDoInforme {
/** O passo atual da tarefa, em uma frase. É o motivo de alguém estar aqui. */
readonly passo: string;
/** O aparelho e o regime em uso, como o ambiente os detectou. */
readonly aparelho: string;
/** O que a sessão concedeu, e o que ela negou. Vazio fora de sessão. */
readonly capacidades: readonly string[];
/** O custo do quadro, já resumido pela camada que o mede. */
readonly custo: readonly string[];
/**
* O aviso do instante: perda de rastreamento, degradação na abertura,
* provocação de desconforto em curso. Passa à frente de tudo quando existe.
*/
readonly aviso: string | undefined;
}
/**
* Quantas linhas semânticas o painel recebe.
*
* O número não saiu de cálculo. Saiu de medir: com a altura de texto que se lê a
* um metro e meio de distância dentro do visor — a distância de quem está em pé
* diante da bancada —, cabem seis linhas na superfície de 62 por 34 centímetros.
* A sétima entra e não se lê. O painel ainda quebra linha comprida em duas, e é
* por isso que este número é o teto do que se ENTREGA, não do que se desenha.
*/
export const LINHAS_UTEIS: number = 6;
/**
* Compõe o informe na ordem de prioridade, e corta o que não couber.
*
* A ordem, com a razão de cada posição:
*
* 1. O AVISO, quando existe. Ele descreve uma situação em que o ambiente está
* fazendo algo que não parece: congelou, degradou, está provocando um caso
* ruim de propósito. Sem ele no topo, quem lê conclui defeito.
* 2. O PASSO da tarefa. É a razão de a pessoa estar ali, e é a única linha que
* ela consulta enquanto trabalha em vez de quando algo dá errado.
* 3. A CAPACIDADE QUE FALTA, e só ela. A capacidade concedida não precisa ser
* dita: ela se manifesta funcionando. A negada explica um comportamento que
* de outro modo pareceria defeito, e essa assimetria é a razão de este bloco
* vir antes do custo.
* 4. O CUSTO do quadro. Assunto de quem constrói, não de quem monta — mas é o
* número que some quando se entra no visor, e o visor é onde ele importa.
* 5. O APARELHO. Último porque é o que menos muda: quem está com o visor no
* rosto já sabe qual visor é. Ele existe para a foto da apresentação e para
* o registro de em que aparelho a medida foi feita.
*/
export function compor(fonte: FonteDoInforme): readonly string[] {
const linhas: string[] = [];
if (fonte.aviso !== undefined) {
linhas.push(fonte.aviso);
}
linhas.push(fonte.passo);
for (const capacidade of fonte.capacidades) {
linhas.push(capacidade);
}
for (const linha of fonte.custo) {
linhas.push(linha);
}
linhas.push(fonte.aparelho);
if (linhas.length <= LINHAS_UTEIS) {
return linhas;
}
// O corte é anunciado, e não silencioso. Painel que corta sem dizer ensina que
// aquilo é tudo o que existe, e a folha de diagnóstico deixa de ser procurada
// justamente por quem mais precisaria dela.
const cortadas: number = linhas.length - (LINHAS_UTEIS - 1);
return [...linhas.slice(0, LINHAS_UTEIS - 1), `(+${cortadas} na folha de diagnóstico)`];
}
/** O passo atual em uma frase, a partir do que a montagem informa. */
export function frasePasso(
encaixadas: number,
total: number,
proxima: string | undefined,
): string {
if (encaixadas >= total) {
return `Montagem concluída: ${encaixadas} de ${total}. O mecanismo está girando.`;
}
if (proxima === undefined) {
return `Encaixadas ${encaixadas} de ${total}. Nenhuma peça está liberada — desencaixe uma para seguir.`;
}
return `Passo ${encaixadas + 1} de ${total}: agora vai a ${proxima}.`;
}A primeira versão do informe punha o custo do quadro no topo, e a razão era só de hábito: o orçamento era o assunto do módulo em que o painel nasceu. Quem estava montando lia dois números de milissegundos e não achava o nome da peça que faltava. A ordem que ficou é outra — o aviso do instante, o passo da tarefa, a capacidade que falta, o custo, e o aparelho por último.
Duas posições dessa lista são discutíveis, e por isso estão justificadas no arquivo. O aviso vem antes de tudo porque ele descreve situações em que o ambiente faz algo que não parece: congelou por perda de referência, degradou na abertura, está provocando um caso ruim de propósito. Sem ele no topo, quem lê conclui defeito.
A outra é a assimetria entre capacidade concedida e capacidade negada. Só a negada aparece. A concedida não precisa ser dita — ela se manifesta funcionando —, enquanto a negada explica um comportamento que de outro modo pareceria erro de programa. É a mesma lógica da mensagem honesta do trecho anterior, aplicada a um espaço vinte vezes menor.
E o corte é anunciado. Quando o informe não cabe, a última linha diz quantas ficaram na folha, em vez de simplesmente truncar. Painel que corta em silêncio ensina que aquilo é tudo o que existe, e a folha de diagnóstico deixa de ser procurada por quem mais precisaria dela.
1.3.2 Encarar sem se soltar da bancada
O cartaz plano visto de lado é uma linha.
O painel nasceu no regime em janela, onde a câmera orbita a bancada e sempre olha para ela. Nos outros regimes isso deixa de valer, e o primeiro ensaio com o visor produziu três defeitos de uma vez: quem contorna a bancada perde o painel inteiro, texto de vinte e quatro pixels a três metros não se lê, e linha comprida saía pela borda direita sem aviso.
src/bancada/ui/painel.ts
// ---------------------------------------------------------------------------
// A primeira pedra do painel diegético.
//
// No módulo anterior o relatório saía em página comum, e a razão declarada era
// que ainda não havia mundo onde pendurá-lo. Agora há. O painel passa a ser um
// objeto da cena, preso à bancada, lido de dentro do ambiente — que é a única
// forma de um número chegar a quem está com o visor no rosto.
//
// A implementação é a mais simples que sustenta o requisito: uma superfície
// plana com uma textura desenhada por nós, redesenhada algumas vezes por
// segundo. Duas razões para não redesenhar a cada quadro. A primeira é custo:
// enviar textura para a placa é das operações mais caras do quadro, e um painel
// que estoura o orçamento enquanto informa o orçamento seria uma piada de mau
// gosto. A segunda é legibilidade: número que muda sessenta vezes por segundo
// não se lê, borra.
//
// ---------------------------------------------------------------------------
// O que o módulo final acrescentou, e por quê.
//
// O painel nasceu no regime em janela, onde a câmera orbita a bancada e sempre
// olha para ela. Nos outros dois regimes isso deixa de valer, e três defeitos
// apareceram no primeiro ensaio com o visor:
//
// 1. O cartaz plano visto de lado é uma linha. Quem contorna a bancada perde o
// painel inteiro. A correção é girá-lo em torno do próprio eixo vertical para
// encarar quem lê — e SÓ em torno dele: inclinar o cartaz para acompanhar a
// cabeça de quem se abaixa faz um objeto preso à mesa parecer solto no ar, e
// a única coisa que o painel não pode perder é o pertencimento à bancada.
// 2. Texto de 24 pixels a três metros não se lê. O painel não pode crescer sem
// deixar de ser um cartaz de bancada, e não pode encolher a distância sem
// mentir sobre a escala — então ele MEDE a própria legibilidade e diz quando
// ela caiu, em vez de fingir que está sendo lido.
// 3. Linha comprida saía pela borda direita sem aviso. Agora ela quebra, e o que
// não couber é anunciado como corte em vez de sumir.
//
// A quarta correção é o `chamar()`: no celular, em sessão aumentada, a bancada
// pousa onde a mesa está e o painel pode ficar de costas, atrás dela. Trazê-lo
// para a borda mais próxima de quem lê é gesto explícito, e não automático —
// painel que se move sozinho é a coisa que mais rápido deixa de parecer objeto.
// ---------------------------------------------------------------------------
import {
CanvasTexture,
LinearFilter,
Mesh,
MeshBasicMaterial,
Object3D,
PlaneGeometry,
Vector3,
} from 'three';
/** Largura do painel em metros. Um cartaz de bancada, não um outdoor. */
const LARGURA_M: number = 0.62;
const ALTURA_M: number = 0.34;
/** Resolução da textura, em pixels. Múltiplo da proporção física acima. */
const LARGURA_PX: number = 620;
const ALTURA_PX: number = 340;
/** Intervalo mínimo entre redesenhos, em segundos. */
const INTERVALO_DE_REDESENHO: number = 0.25;
/** Corpo do texto das linhas, em pixels da textura. */
const CORPO_PX: number = 24;
/** Altura de linha, em pixels da textura. */
const ENTRELINHA_PX: number = 34;
/** Margem lateral, em pixels da textura. Vale dos dois lados. */
const MARGEM_PX: number = 24;
/**
* Abaixo de quantos minutos de arco o texto deixa de ser lido.
*
* É DECISÃO DE PROJETO, não norma citada. O valor saiu do único ensaio que se
* pôde fazer sem depender do rodízio dos três visores: no desktop, afastando o
* ponto de vista até o texto do painel parar de ser lido por três pessoas
* diferentes com a mesma fonte e o mesmo corpo. Deu por volta de vinte minutos
* de arco, e vinte é o que está aqui.
*
* O ensaio dentro do visor CONTINUA A FAZER. Ele pode mover este número, porque
* a densidade de pixels do visor e a distorção da lente não entram na conta do
* ângulo — e é por isso que o painel informa o valor medido ao lado do limiar,
* em vez de devolver apenas "legível" ou "ilegível".
*/
export const LIMIAR_DE_LEITURA_MINUTOS: number = 20;
/** O que o painel responde sobre a própria leitura, no instante da consulta. */
export interface Legibilidade {
readonly distanciaM: number;
readonly alturaAparenteMinutos: number;
readonly legivel: boolean;
/** Quantas linhas o último desenho não conseguiu mostrar. */
readonly linhasCortadas: number;
}
export interface Painel {
readonly no: Mesh;
/** Entrega o texto do painel. O redesenho só acontece quando vale a pena. */
atualizar(linhas: readonly string[], decorrido: number): void;
/**
* Gira o painel em torno do eixo vertical para encarar quem lê. Chamada a
* cada quadro; a conta é de três linhas e não entra no orçamento de forma
* mensurável.
*/
encarar(observador: Object3D): void;
/** Mede a própria leitura a partir de onde o observador está agora. */
legibilidade(observador: Object3D): Legibilidade;
/** Traz o painel para a borda da bancada voltada a quem lê. Gesto explícito. */
chamar(observador: Object3D): void;
/** Devolve o painel ao lugar de origem sobre o tampo. */
devolver(): void;
chamado(): boolean;
}
export function montarPainel(titulo: string): Painel {
const tela: HTMLCanvasElement = document.createElement('canvas');
tela.width = LARGURA_PX;
tela.height = ALTURA_PX;
const contexto: CanvasRenderingContext2D | null = tela.getContext('2d');
if (contexto === null) {
throw new Error('Este navegador não fornece contexto de desenho para o painel.');
}
// A cópia em constante não é adorno: a checagem acima estreita o tipo aqui, e
// esse estreitamento não sobrevive à entrada na função de desenho.
const pincel: CanvasRenderingContext2D = contexto;
const textura: CanvasTexture = new CanvasTexture(tela);
// Sem os níveis intermediários de detalhe, texto lido de longe cintila; com o
// filtro linear ele apenas borra, que é o defeito menos ruim dos dois.
textura.minFilter = LinearFilter;
textura.generateMipmaps = false;
const no: Mesh = new Mesh(
new PlaneGeometry(LARGURA_M, ALTURA_M),
new MeshBasicMaterial({ map: textura }),
);
no.name = 'painel';
// Material sem iluminação de propósito: painel que escurece quando a luz muda
// de lugar deixa de ser instrumento de leitura.
no.position.y = ALTURA_M / 2;
const posicaoDeOrigem: Vector3 = no.position.clone();
let ultimoDesenho: number = Number.NEGATIVE_INFINITY;
let ultimoTexto: string = '';
let cortadas: number = 0;
let chamado: boolean = false;
const doPainel: Vector3 = new Vector3();
const doObservador: Vector3 = new Vector3();
/**
* Quebra a linha que não cabe na largura útil.
*
* A medida é feita com a régua do próprio contexto de desenho, e não por
* contagem de caracteres: a fonte é proporcional, e "iiii" e "MMMM" têm o
* mesmo número de letras e larguras que diferem em três vezes. Contagem de
* caracteres é o atalho que faz o painel cortar cedo demais em umas linhas e
* transbordar em outras.
*/
function quebrar(linha: string, larguraUtil: number): string[] {
if (pincel.measureText(linha).width <= larguraUtil) {
return [linha];
}
const partes: string[] = [];
let atual: string = '';
for (const palavra of linha.split(' ')) {
const tentativa: string = atual === '' ? palavra : `${atual} ${palavra}`;
if (pincel.measureText(tentativa).width <= larguraUtil) {
atual = tentativa;
continue;
}
if (atual !== '') {
partes.push(atual);
}
atual = palavra;
}
if (atual !== '') {
partes.push(atual);
}
return partes;
}
function desenhar(linhas: readonly string[]): void {
pincel.fillStyle = '#11131a';
pincel.fillRect(0, 0, LARGURA_PX, ALTURA_PX);
pincel.fillStyle = '#f2f4fa';
pincel.font = `bold ${CORPO_PX + 6}px system-ui, sans-serif`;
pincel.fillText(titulo, MARGEM_PX, 52);
pincel.font = `${CORPO_PX}px system-ui, sans-serif`;
const larguraUtil: number = LARGURA_PX - MARGEM_PX * 2;
const quebradas: string[] = [];
for (const linha of linhas) {
quebradas.push(...quebrar(linha, larguraUtil));
}
let linhaY: number = 100;
let escritas: number = 0;
for (const linha of quebradas) {
if (linhaY > ALTURA_PX - MARGEM_PX) {
break;
}
pincel.fillText(linha, MARGEM_PX, linhaY);
linhaY += ENTRELINHA_PX;
escritas += 1;
}
cortadas = quebradas.length - escritas;
textura.needsUpdate = true;
}
desenhar([]);
function atualizar(linhas: readonly string[], decorrido: number): void {
const texto: string = linhas.join('\n');
if (decorrido - ultimoDesenho < INTERVALO_DE_REDESENHO || texto === ultimoTexto) {
return;
}
ultimoDesenho = decorrido;
ultimoTexto = texto;
desenhar(linhas);
}
function distanciaAte(observador: Object3D): number {
no.getWorldPosition(doPainel);
observador.getWorldPosition(doObservador);
return doPainel.distanceTo(doObservador);
}
function encarar(observador: Object3D): void {
no.getWorldPosition(doPainel);
observador.getWorldPosition(doObservador);
// Só o componente horizontal entra na conta. Zerar a altura ANTES de mirar é
// o que mantém o cartaz de pé: mirando o ponto inteiro, ele se inclinaria
// para acompanhar quem se abaixa, e um objeto preso à mesa não faz isso.
doObservador.y = doPainel.y;
no.lookAt(doObservador);
}
// diagrama:adiado quem exercita e o regime imersivo, pela composicao do ambiente
function legibilidade(observador: Object3D): Legibilidade {
const distancia: number = distanciaAte(observador);
// Altura física de uma letra: a proporção que ela ocupa na textura, aplicada
// à altura real da superfície.
const alturaDaLetraM: number = (CORPO_PX / ALTURA_PX) * ALTURA_M;
// Ângulo subtendido, em minutos de arco. A distância mínima evita a divisão
// por zero de quem encosta o rosto no painel dentro do visor — que acontece.
const radianos: number = 2 * Math.atan(alturaDaLetraM / (2 * Math.max(distancia, 0.05)));
const minutos: number = (radianos * 180 * 60) / Math.PI;
return {
distanciaM: distancia,
alturaAparenteMinutos: minutos,
legivel: minutos >= LIMIAR_DE_LEITURA_MINUTOS,
linhasCortadas: cortadas,
};
}
// diagrama:adiado quem exercita e o regime imersivo, pela composicao do ambiente
function chamar(observador: Object3D): void {
// O painel anda no espaço do próprio pai — o suporte preso ao tampo —, e é
// por isso que a bancada continua sendo o dono dele depois de chamado. Um
// painel que se soltasse do suporte para ir até quem lê deixaria de ser
// diegético no instante em que fosse mais útil.
const pai: Object3D | null = no.parent;
if (pai === null) {
return;
}
observador.getWorldPosition(doObservador);
pai.worldToLocal(doObservador);
doObservador.y = posicaoDeOrigem.y;
// Meio metro à frente de quem lê, na direção em que ele está, e não em cima
// dele: colado no rosto o cartaz fica perto demais para ser lido e atrapalha
// a mão que trabalha.
const direcao: Vector3 = doObservador.clone().setY(0);
if (direcao.lengthSq() < 1e-6) {
return;
}
direcao.normalize().multiplyScalar(0.45);
no.position.set(direcao.x, posicaoDeOrigem.y, direcao.z);
chamado = true;
}
function devolver(): void {
no.position.copy(posicaoDeOrigem);
chamado = false;
}
return {
no,
atualizar,
encarar,
legibilidade,
chamar,
devolver,
chamado(): boolean {
return chamado;
},
};
}
/** A medida da leitura em texto, para a folha e para o diário. */
// diagrama:adiado quem exercita e o regime imersivo, pela composicao do ambiente
export function linhasDaLegibilidade(medida: Legibilidade): string[] {
const linhas: string[] = [
`Distância de quem lê até o painel: ${medida.distanciaM.toFixed(2)} m.`,
`Altura aparente da letra: ${medida.alturaAparenteMinutos.toFixed(1)} minutos de arco ` +
`(limiar adotado: ${LIMIAR_DE_LEITURA_MINUTOS}).`,
medida.legivel
? 'Acima do limiar: o texto está sendo lido desta distância.'
: 'ABAIXO do limiar: daqui o painel é um borrão. Aproxime-se, ou chame o painel para a ' +
'borda da bancada — ele não cresce, porque crescer mentiria sobre a escala do mundo.',
];
if (medida.linhasCortadas > 0) {
linhas.push(
`${medida.linhasCortadas} linha(s) não couberam no último desenho e foram anunciadas como ` +
'corte. O texto inteiro está na folha de diagnóstico.',
);
}
linhas.push(
'',
'O limiar é decisão de projeto aferida no desktop, e não norma citada. O ensaio dentro do ' +
'visor continua a fazer: densidade de pixels e distorção da lente não entram na conta do ' +
'ângulo, e podem mover o número.',
);
return linhas;
}A correção do primeiro é girar o cartaz para encarar quem lê, e só em torno do eixo vertical. Zerar a altura antes de mirar é a linha que mantém o painel de pé: mirando o ponto inteiro, ele se inclinaria para acompanhar a cabeça de quem se abaixa, e um objeto preso à mesa não faz isso. A única coisa que este painel não pode perder é o pertencimento à bancada.
A do segundo é a que gostamos mais, e ela não é uma correção. O painel não pode crescer sem deixar de ser um cartaz de bancada, e não pode encolher a distância sem mentir sobre a escala do mundo. Então ele mede a própria legibilidade: calcula a altura aparente da letra em minutos de arco, compara com o limiar adotado, e diz quando ela caiu — em vez de fingir que está sendo lido.
O limiar é de vinte minutos de arco, e é decisão de projeto, não norma citada. Saiu do único ensaio que se pôde fazer sem depender do rodízio dos três visores: no desktop, afastando o ponto de vista até três pessoas pararem de ler a mesma linha. O ensaio dentro do visor continua a fazer — a densidade de pixels e a distorção da lente não entram na conta do ângulo, e podem mover o número. É por isso que a folha informa o valor medido ao lado do limiar, e não apenas o veredito.
O chamar() é a quarta correção, e a única que é gesto de quem usa. No celular, em sessão aumentada, a bancada pousa onde a mesa está e o painel pode ficar de costas, atrás dela. Trazê-lo para a borda mais próxima resolve, e não acontece sozinho: painel que se move por conta própria é a coisa que mais rápido deixa de parecer objeto. Mesmo chamado, ele continua pendurado no suporte do tampo — soltá-lo da bancada para segui-lo pela sala o faria deixar de ser diegético justamente quando é mais útil.
1.4 Tarefa 3: Documentar as decisões, os números e os aparelhos
Duas das sete grandezas deste projeto continuam sem medida, e elas aparecem assim.
Enunciado da tarefa
Um ambiente que funciona sem que se saiba dizer por que funciona não sustenta nada. O que precisa ficar escrito é o que ninguém reconstitui depois olhando o código.
O que fica pronto é o registro das decisões tomadas e das descartadas, dos números medidos com a máquina em que foram obtidos, das tolerâncias escolhidas com o que se testou antes, e da lista de aparelhos em que o ambiente foi de fato aberto, com o que funcionou em cada um. Afirmar que um ambiente de três regimes funciona sem dizer onde é afirmação vazia.
1.4.1 O número que não saiu
A tentação é completar a tabela.
O código diz o que o ambiente faz. Não diz por que a folga do encaixe é de quatro centímetros e não de dois, o que se experimentou antes de fixá-la, em que máquina os milissegundos foram obtidos, nem quais aparelhos de fato abriram este endereço. Essas quatro coisas somem no dia em que o semestre acaba, e somem primeiro da cabeça de quem as decidiu.
src/bancada/relatorio/decisoes.ts
// ---------------------------------------------------------------------------
// O que ninguém reconstitui depois olhando o código.
//
// O código diz o que o ambiente faz. Não diz por que a folga do encaixe é de
// quatro centímetros e não de dois, o que se experimentou antes de fixá-la, em
// que máquina o número de milissegundos foi obtido, nem quais aparelhos de fato
// abriram este endereço. Essas quatro coisas somem no dia em que o semestre
// acaba, e somem primeiro da cabeça de quem as decidiu.
//
// Este arquivo é o registro delas, e ele é código pela mesma razão que o painel
// é objeto da cena: documento que vive fora do artefato é documento que
// envelhece sem que ninguém perceba. Aqui ele envelhece junto — quem trocar a
// tolerância e não trocar a linha correspondente vê as duas lado a lado.
//
// Três coisas que o registro deliberadamente NÃO faz.
//
// Não estima. Número que não foi medido aparece como não medido, com o que
// falta para medi-lo. A tentação de completar a tabela com um valor plausível é
// exatamente o que torna a tabela inútil — basta um valor inventado para que
// nenhum dos outros possa ser confiado sem conferência.
//
// Não guarda só a decisão vencedora. A alternativa descartada e a razão do
// descarte valem mais na arguição do que a escolha em si: quem só sabe o que
// escolheu não sabe o que escolheu.
//
// Não inventa a lista de aparelhos. Ela nasce VAZIA e é preenchida por quem
// abriu o endereço no aparelho. Afirmar que um ambiente de três regimes funciona
// sem dizer onde é afirmação vazia, e a lista escrita de memória na véspera da
// entrega é a versão pior dessa mesma afirmação.
// ---------------------------------------------------------------------------
/** Uma decisão de projeto, com o caminho que não foi tomado. */
export interface Decisao {
readonly assunto: string;
readonly decidido: string;
readonly porQue: string;
/** O que foi considerado e descartado, e por quê. Nunca vazio. */
readonly descartado: string;
/** O que muda se ela for revertida. É o custo de mexer nela depois. */
readonly seRevertida: string;
}
/**
* Um número medido, com a origem da medida.
*
* O campo `maquina` é o que separa dado de boato. "Doze milissegundos" não é
* informação: doze milissegundos no desktop de laboratório com placa integrada é
* outra coisa que doze milissegundos num visor. O tipo obriga a dizer onde.
*/
export interface NumeroMedido {
readonly grandeza: string;
/** Ausente quando ainda não foi medido. A ausência é o dado, nesse caso. */
readonly valor: string | undefined;
readonly maquina: string;
/** Como se refaz a medida. Sem isto, o número não é conferível. */
readonly comoSeObteve: string;
}
/** Uma tolerância adotada, com o que se experimentou antes de fixá-la. */
export interface Tolerancia {
readonly nome: string;
readonly valor: string;
readonly ensaiadoAntes: string;
readonly oQueODesvioProduz: string;
}
/** Um aparelho em que o endereço foi de fato aberto. Registrado por quem abriu. */
export interface AparelhoTestado {
readonly aparelho: string;
readonly regimeAberto: string;
readonly oQueFuncionou: string;
readonly oQueNaoFuncionou: string;
readonly quemAbriu: string;
}
export const DECISOES: readonly Decisao[] = [
{
assunto: 'ferramental',
decidido: 'biblioteca 3D para renderização e ciclo de sessão; interação, manipulação, locomoção e ancoragem escritas à mão',
porQue:
'a abstração de apontar com três back-ends é o conteúdo desta disciplina, e é justamente o que uma biblioteca de atalhos XR entrega pronta',
descartado:
'engine completa, que resolveria os três regimes em poucas semanas e esconderia exatamente o que se veio aprender; e reconstruir o pipeline gráfico, que é escopo de outra disciplina e consumiria o semestre antes de chegar em realidade aumentada',
seRevertida:
'com engine, o percurso perde os módulos de interação e ancoragem; sem biblioteca alguma, perde os de imersão em diante',
},
{
assunto: 'distribuição',
decidido: 'um endereço servido por HTTPS, aberto no navegador do próprio aparelho',
porQue:
'a turma inteira executa no mesmo instante, no aparelho que tem à mão, sem instalar nada',
descartado:
'aplicativo nativo instalado no visor, que dá acesso a mais capacidades e consome uma aula inteira de instalação para três aparelhos',
seRevertida:
'o celular do estudante sai do alcance do ambiente, e com ele a maior parte da turma',
},
{
assunto: 'ordem de preferência dos regimes',
decidido: 'realidade aumentada com sessão, imersivo, marcador impresso, janela',
porQue:
'a bancada é uma oficina de montagem, e montar sobre a mesa real de quem trabalha vale mais que montar numa sala inventada',
descartado:
'pôr o imersivo em primeiro, que traz escala corporal e alcance de braço e é o que mais impressiona na demonstração',
seRevertida:
'o artefato passa a privilegiar a sala inventada, e a ancoragem contra superfície real vira acessório',
},
{
assunto: 'registro por marcador impresso',
decidido: 'implementado à mão, como segunda ancoragem, com qualidade inferior declarada',
porQue:
'parte relevante dos celulares da turma não entra em sessão aumentada, e o marcador é o único caminho que alcança todo mundo',
descartado:
'depender só da sessão aumentada do aparelho, que teria dado um registro melhor para metade da turma e tela em branco para a outra metade',
seRevertida: 'o parque heterogêneo volta a dividir a turma entre quem vê e quem não vê',
},
{
assunto: 'painel de informação',
decidido: 'objeto da cena, preso à bancada',
porQue:
'painel preso à janela do navegador desaparece quando a sessão imersiva começa, que é quando ele faria mais falta',
descartado:
'sobreposição em HTML sobre a superfície de desenho, que é mais fácil de escrever, se lê melhor no desktop e some no visor',
seRevertida: 'o ambiente perde toda informação de estado dentro dos dois regimes imersivos',
},
{
assunto: 'aviso de perda de rastreamento',
decidido: 'preso à cabeça, e não à cena — única exceção do percurso à regra do cartaz diegético',
porQue:
'quando a referência se perde, a posição da cena é justamente o que não se conhece; um aviso preso a ela apareceria no lugar errado ou não apareceria',
descartado: 'manter a coerência diegética e aceitar que o aviso mais urgente fosse o menos visível',
seRevertida: 'a perda de rastreamento volta a parecer travamento do programa',
},
];
export const NUMEROS: readonly NumeroMedido[] = [
{
grandeza: 'lado impresso do marcador fiducial',
valor: '15 cm de lado do quadrado preto, sem a margem branca',
maquina: 'impressora comum, papel A4',
comoSeObteve:
'a constante está no código e gera o desenho; confere-se com régua sobre o papel impresso, antes do primeiro teste, e o ajuste automático à página tem de estar desligado',
},
{
grandeza: 'recuo do posto de trabalho diante da bancada',
valor: '0,55 m',
maquina: 'visor com espaço de piso concedido',
comoSeObteve:
'medido pelo alcance do braço: no valor anterior, de 1,15 m, nenhuma peça do tampo caía dentro do raio de quem trabalha',
},
{
grandeza: 'altura de olhos adotada quando o piso real não é concedido',
valor: '1,6 m',
maquina: 'qualquer aparelho que conceda só a altura da cabeça',
comoSeObteve:
'valor adotado no assentamento do mundo; confere-se comparando a altura do tampo virtual com a de uma bancada real de oficina',
},
{
grandeza: 'campo de visão horizontal da câmera do celular',
valor: '60 graus — ESTIMATIVA declarada, não medida',
maquina: 'celular do estudante, parque heterogêneo',
comoSeObteve:
'o navegador não informa a distância focal; medir é possível e barato, fotografando o marcador a distância conhecida, e continua a fazer — o erro entra quase todo na profundidade',
},
{
grandeza: 'limiar de leitura do painel',
valor: '20 minutos de arco de altura de letra',
maquina: 'desktop de laboratório, com placa integrada',
comoSeObteve:
'afastando o ponto de vista até três pessoas pararem de ler a mesma linha; o ensaio dentro do visor continua a fazer, e pode mover o número',
},
{
grandeza: 'orçamento de cena que a placa integrada sustenta',
valor: undefined,
maquina: 'desktop de laboratório, com placa integrada',
comoSeObteve:
'a instrumentação de custo do quadro já está no ambiente e informa chamadas de desenho e triângulos; falta rodar a série de medidas com a cena cheia e anotar o teto observado',
},
{
grandeza: 'taxa de quadros sob carga no visor',
valor: undefined,
maquina: 'visor',
comoSeObteve:
'decisão declarada de não fixar taxa-alvo: conforto aqui é tratado qualitativamente, e o contador de engasgos mede o que a instrumentação alcança; a metade que falta é o relato de quem experimentou',
},
];
export const TOLERANCIAS: readonly Tolerancia[] = [
{
nome: 'folga linear do encaixe',
valor: '4 cm',
ensaiadoAntes:
'três ensaios com folgas menores e maiores; com folga generosa, a peça grudava em quem só passou perto do encaixe',
oQueODesvioProduz:
'folga apertada demais faz a peça certa ser recusada e quem monta conclui que errou a ordem; folga larga demais faz a peça errada encaixar',
},
{
nome: 'folga angular do encaixe',
valor: '20 graus',
ensaiadoAntes:
'mesma série de ensaios, com a peça solta girada de propósito fora do eixo do encaixe',
oQueODesvioProduz:
'apertada, exige de quem usa uma precisão de pulso que o controle de seis graus não entrega; larga, a peça encaixa visivelmente torta e a animação do mecanismo denuncia',
},
];
const CHAVE_DE_ARMAZENAMENTO: string = 'bancada.aparelhos-testados';
/**
* O registro dos aparelhos, que nasce vazio e é preenchido por quem abriu.
*
* A persistência é local ao navegador, e essa limitação é grande o bastante para
* estar escrita aqui e ser repetida a quem lê a folha: o registro feito no
* celular fica no celular. É por isso que existe a exportação em texto — o
* destino final do registro é o repositório do projeto, escrito por gente, e o
* armazenamento local serve só para o registro sobreviver ao recarregamento da
* página enquanto o ensaio acontece.
*/
export class RegistroDeAparelhos {
private testados: AparelhoTestado[] = [];
constructor() {
this.testados = this.ler();
}
private ler(): AparelhoTestado[] {
try {
const cru: string | null = window.localStorage.getItem(CHAVE_DE_ARMAZENAMENTO);
if (cru === null) {
return [];
}
const lido: unknown = JSON.parse(cru);
return Array.isArray(lido) ? (lido as AparelhoTestado[]) : [];
} catch {
// Armazenamento local pode estar bloqueado — navegação privada, política
// do aparelho, cota estourada. Registro perdido é ruim; página que não
// sobe por causa dele é pior, e a lista vazia diz a verdade.
return [];
}
}
private gravar(): void {
try {
window.localStorage.setItem(CHAVE_DE_ARMAZENAMENTO, JSON.stringify(this.testados));
} catch {
// Mesmo motivo do `ler`. O registro segue valendo nesta sessão.
}
}
registrar(aparelho: AparelhoTestado): void {
this.testados.push(aparelho);
this.gravar();
}
lista(): readonly AparelhoTestado[] {
return this.testados;
}
esquecer(): void {
this.testados = [];
this.gravar();
}
/**
* O registro em texto corrido, pronto para ser colado no repositório.
*
* A exportação existe porque o armazenamento local não viaja. Sem ela, o
* registro morre no aparelho em que foi feito, e a lista de aparelhos volta a
* ser escrita de memória — que é exatamente o que este arquivo veio impedir.
*/
exportar(): string {
const linhas: string[] = ['# Registro do projeto — Bancada', ''];
linhas.push('## Decisões tomadas, e as descartadas', '');
for (const decisao of DECISOES) {
linhas.push(
`- **${decisao.assunto}**: ${decisao.decidido}. Por quê: ${decisao.porQue}. ` +
`Descartado: ${decisao.descartado}. Se revertida: ${decisao.seRevertida}.`,
);
}
linhas.push('', '## Números, com a máquina em que foram obtidos', '');
for (const numero of NUMEROS) {
const valor: string = numero.valor ?? 'NÃO MEDIDO';
linhas.push(
`- **${numero.grandeza}**: ${valor} (${numero.maquina}). Como se obtém: ${numero.comoSeObteve}.`,
);
}
linhas.push('', '## Tolerâncias, e o que se ensaiou antes', '');
for (const tolerancia of TOLERANCIAS) {
linhas.push(
`- **${tolerancia.nome}**: ${tolerancia.valor}. Ensaiado antes: ${tolerancia.ensaiadoAntes}. ` +
`O desvio produz: ${tolerancia.oQueODesvioProduz}.`,
);
}
linhas.push('', '## Aparelhos em que o endereço foi de fato aberto', '');
if (this.testados.length === 0) {
linhas.push(
'- Nenhum registrado ainda. A lista nasce vazia de propósito: ela só vale preenchida por ' +
'quem abriu.',
);
} else {
for (const testado of this.testados) {
linhas.push(
`- **${testado.aparelho}** — regime aberto: ${testado.regimeAberto}. ` +
`Funcionou: ${testado.oQueFuncionou}. Não funcionou: ${testado.oQueNaoFuncionou}. ` +
`Registrado por: ${testado.quemAbriu}.`,
);
}
}
return linhas.join('\n');
}
linhas(): string[] {
const linhas: string[] = [];
linhas.push('DECISÕES TOMADAS, E O QUE FOI DESCARTADO', '');
for (const decisao of DECISOES) {
linhas.push(`${decisao.assunto}: ${decisao.decidido}.`);
linhas.push(` por quê: ${decisao.porQue}.`);
linhas.push(` descartado: ${decisao.descartado}.`);
linhas.push(` se revertida: ${decisao.seRevertida}.`);
linhas.push('');
}
linhas.push('NÚMEROS, COM A MÁQUINA EM QUE FORAM OBTIDOS', '');
for (const numero of NUMEROS) {
linhas.push(`${numero.grandeza}: ${numero.valor ?? 'NÃO MEDIDO'} — ${numero.maquina}.`);
linhas.push(` como se obtém: ${numero.comoSeObteve}.`);
}
const naoMedidos: number = NUMEROS.filter((numero) => numero.valor === undefined).length;
linhas.push(
'',
`${naoMedidos} das ${NUMEROS.length} grandezas continuam sem medida, e aparecem assim de ` +
'propósito. Preenchê-las com valor plausível tornaria as outras não confiáveis.',
'',
);
linhas.push('TOLERÂNCIAS, E O QUE SE ENSAIOU ANTES DE FIXÁ-LAS', '');
for (const tolerancia of TOLERANCIAS) {
linhas.push(`${tolerancia.nome}: ${tolerancia.valor}.`);
linhas.push(` ensaiado antes: ${tolerancia.ensaiadoAntes}.`);
linhas.push(` o desvio produz: ${tolerancia.oQueODesvioProduz}.`);
}
linhas.push('', 'APARELHOS EM QUE O ENDEREÇO FOI DE FATO ABERTO', '');
if (this.testados.length === 0) {
linhas.push(
'Nenhum registrado ainda. A lista nasce vazia, e é a única parte deste registro que o ' +
'código não pode preencher: ela depende de alguém abrir o endereço num aparelho e dizer ' +
'o que aconteceu.',
);
} else {
for (const testado of this.testados) {
linhas.push(
`${testado.aparelho} — ${testado.regimeAberto}. Funcionou: ${testado.oQueFuncionou}. ` +
`Não funcionou: ${testado.oQueNaoFuncionou}. Registro de ${testado.quemAbriu}.`,
);
}
}
linhas.push(
'',
'O registro de aparelhos fica no armazenamento local DESTE navegador: o que foi anotado no ' +
'celular ficou no celular. O destino dele é o repositório do projeto, e é para isso que ' +
'existe a exportação em texto.',
);
return linhas;
}
}O registro é código pela mesma razão que o painel é objeto da cena: documento que vive fora do artefato envelhece sem que ninguém perceba. Aqui ele envelhece junto — quem trocar a tolerância e não trocar a linha correspondente vê as duas lado a lado, no mesmo arquivo.
Três coisas ele deliberadamente não faz, e a primeira é a que dói. Ele não estima. Duas das sete grandezas listadas aparecem como não medidas: o orçamento de cena que a placa integrada sustenta, e a taxa de quadros sob carga no visor. Seria fácil escrever um número plausível em cada uma, e ninguém perceberia. Bastaria isso para que nenhum dos outros cinco pudesse ser confiado sem conferência, e uma tabela que precisa ser conferida inteira não serve para nada.
A segunda é que a alternativa descartada fica registrada ao lado da vencedora. Na arguição, ela vale mais: quem só sabe o que escolheu não sabe o que escolheu. Cada decisão do arquivo carrega o que foi considerado, por que caiu, e o que muda se a escolha for revertida — que é o custo de mexer nela daqui a dois semestres.
A terceira é a lista de aparelhos, e ela nasce vazia. Não há como o código preenchê-la: ela depende de alguém abrir o endereço num aparelho e dizer o que aconteceu. Guardamos os registros no armazenamento local do navegador, e essa limitação é grande a ponto de estar dita em três lugares — o que foi anotado no celular ficou no celular. É por isso que existe a exportação em texto: o destino do registro é o repositório do projeto, escrito por gente, e o armazenamento local só serve para o registro sobreviver ao recarregamento enquanto o ensaio acontece.
1.4.2 Avaliar pela tarefa, e não pela impressão
A pergunta “ficou bom?” não tem resposta.
A que tem é outra: alguém consegue montar o mecanismo, e onde essa pessoa trava? Trocar a primeira pela segunda é o que separa avaliação de impressão, e é também o que impede o julgamento mais comum sobre trabalho de realidade virtual — o de que ficou bonito.
src/bancada/relatorio/uso.ts
// ---------------------------------------------------------------------------
// Avaliar o ambiente pela tarefa que ele suporta.
//
// A pergunta "o ambiente ficou bom?" não tem resposta. A pergunta que tem é
// outra: alguém consegue montar o mecanismo, e onde essa pessoa trava? Trocar a
// primeira pela segunda é o que separa avaliação de impressão, e é também o que
// impede o julgamento mais comum sobre trabalho de realidade virtual — o de que
// ficou bonito.
//
// A análise abaixo decompõe a tarefa em demandas, e não em passos. Passos já
// existem: são as cinco peças e a ordem entre elas, e o código os conhece. O que
// o código não conhece é o que cada passo COBRA de quem monta — julgar distância
// no olho, girar o pulso com precisão, lembrar a ordem — e é essa coluna que
// explica por que a mesma tarefa é fácil num regime e penosa noutro.
//
// A segunda metade do arquivo é o registro de observação, e ele nasce vazio pelo
// mesmo motivo que a lista de aparelhos: o que uma pessoa faz diante do ambiente
// não se deduz do código. Ele mora em memória, e só na sessão em curso — de
// propósito. A observação se transcreve para o registro do projeto no instante
// em que acontece, com a tutoria ainda na sala; um arquivo que se acumulasse
// sozinho pareceria arquivo, e ninguém transcreveria nada.
// ---------------------------------------------------------------------------
import type { RegimeExecutavel } from '../app/degradacao';
/** O que a demanda encontra em cada um dos quatro regimes que o ambiente abre. */
export type PorRegime = Readonly<Record<RegimeExecutavel, string>>;
export interface Demanda {
readonly nome: string;
/** O que ela cobra de quem monta, em uma frase. */
readonly cobra: string;
/** Em que passo da montagem ela aparece com mais força. */
readonly ondeAparece: string;
readonly porRegime: PorRegime;
}
/**
* As quatro demandas da tarefa de montagem.
*
* São quatro e não dez porque a lista foi cortada pelo que o ambiente de fato
* diferencia entre regimes. Leitura de texto, por exemplo, é demanda real da
* tarefa e não está aqui: ela é igual nos quatro regimes, e uma linha igual nas
* quatro colunas não ajuda a decidir nada.
*/
export const DEMANDAS: readonly Demanda[] = [
{
nome: 'julgar profundidade',
cobra: 'saber, no olho, se a peça está sobre o encaixe ou meio palmo à frente dele',
ondeAparece: 'no instante de soltar a peça sobre o encaixe',
porRegime: {
'immersive-ar': 'a mesa real dá a referência de profundidade, e a bancada pousa sobre ela',
'immersive-vr': 'a estereoscopia resolve, e o contorno da folga confirma',
marcador: 'a profundidade é o eixo em que o erro de registro se concentra, e ela oscila',
janela: 'não há estereoscopia; resolve-se orbitando a câmera, que é um gesto a mais',
},
},
{
nome: 'orientar a peça',
cobra: 'girar o pulso até a peça entrar no eixo do encaixe, dentro da folga angular',
ondeAparece: 'na tampa, que é a única peça sem simetria de revolução',
porRegime: {
'immersive-ar': 'o controle de seis graus entrega a orientação da mão direto',
'immersive-vr': 'igual ao aumentado: é a mesma fonte de apontamento',
marcador: 'não há orientação de mão; o giro sai da tecla, e o gesto vira comando',
janela: 'o giro sai da tecla, e é o ponto em que o regime base mais se afasta dos outros',
},
},
{
nome: 'lembrar a ordem',
cobra: 'saber que a tampa depende das duas engrenagens, e elas do eixo',
ondeAparece: 'na primeira recusa por ordem',
porRegime: {
'immersive-ar': 'o painel preso à bancada informa o passo, e ele acompanha a mesa',
'immersive-vr': 'o mesmo painel, com o cuidado de não ficar longe demais para ser lido',
marcador: 'o painel treme junto com o registro, e o passo é o que se lê pior',
janela: 'painel e folha de diagnóstico convivem na mesma tela; é onde a ordem é mais clara',
},
},
{
nome: 'alcançar a peça',
cobra: 'estender o braço até a peça sem sair do lugar',
ondeAparece: 'nas peças da prateleira do fundo',
porRegime: {
'immersive-ar': 'o alcance é o do braço real, e o recuo do posto foi medido por causa disto',
'immersive-vr': 'igual, com o salto disponível para o que estiver fora do braço',
marcador: 'não há braço: tudo se alcança pelo toque na tela, e a demanda desaparece',
janela: 'não há braço: tudo se alcança pelo cursor, e a demanda desaparece',
},
},
];
/** O que uma pessoa fez diante do ambiente. Só isto conta como avaliação de uso. */
export interface Observacao {
readonly quem: string;
readonly regime: RegimeExecutavel;
/** Em que ponto ela travou, ou o que a fez desistir. Vazio quando concluiu. */
readonly ondeTravou: string;
/** As palavras dela, e não o resumo de quem observou. */
readonly relato: string;
readonly concluiu: boolean;
}
/**
* O registro de observação da sessão em curso.
*
* Sem persistência, e isso é decisão. A observação vale transcrita para o
* registro do projeto enquanto a pessoa ainda está na sala e pode ser
* perguntada; um arquivo que se acumulasse sozinho daria a impressão de que a
* transcrição pode esperar, e ela não pode — o que se perde primeiro é
* exatamente o que a pessoa disse, que é a parte que a instrumentação não mede.
*/
export class RegistroDeUso {
private readonly observacoes: Observacao[] = [];
registrar(observacao: Observacao): void {
this.observacoes.push(observacao);
}
lista(): readonly Observacao[] {
return this.observacoes;
}
/** Quantas pessoas concluíram a montagem, e quantas travaram. É o número da avaliação. */
taxaDeConclusao(): string {
if (this.observacoes.length === 0) {
return 'Ainda sem observação registrada nesta sessão.';
}
const concluidas: number = this.observacoes.filter((item) => item.concluiu).length;
return `${concluidas} de ${this.observacoes.length} concluíram a montagem.`;
}
linhas(): string[] {
const linhas: string[] = [
'ANÁLISE DA TAREFA: O QUE A MONTAGEM COBRA DE QUEM MONTA',
'',
];
for (const demanda of DEMANDAS) {
linhas.push(`${demanda.nome} — ${demanda.cobra}. Aparece ${demanda.ondeAparece}.`);
linhas.push(` em realidade aumentada com sessão: ${demanda.porRegime['immersive-ar']}.`);
linhas.push(` em realidade virtual imersiva: ${demanda.porRegime['immersive-vr']}.`);
linhas.push(` por marcador impresso: ${demanda.porRegime.marcador}.`);
linhas.push(` em janela: ${demanda.porRegime.janela}.`);
linhas.push('');
}
linhas.push(
'Duas demandas desaparecem nos regimes sem corpo, e desaparecer não é o mesmo que ficar ' +
'mais fácil: a tarefa passa a ser outra. Comparar a montagem feita em janela com a feita ' +
'no visor é comparar duas atividades diferentes, e quem apresentar os dois resultados lado ' +
'a lado precisa dizer isso antes de mostrar o número.',
'',
'OBSERVAÇÃO DE USO',
'',
this.taxaDeConclusao(),
'',
);
if (this.observacoes.length === 0) {
linhas.push(
'Nenhuma observação nesta sessão. O que o ambiente mede sozinho — quadros, engasgos, ' +
'recusas por ordem — não responde se alguém consegue montar o mecanismo; responde quanto ' +
'custa desenhá-lo enquanto tentam.',
);
return linhas;
}
for (const observacao of this.observacoes) {
const desfecho: string = observacao.concluiu
? 'concluiu'
: `travou ${observacao.ondeTravou}`;
linhas.push(`${observacao.quem}, em ${observacao.regime}: ${desfecho}. "${observacao.relato}"`);
}
linhas.push(
'',
'As observações desta lista somem ao recarregar a página. Transcreva-as para o registro do ' +
'projeto enquanto quem experimentou ainda está por perto e pode ser perguntado.',
);
return linhas;
}
}A análise decompõe a tarefa em demandas, e não em passos. Passos já existem: são as cinco peças e a ordem entre elas, e o código as conhece. O que ele não conhece é o que cada passo cobra de quem monta — julgar profundidade no olho, girar o pulso dentro da folga angular, lembrar que a tampa depende das duas engrenagens, estender o braço até a prateleira do fundo.
São quatro demandas, e não dez, porque a lista foi cortada pelo que o ambiente de fato diferencia entre regimes. Ler texto é demanda real da tarefa e ficou de fora: é igual nos quatro regimes, e linha igual nas quatro colunas não ajuda a decidir nada.
E aqui a escada rolante parada cobra a última parcela, pelo lado que faltava. Duas das quatro demandas desaparecem nos regimes sem corpo: em janela e por marcador, não há braço a estender nem pulso a girar — tudo se alcança pelo cursor ou pelo toque. Desaparecer não é ficar mais fácil. A tarefa passa a ser outra, e comparar a montagem feita em janela com a feita no visor é comparar duas atividades diferentes. Quem apresentar os dois resultados lado a lado precisa dizer isso antes de mostrar o número.
O registro de observação não persiste, e é decisão. A observação vale transcrita enquanto a pessoa ainda está na sala e pode ser perguntada; um arquivo que se acumulasse sozinho daria a impressão de que a transcrição pode esperar. O que se perde primeiro é exatamente o que a pessoa disse, que é a metade que a instrumentação não mede.
1.5 A composição, e o que ela acrescentou
Cinco arquivos novos, e três linhas dentro do laço.
Fora das pastas novas, o que mudou foi pouco e é fácil de auditar. O laço ganhou a chamada que gira o painel para encarar quem lê, e ela vem antes do redesenho por um detalhe: a legibilidade é medida contra a posição deste quadro, e medi-la depois daria a distância do anterior. As duas chamadas antigas do painel, que despejavam as cinco linhas do orçamento, foram trocadas pelo informe composto.
A segunda mudança está no ramo do congelamento por perda de rastreamento. O painel continua sendo atualizado com a cena parada, e isso é deliberado — ele é a única coisa que ainda pode explicar por que nada se move. O giro para encarar também continua, porque quem procura a explicação vai contornar a mesa.
A terceira é o registro das camadas, espalhado por dez pontos da composição, cada um na linha em que a camada é de fato ligada. É a única mudança do módulo que aparece em lugares distantes uns dos outros, e a proximidade é o que lhe dá valor.
A abertura pelo melhor regime mora num botão, e não no carregamento. A razão é a mesma do módulo anterior e não mudou: o navegador recusa sessão pedida sem gesto de quem usa, e ligar a câmera sem que ninguém tenha pedido acenderia a luz do aparelho por conta própria. O que a decisão automática faz continua sendo dizer qual botão apertar, e por quê.
1.6 O que este módulo deixa pronto
O critério de quem trabalhou décadas na área e recusou o entusiasmo.
O artefato está inteiro: um endereço, quatro regimes, a degradação percorrendo a ordem declarada tanto na consulta quanto na abertura, o painel diegético informando o passo da tarefa dentro dos três ambientes, e o registro do que se decidiu, do que se mediu e do que continua sem medida.
Em Chapel Hill, Frederick Brooks pôs químicos para encaixar moléculas com um braço mecânico de retorno de força montado sobre a bancada, num projeto que perseguiu por décadas. No balanço que escreveu em 1999, disse que a realidade virtual havia passado de “quase funciona” a “mal e mal funciona”. É de quem tinha todo motivo para o entusiasmo e o recusou — e é por isso que serve de critério aqui, no fim do percurso, melhor do que serviria qualquer advertência nossa.
O que esse critério cobra na apresentação é concreto. A demonstração que abre pelo regime mais espetacular depende do aparelho mais capaz, que é o que falha na hora. Comece pelo regime em janela, que sempre abre e já mostra a cena inteira, a tarefa inteira e o custo medido; depois troque de aparelho sem trocar de endereço. E responda às três perguntas que aparecem em toda arguição: como o sistema decide onde o objeto pousa sobre uma superfície real, o que ele faz quando o rastreamento se perde, e por que o mesmo código roda em aparelhos que declaram capacidades diferentes.
A quarta pergunta é específica de cada projeto, e sai da documentação que o próprio grupo escreveu. É a única que não se pode treinar antes.