Pular para o conteúdo principal

1 Realidade virtual não imersiva — Infográfico do código

O código deste módulo faz da câmera da cena uma câmera em órbita, que gira em volta de um ponto, o alvo, sempre olhando para ele; confere, peça a peça, se essa câmera consegue mostrar inteira cada peça do mecanismo; e escreve na página o que o regime em janela (a cena mostrada numa tela comum, de computador ou de celular) não oferece. Este infográfico acompanha uma execução só: da abertura da página do módulo, a que esse código monta no navegador, passando por um arrasto, dois passos da roda do mouse, uma rolagem de lado, dois dedos na tela e dois botões, com um clique em “Medir agora” depois de cada gesto e outro no fim.

1.1 O caminho

Os gestos e os botões entram em cima e mudam os números da órbita. Embaixo fica a folha, o texto que o programa escreve na tela, abaixo do desenho, com os números desta execução: ela só é reescrita nos momentos da segunda fileira de entradas, e é nessa hora que lê os números da câmera (a seta pontilhada).

lido quando a folha
é reescrita

a página abre
câmera em (0; 1,55; 1,50)

montarOrbita()
mede a partida:
1,62 m, 21,8°, 0,0°

arrasto, roda
e dois dedos

girar() e aproximar()
mudam os números
da órbita

botões de
enquadramento

caixaDoQueSeVe()
planejarEnquadramento()
olharPara()

aplicar()
escreve a câmera

montarRegimeEmJanela()
escreve a folha

a abertura, a chegada
da morsa e cada botão

clique em
Medir agora

medirEntrega()
aferirLimites()

custoContraOTeto()

a folha na tela
câmera escrita 25 vezes
5 de 5 peças cobertas
confirmado, confirmado,
inconclusivo

Figura 1: A partida são os três números que a órbita guarda, medidos a partir do alvo: a distância até ele, 1,62 m; a elevação, o ângulo com que a câmera sobe acima da altura do alvo, 21,8°; e o azimute, o giro em volta do alvo visto de cima, 0,0°. Um botão de enquadramento pede à órbita que leve a câmera até o ponto em que uma parte da cena aparece inteira na tela, e é isso que enquadrar quer dizer. As peças do domínio são as cinco do mecanismo (o corpo, o eixo, as duas engrenagens e a tampa do mecanismo), e uma delas está coberta pela órbita quando a órbita consegue enquadrá-la. Os três resultados do fim (confirmado, confirmado e inconclusivo) vêm da aferição dos três limites do regime: um limite é uma coisa que o regime não oferece, e a aferição é o confronto de cada limite com o que o programa em execução sabe. custoContraOTeto() só roda no clique em “Medir agora”.

1.2 Os arquivos

Cada arquivo do módulo, o que ele declara e o que ele usa de módulos anteriores.

Arquivo O que declara O que usa de antes
orbita.ts as travas em TRAVAS_DA_ORBITA (o menor e o maior valor que a órbita aceita: 0° e 85° para a elevação, 0,35 m e 8 m para a distância), GRAUS_POR_PIXEL (0,35), FATOR_DA_RODA (1,12), MARGEM_DE_ENQUADRAMENTO (1,25), ALVO_INICIAL, INSTRUCAO_DA_ORBITA, os tipos EstadoDaOrbita, Enquadramento e Orbita, e as funções caixaDoQueSeVe(), planejarEnquadramento() e montarOrbita(); esta guarda dentro de si a contagem escritas e as funções aplicar(), girar(), aproximar(), olharPara(), distanciaEntreDoisDedos(), aoDescer(), aoMover(), aoSubir() e aoRodar(), e devolve olharPara(), enquadrar(), estado(), descrever() e encerrar() ALTURA_DO_TAMPO, do módulo do grafo de cena; da biblioteca, Box3, Sphere, MathUtils, Mesh, InstancedMesh, Object3D, Vector3 e a câmera PerspectiveCamera
janela.ts, o do regime os tipos CoberturaDaPeca, EntregaDaJanela, Afericao, LimiteDoRegime e LimiteAferido, FATOR_DE_IMAGEM_PERDIDA (1,5), LIMITES_DA_JANELA e as funções medirEntrega(), custoContraOTeto(), aferirLimites() e linhasDaJanela() caixaDoQueSeVe(), planejarEnquadramento() e TRAVAS_DA_ORBITA, de orbita.ts; regimePorId() e o tipo Peca, do módulo do contínuo; o tipo LeituraDoOrcamento, do módulo do grafo de cena; da biblioteca, Object3D, PerspectiveCamera e WebGLRenderer
janela.ts, o da folha montarRegimeEmJanela() paragrafo() e subtitulo(), do módulo de dispositivos
06_demo.ts a página do módulo: TETO_DO_QUADRO_MS (16,7 ms, o mesmo valor de TETO_DESKTOP_MS), MAIOR_LADO_DA_MORSA_M, POSICAO_DA_MORSA, PECAS_DE_REPOSICAO e REMOCAO_NO_NIVEL_DISTANTE, com os valores do módulo anterior; a lista pedidos, com um botão de enquadramento por entrada; o botão “Medir agora”; atualizarFolhas(); e a ordem em que as funções rodam montarPalco(), montarCena(), montarLaco(), Orcamento e TETO_DESKTOP_MS, do módulo do grafo de cena; BANCADA, do módulo do contínuo; vestirCena(), extrudarPerfil() e perfilRegular(), do módulo de modelagem; paragrafo(), do módulo de dispositivos; a morsa, a borda parafusada, a prateleira, a medição e a folha do módulo sobre ativos e orçamento de cena, com importarAtivo(), montarPrateleira(), linhasDaMedicao() e montarAtivosECusto(), entre outras

1.3 Os dados na memória

Os tipos que o módulo define, campo a campo, com o valor que cada campo guarda nesta execução.

1.3.1 A órbita

Os números da órbita não ficam num objeto com campos: são variáveis de montarOrbita(), que só as funções declaradas ali dentro leem e escrevem. O tipo EstadoDaOrbita copia o alvo, a distância, os dois ângulos e a contagem de escritas para quem os pedir por estado(), que a página do módulo não chama. As travas são uma constante, e a da distância máxima fica abaixo dos 50 m em que o palco corta o desenho (o palco é a parte do programa que cria a tela de desenho e a câmera):

orbita.ts
/** As travas da órbita: até onde ela deixa a câmera ir. Ângulos em graus, distâncias em metros. */
export const TRAVAS_DA_ORBITA = {
  /** A câmera não desce abaixo da altura do alvo: dali para baixo, o tampo fica entre ela e as peças. */
  elevacaoMinimaGraus: 0,
  /**
   * Para cinco graus antes de ficar exatamente acima do alvo. Ali, olhar para o
   * alvo deixa de dizer qual lado da imagem fica em cima, e passar dali vira a
   * imagem de cabeça para baixo e inverte o sentido do arrasto.
   */
  elevacaoMaximaGraus: 85,
  /** O alvo inicial fica dentro do corpo, a peça do meio do tampo; sem esta trava, a câmera entraria nele. */
  raioMinimoM: 0.35,
  /** Sem esta trava, a roda levaria a câmera além dos 50 m em que o palco corta o desenho, e a cena sumiria. */
  raioMaximoM: 8,
} as const;
Variável O que guarda Nesta execução
alvo o ponto para onde a câmera olha ao fim, (0,00; 0,57; −1,24), o ponto que o botão “A cena inteira” pôs como alvo; na abertura, o alvo inicial, (0; 0,95; 0)
raio a distância da câmera ao alvo, que o código chama de raio ao fim, 5,07 m
azimute, elevacao os dois ângulos, em graus ao fim, −35,0 e 35,8
escritas quantas vezes a órbita escreveu a posição da câmera desde que foi instalada ao fim, 25
ponteiros o mapa de cada ponteiro apertado agora (o mouse, cada dedo, a caneta), pelo número que o navegador dá a ele, com a última posição na tela os dois dedos, durante a pinça, o gesto de afastar ou aproximar dois dedos na tela
distanciaAnterior a distância entre os dois dedos, em pixels, no aviso anterior (cada aviso do navegador, o evento do código, traz o número do ponteiro e a posição dele na tela) 100 px quando o segundo dedo pousou, 200 px no último movimento

girar()

‘azimute’ −35,0°
‘elevacao’ 35,8°

aproximar()

‘raio’, a distância
5,07 m

olharPara()

‘alvo’
(0,00; 0,57; −1,24)

aplicar()

‘escritas’
25

a câmera da cena
‘camera.position’

prateleira.atualizar()
lê a câmera a cada quadro

aoDescer(), aoMover()
e aoSubir()

‘ponteiros’
os apertados agora

‘distanciaAnterior’
entre os dois dedos

Figura 2: Quem escreve cada número, com o valor ao fim da execução. Os gestos e os botões mudam os números, e, com a órbita instalada, só aplicar() escreve a posição da câmera. A prateleira do fundo lê a câmera a cada quadro, cada imagem que o aparelho mostra, para escolher entre a versão de perto das peças de reposição e a de longe, com menos triângulos.

1.3.2 O enquadramento

Para enquadrar, a órbita põe o alvo no centro da esfera envolvente e leva a câmera à distância em que a esfera cabe na altura da tela. A esfera sai da caixa envolvente do objeto, a menor caixa, com as arestas paralelas às direções x, y e z, que contém todos os cantos dos triângulos que formam a superfície dele: o centro da esfera é o centro dessa caixa, e o raio é a metade da diagonal dela. A distância pedida é a distância em que a esfera encosta nas bordas de cima e de baixo da tela, multiplicada pela margem, 1,25, para a cena não encostar nelas; o campo de visão vertical, o ângulo que a tela mostra de cima a baixo, 55° nesta câmera, entra nessa conta. A distância concedida é a pedida presa entre as travas de distância, e é a que a câmera usa; a fração da altura é quanto da altura da tela o diâmetro da esfera ocupa na distância concedida.

Campo de Enquadramento A cena inteira A engrenagem pequena
centro, que vira o alvo (0,00; 0,57; −1,24) (0,47; 0,93; −0,05)
raioDaEsferaM 1,87 m cerca de 0,067 m, que a folha não escreve: sai da distância pedida, 0,18 × 0,462 ÷ 1,25, com 0,462 o seno de 27,5°, a metade do campo de visão
distanciaPedidaM 5,07 m 0,18 m
distanciaConcedidaM 5,07 m, entre as travas 0,35 m, a trava mínima
fracaoDaAltura 0,76, que a folha também não escreve: com a distância pedida dentro das travas, a conta dá sempre 0,76 0,37

1.3.3 A conferência da entrega

O nome que o projeto dá à passagem de cada peça do domínio pela conta do enquadramento é conferência da entrega. medirEntrega() guarda o resultado numa EntregaDaJanela, com uma CoberturaDaPeca por peça.

Campo O que guarda Nesta execução
EntregaDaJanela.regime a declaração do regime em janela, lida por regimePorId('inline') “Realidade virtual não imersiva”
EntregaDaJanela.cena o enquadramento da cena inteira o da tabela acima
EntregaDaJanela.pecas uma CoberturaDaPeca por peça, com peca, enquadramento e coberta cinco, na tabela abaixo
EntregaDaJanela.cobertas quantas peças estão cobertas pela órbita: a cobertura do domínio 5
Peça Distância pedida Distância concedida Fração da altura coberta
Corpo 0,24 m 0,35 m 51% true
Eixo 0,30 m 0,35 m 65% true
Engrenagem grande 0,30 m 0,35 m 64% true
Engrenagem pequena 0,18 m 0,35 m 37% true
Tampa 0,25 m 0,35 m 52% true

1.3.4 Os limites

Cada um dos três limites do regime fala de uma destas coisas: a profundidade inferida é a distância que quem olha calcula a partir de pistas da própria imagem; a escala corporal é a comparação entre o tamanho das coisas da cena e o corpo de quem as vê; e o alcance de braço é a distância até onde a mão chega sem dar um passo. Cada limite tem quatro partes:

janela.ts (o regime)
export interface LimiteDoRegime {
  readonly id: 'profundidade-inferida' | 'sem-escala-corporal' | 'sem-alcance-de-braco';
  /** O que falta, numa frase que quem lê consegue conferir. */
  readonly enunciado: string;
  /** Por que falta: a razão técnica. */
  readonly porque: string;
  /** O que a falta impede de demonstrar neste regime. */
  readonly impedeDemonstrar: string;
}
LimiteDoRegime.id O que a falta impede de demonstrar LimiteAferido.afericao Por quê, nesta execução
'profundidade-inferida' julgar, sem girar a câmera, qual de duas peças próximas está mais perto 'confirmado' a biblioteca entrega uma imagem por quadro, para a tela, e não uma para cada olho
'sem-escala-corporal' que o tampo tem a altura certa para trabalhar de pé 'confirmado' o espaço de referência declarado para o regime (o sistema de coordenadas em que as posições do ambiente são expressas, junto com a regra que fixa a origem) é o 'viewer', que acompanha o olhar e não tem chão; e nenhum visor (o aparelho preso à cabeça que cobre os olhos) está apresentando imagem
'sem-alcance-de-braco' que uma peça está longe demais para ser apanhada sem dar um passo até ela 'inconclusivo' nada no programa mede o braço de quem usa

1.4 As funções

Cada função que decide alguma coisa, com o que recebe, o que decide e o que devolve nesta execução.

1.4.1 montarOrbita()

Recebe Decide Devolve
palco.camera, a câmera que o palco pôs em (0; 1,55; 1,50), e a tela de desenho, que recebe os gestos a partida: mede o caminho do alvo inicial, em (0; 0,95; 0), até a câmera (o deslocamento do código), que dá (0; 0,60; 1,50), e tira desse caminho os três números, com a distância e a elevação já presas às travas; depois põe os ouvintes dos gestos e escreve a câmera uma vez uma Orbita, com 1,62 m, 21,8° e azimute 0,0°, e a contagem em 1
orbita.ts
// A órbita começa onde a câmera já está. Os três números saem da posição que o
// palco deu à câmera, medida a partir do alvo, e não de valores escritos aqui.
const alvo: Vector3 = ALVO_INICIAL.clone();
const deslocamento: Vector3 = camera.position.clone().sub(alvo);
let raio: number = MathUtils.clamp(
  deslocamento.length(),
  TRAVAS_DA_ORBITA.raioMinimoM,
  TRAVAS_DA_ORBITA.raioMaximoM,
);
let azimute: number = MathUtils.radToDeg(Math.atan2(deslocamento.x, deslocamento.z));
let elevacao: number = MathUtils.clamp(
  MathUtils.radToDeg(Math.asin(deslocamento.y / deslocamento.length())),
  TRAVAS_DA_ORBITA.elevacaoMinimaGraus,
  TRAVAS_DA_ORBITA.elevacaoMaximaGraus,
);
let escritas: number = 0;

1.4.2 aplicar()

Recebe Decide Devolve
nenhum parâmetro; lê de montarOrbita() o alvo, a distância (raio) e os dois ângulos a ordem: primeiro a posição, que sai sempre dos três números; depois a direção do olhar, para o alvo; por fim, mais um na contagem nada: escreve a câmera, o que nesta execução acontece 25 vezes
orbita.ts
function aplicar(): void {
  const a: number = MathUtils.degToRad(azimute);
  const e: number = MathUtils.degToRad(elevacao);
  // O raio visto de cima, a sombra dele no chão. É esse comprimento que o
  // azimute reparte entre x e z.
  const sombra: number = raio * Math.cos(e);
  camera.position.set(
    alvo.x + sombra * Math.sin(a),
    alvo.y + raio * Math.sin(e),
    alvo.z + sombra * Math.cos(a),
  );
  camera.lookAt(alvo);
  escritas += 1;
}

1.4.3 girar()

Recebe Decide Devolve
dx e dy, os pixels que o ponteiro andou desde o aviso anterior; no arrasto, 10 e 4 a cada movimento o sinal: o azimute diminui quando a mão vai para a direita, e a elevação sobe quando a mão vai para baixo; a elevação fica presa entre 0° e 85°, e o azimute não tem trava nada: chama aplicar(); depois dos dez movimentos, azimute −35,0° e elevação 35,8°
orbita.ts
function girar(dx: number, dy: number): void {
  // Arrastar para a direita leva a câmera para a esquerda em volta do alvo, e a
  // cena parece girar para a direita, junto com a mão.
  azimute -= dx * GRAUS_POR_PIXEL;
  elevacao = MathUtils.clamp(
    elevacao + dy * GRAUS_POR_PIXEL,
    TRAVAS_DA_ORBITA.elevacaoMinimaGraus,
    TRAVAS_DA_ORBITA.elevacaoMaximaGraus,
  );
  aplicar();
}

os dois passos da roda

true: rolagem
de lado

false

true: para baixo

false: para cima

aoRodar(evento):
preventDefault()

evento.deltaY === 0

retorna, raio igual

evento.deltaY > 0

aproximar(FATOR_DA_RODA):
1.62 vira 1.81

aproximar(1 / FATOR_DA_RODA):
1.81 volta a 1.62

aplicar()

arrasto de 100 por 40 px

true: mouse sem
botão

false

false: um
ponteiro só

aoDescer(evento):
ponteiros.set(),
setPointerCapture()

aoMover(evento),
dez vezes

anterior === undefined

retorna sem girar

ponteiros.size >= 2

girar(dx, dy):
azimute -= dx * 0.35,
elevacao entre 0 e 85

aplicar()

aoSubir(evento):
ponteiros.delete()

Figura 3: Cada um dos dez movimentos do arrasto passa por girar() e termina em aplicar(). A elevação vai a 21,8 + 40 × 0,35 = 35,8 graus, abaixo da trava de 85, que não age. O mesmo fluxo segue pelos dois passos da roda, que aoRodar() atende.

1.4.4 aoDescer() e aoMover()

Recebe Decide Devolve
um aviso do navegador aoDescer() guarda o ponteiro no mapa ponteiros e mede a referência da pinça no pouso, com distanciaEntreDoisDedos(), que devolve 0 enquanto não há dois ponteiros no mapa; aoMover() escolhe um de três casos: com o ponteiro fora do mapa, sai sem fazer nada; com dois ou mais no mapa, chama aproximar() com a distância anterior entre os dedos dividida pela de agora, quando as duas passam de zero; com um só, chama girar(). aoSubir() tira o ponteiro do mapa e mede de novo a distância entre os dedos nada; nesta execução, girar() dez vezes no arrasto e aproximar() dez vezes com os dois dedos, de 1,62 m a 0,81 m
orbita.ts
// Os ponteiros apertados agora, pelo número que o navegador dá a cada um. Com
// um só, o arrasto gira; com dois dedos na tela, a distância entre eles muda o
// raio, e medir essa distância exige guardar os dois.
const ponteiros: Map<number, { x: number; y: number }> = new Map();
let distanciaAnterior: number = 0;

function distanciaEntreDoisDedos(): number {
  const [primeiro, segundo] = [...ponteiros.values()];
  if (primeiro === undefined || segundo === undefined) {
    return 0;
  }
  return Math.hypot(primeiro.x - segundo.x, primeiro.y - segundo.y);
}
orbita.ts
function aoDescer(evento: PointerEvent): void {
  ponteiros.set(evento.pointerId, { x: evento.clientX, y: evento.clientY });
  // A referência da pinça é medida no instante em que o segundo dedo pousa.
  // Medida só no primeiro movimento, o trecho inicial do gesto se perderia.
  distanciaAnterior = distanciaEntreDoisDedos();
  // Os movimentos deste ponteiro continuam chegando a este elemento mesmo se o
  // cursor sair dele no meio do arrasto.
  elemento.setPointerCapture(evento.pointerId);
}
orbita.ts
function aoMover(evento: PointerEvent): void {
  const anterior: { x: number; y: number } | undefined = ponteiros.get(evento.pointerId);
  // Mouse passando sem botão apertado: não há arrasto a seguir.
  if (anterior === undefined) {
    return;
  }
  const dx: number = evento.clientX - anterior.x;
  const dy: number = evento.clientY - anterior.y;
  ponteiros.set(evento.pointerId, { x: evento.clientX, y: evento.clientY });

  if (ponteiros.size >= 2) {
    const agora: number = distanciaEntreDoisDedos();
    // Dedos que se afastam dão razão menor que 1, e o raio encolhe: a câmera
    // chega perto. A razão, e não a diferença em pixels, faz o gesto render o
    // mesmo perto e longe do alvo.
    if (distanciaAnterior > 0 && agora > 0) {
      aproximar(distanciaAnterior / agora);
    }
    distanciaAnterior = agora;
    return;
  }

  girar(dx, dy);
}

dois ponteiros

os dois dedos:
10 movimentos

aproximar() pela
razão das distâncias
10 escritas

um ponteiro

o arrasto:
10 movimentos

girar(10, 4)
a cada movimento
10 escritas

nenhum apertado

o mouse passa
sem botão

sai sem mudar nada
0 escritas

Figura 4: Os três casos de aoMover() lado a lado: o mouse que passa sem botão não escreve nada; nesta execução, o arrasto escreveu dez vezes e os dois dedos, dez.

true

true

false

aoDescer(evento) do
primeiro dedo:
distanciaEntreDoisDedos()
devolve 0

aoDescer(evento) do
segundo dedo:
distanciaAnterior = 100 px

aoMover(evento)

ponteiros.size >= 2

agora =
distanciaEntreDoisDedos()

distanciaAnterior > 0
&& agora > 0

aproximar(distanciaAnterior / agora):
razão menor que 1,
o raio encolhe

distanciaAnterior = agora

aoSubir(evento) dos
dois dedos: distância
final 0.81 m

Figura 5: A cada aviso, um dos dedos se afasta dez pixels do outro, e cada aviso multiplica a distância da câmera pela razão daquele trecho: 100/110, 110/120, e assim até 190/200. Cada razão começa onde a anterior terminou, e o produto das dez é 100/200: 1,62 × 1/2 = 0,81 m.

1.4.5 aoRodar() e aproximar()

Recebe Decide Devolve
um passo da roda, com deltaY, o valor vertical primeiro o zero: a rolagem de lado chega com deltaY zero, e aoRodar() sai sem mexer na distância; depois o sinal: para baixo, aproximar(FATOR_DA_RODA), e para cima, aproximar(1 / FATOR_DA_RODA). aproximar() multiplica a distância pelo número que recebe e a prende entre as travas: com 1,12, apesar do nome, ela afasta a câmera nada; 1,62 m vira 1,81 m e volta a 1,62 m, e a rolagem de lado deixa a distância em 1,62 m e a contagem em 13
orbita.ts
function aoRodar(evento: WheelEvent): void {
  // Sem isto, a roda também rolaria a página enquanto a câmera se move.
  evento.preventDefault();
  // A rolagem de lado, que o trackpad produz, chega com deltaY zero.
  if (evento.deltaY === 0) {
    return;
  }
  // Afastar multiplica e aproximar divide pelo mesmo fator: um passo para cada
  // lado devolve a câmera à mesma distância.
  aproximar(evento.deltaY > 0 ? FATOR_DA_RODA : 1 / FATOR_DA_RODA);
}

aproximar() e olharPara(), que o enquadramento também usa, prendem a distância nas mesmas travas antes de chamar aplicar():

orbita.ts
function aproximar(fator: number): void {
  raio = MathUtils.clamp(raio * fator, TRAVAS_DA_ORBITA.raioMinimoM, TRAVAS_DA_ORBITA.raioMaximoM);
  aplicar();
}

function olharPara(novoAlvo: Vector3, novoRaio: number): void {
  alvo.copy(novoAlvo);
  raio = MathUtils.clamp(novoRaio, TRAVAS_DA_ORBITA.raioMinimoM, TRAVAS_DA_ORBITA.raioMaximoM);
  aplicar();
}

1.4.6 caixaDoQueSeVe()

Recebe Decide Devolve
um nó da cena (cada objeto da cena é um nó, e um nó pode ficar pendurado em outro): cada peça do domínio e a raiz cena.sala, toda vez que a folha é reescrita, e o nó de cada botão clicado só os nós visíveis entram; para cada um, um de três casos: lote (o conjunto de cópias desenhado numa só chamada de desenho, o pedido que o programa faz à placa de vídeo para desenhar um objeto), com a caixa que cobre todas as cópias; nó de malha (o nó que carrega uma malha, a forma de uma peça guardada em listas de números), com a caixa da malha levada ao mundo, as coordenadas da cena inteira, no lugar que o nó ocupa nela; nenhum dos dois, nada a caixa do que aparece na tela
orbita.ts
export function caixaDoQueSeVe(no: Object3D): Box3 {
  const caixa: Box3 = new Box3();
  const parte: Box3 = new Box3();
  no.updateWorldMatrix(true, true);
  // Nó escondido é pulado junto com tudo o que está pendurado nele.
  no.traverseVisible((filho: Object3D) => {
    if (filho instanceof InstancedMesh) {
      // O lote tem caixa própria, que cobre todas as cópias de uma vez.
      filho.computeBoundingBox();
      if (filho.boundingBox !== null) {
        caixa.union(parte.copy(filho.boundingBox).applyMatrix4(filho.matrixWorld));
      }
      return;
    }
    if (filho instanceof Mesh) {
      if (filho.geometry.boundingBox === null) {
        filho.geometry.computeBoundingBox();
      }
      if (filho.geometry.boundingBox !== null) {
        caixa.union(parte.copy(filho.geometry.boundingBox).applyMatrix4(filho.matrixWorld));
      }
    }
  });
  return caixa;
}

1.4.7 planejarEnquadramento() e enquadrar()

Recebe Decide Devolve
a caixa e o campo de visão da câmera, 55° com a caixa vazia, devolve undefined, e enquadrar() devolve false sem mover a câmera; nas outras, a distância pedida pela esfera, a concedida pelas travas e a fração da altura um Enquadramento: na cena inteira, esfera de 1,87 m de raio e 5,07 m pedidos e concedidos; na engrenagem pequena, 0,18 m pedidos e 0,35 m concedidos. enquadrar() passa o centro e a distância concedida a olharPara(), que muda o alvo e a distância e chama aplicar()
orbita.ts
export function planejarEnquadramento(
  caixa: Box3,
  campoVerticalGraus: number,
): Enquadramento | undefined {
  // Caixa vazia não tem centro: a biblioteca devolveria uma esfera de raio -1.
  if (caixa.isEmpty()) {
    return undefined;
  }
  const esfera: Sphere = caixa.getBoundingSphere(new Sphere());
  const meioCampo: number = MathUtils.degToRad(campoVerticalGraus / 2);

  // A esfera encosta na borda de cima e na de baixo da tela quando a distância
  // até o centro dela é o raio dividido pelo seno da metade do campo de visão.
  const pedida: number = (esfera.radius / Math.sin(meioCampo)) * MARGEM_DE_ENQUADRAMENTO;
  const concedida: number = MathUtils.clamp(
    pedida,
    TRAVAS_DA_ORBITA.raioMinimoM,
    TRAVAS_DA_ORBITA.raioMaximoM,
  );

  // Vista da câmera, a esfera se abre num ângulo cujo seno é raio sobre
  // distância; na tela, conta a tangente desse ângulo contra a do meio campo.
  // A conta mede só a altura da tela, e vale porque a tela de desenho da página
  // tem proporção 16 por 9: mais larga do que alta, é a altura que encosta
  // primeiro. Numa tela mais alta do que larga, a esfera sairia pelos lados com
  // a fração ainda abaixo de 1.
  const angulo: number = Math.asin(Math.min(1, esfera.radius / concedida));
  return {
    centro: esfera.center.clone(),
    raioDaEsferaM: esfera.radius,
    distanciaPedidaM: pedida,
    distanciaConcedidaM: concedida,
    fracaoDaAltura: Math.tan(angulo) / Math.tan(meioCampo),
  };
}
orbita.ts
enquadrar(caixa: Box3): boolean {
  const plano: Enquadramento | undefined = planejarEnquadramento(caixa, camera.fov);
  // Sem esta saída, a câmera iria para perto da origem do mundo, no chão
  // embaixo da bancada.
  if (plano === undefined) {
    return false;
  }
  olharPara(plano.centro, plano.distanciaConcedidaM);
  return true;
},

o movimento

olharPara(plano.centro,
5.07)

aplicar(): alvo
(0, 0.57, -1.24),
mesmo azimute e
mesma elevação

a conta, sem mover nada

true

false

orbita.enquadrar(caixa)

planejarEnquadramento(caixa,
camera.fov)

caixa.isEmpty()

undefined: enquadrar()
devolve false e a
câmera fica

esfera de 1.87 m;
pedida = raio dividido
pelo seno de 27.5°,
vezes 1.25: 5.07 m

concedida =
MathUtils.clamp(pedida,
0.35, 8): 5.07 m

a caixa do que se vê

true

false

true

false: luz
ou grupo

clique em ‘A cena inteira’:
caixaDoQueSeVe(cena.sala)

no.updateWorldMatrix(true, true)

no.traverseVisible():
o nó escondido nem
é visitado

filho instanceof
InstancedMesh

computeBoundingBox()
do lote: todas as cópias

filho instanceof Mesh

caixa da geometria,
levada ao mundo

não soma nada

caixa.union()

Figura 6: O clique em “A cena inteira”, da caixa ao movimento: a esfera de 1,87 m de raio, os 5,07 m pedidos e concedidos, e o alvo em (0,00; 0,57; −1,24), com o mesmo azimute e a mesma elevação de antes. O teste do lote vem antes do teste da malha, porque um lote também é um nó de malha: carrega a malha de uma cópia.

1.4.8 medirEntrega()

Recebe Decide Devolve
cena.sala, a lista das cinco peças do domínio e a câmera para cada peça, procura o nó pelo nome; sem nó, a peça entra na lista como não coberta, sem interromper as outras; com nó, passa pela mesma planejarEnquadramento() que a órbita usa para mover a câmera, e está coberta quando a fração da altura é no máximo 1 uma EntregaDaJanela com 5 de 5 peças cobertas
janela.ts (o regime)
export function medirEntrega(
  raiz: Object3D,
  pecas: readonly Peca[],
  camera: PerspectiveCamera,
): EntregaDaJanela {
  const coberturas: CoberturaDaPeca[] = [];
  let cobertas: number = 0;
  for (const peca of pecas) {
    const no: Object3D | undefined = raiz.getObjectByName(peca.id);
    const plano: Enquadramento | undefined =
      no === undefined ? undefined : planejarEnquadramento(caixaDoQueSeVe(no), camera.fov);
    const coberta: boolean = plano !== undefined && plano.fracaoDaAltura <= 1;
    if (coberta) {
      cobertas += 1;
    }
    coberturas.push({ peca, enquadramento: plano, coberta });
  }

  return {
    regime: regimePorId('inline'),
    cena: planejarEnquadramento(caixaDoQueSeVe(raiz), camera.fov),
    pecas: coberturas,
    cobertas,
  };
}

true

false

true

false

próxima peça

fim da lista

atualizarFolhas() chama
medirEntrega(cena.sala,
BANCADA.pecas, palco.camera)

for (const peca of pecas):
Corpo, Eixo, Engrenagem
grande, Engrenagem
pequena, Tampa

raiz.getObjectByName(peca.id)

no === undefined

plano undefined:
a peça não está
na cena

planejarEnquadramento(caixaDoQueSeVe(no),
camera.fov)

plano !== undefined &&
plano.fracaoDaAltura <= 1

cobertas += 1

coberturas.push()

planejarEnquadramento(caixaDoQueSeVe(raiz),
camera.fov): 5.07 m

regimePorId(‘inline’)

EntregaDaJanela com
cobertas 5 de 5

Figura 7: As cinco peças passam pela mesma conta, uma de cada vez, e somam em cobertas; a cena inteira passa pela conta no fim, a 5,07 m. A peça que não estivesse na cena seguiria para a lista sem enquadramento, e a folha a escreveria como “não está na cena”.

1.4.9 custoContraOTeto()

custoContraOTeto() julga o trabalho do código contra o tempo disponível para cada quadro. O custo de um quadro é o tempo que o trabalho do próprio programa consome no processador; o intervalo, o tempo real entre duas imagens apresentadas; e o teto, o tempo que o programa tem para preparar um quadro antes que o aparelho precise do seguinte, 16,7 ms na página do módulo. O medidor é a parte do programa que compara o tempo de cada quadro com o teto e guarda os números dos últimos 120 quadros.

Recebe Decide Devolve
o que o medidor devolve, orcamento.ler() sem quadro medido, uma linha só; nos outros casos, a folga (o teto menos o custo médio, em milissegundos), escrita como “dentro do teto” ou “acima do teto”; e uma terceira linha quando o pior intervalo (o mais longo dos últimos 120 quadros) passa de uma vez e meia o teto, 25,05 ms: é o sinal de imagem perdida, a imagem que não ficou pronta a tempo e fez a tela repetir a anterior; com imagem perdida e o custo médio dentro do teto, essa linha diz onde procurar o atraso, e um dos lugares é o compositor do sistema (a parte do sistema operacional que junta na tela o que todos os programas desenham) duas linhas nesta execução, com 120 quadros medidos e o custo médio dentro do teto; os tempos são da máquina que rodou a página, e não aparecem neste infográfico
janela.ts (o regime)
export function custoContraOTeto(medida: LeituraDoOrcamento): string[] {
  if (medida.quadrosMedidos === 0) {
    return ['Custo do quadro: ainda sem quadros medidos.'];
  }
  const folga: number = medida.tetoMs - medida.custoMedioMs;
  const veredito: string =
    folga >= 0
      ? `dentro do teto de ${medida.tetoMs.toFixed(1)} ms, com ${folga.toFixed(1)} ms de folga`
      : `acima do teto de ${medida.tetoMs.toFixed(1)} ms em ${(-folga).toFixed(1)} ms`;
  const linhas: string[] = [
    `Trabalho do código por quadro: ${medida.custoMedioMs.toFixed(2)} ms em média, ` +
      `em ${medida.quadrosMedidos} ${medida.quadrosMedidos === 1 ? 'quadro' : 'quadros'}, ${veredito}`,
    `Intervalo entre imagens: ${medida.intervaloMedioMs.toFixed(1)} ms em média, ` +
      `o pior ${medida.piorIntervaloMs.toFixed(1)} ms`,
  ];
  if (medida.piorIntervaloMs > medida.tetoMs * FATOR_DE_IMAGEM_PERDIDA) {
    linhas.push(
      folga >= 0
        ? 'Houve imagem perdida com o custo médio dentro do teto: a média não mostra o quadro ' +
            'perdido, e o atraso pode ter vindo de um quadro em que o código passou do teto ou de ' +
            'fora do código medido, da placa de vídeo, do compositor do sistema ou de outro programa'
        : 'Houve imagem perdida, e o trabalho do código sozinho já passa do teto',
    );
  }
  return linhas;
}

1.4.10 aferirLimites()

Recebe Decide Devolve
palco.renderer, o renderizador, a parte da biblioteca que desenha a cena um caso por limite, sem caso padrão: o limite da profundidade inferida sai confirmado quando o renderizador entrega uma imagem por quadro, e contrariado com um visor apresentando; o da falta de escala corporal, confirmado sem visor e com o regime declarado no espaço de referência 'viewer'; o da falta de alcance de braço, inconclusivo sem consultar nada três LimiteAferido: confirmado, confirmado e inconclusivo
janela.ts (o regime)
export function aferirLimites(renderer: WebGLRenderer): LimiteAferido[] {
  // Verdadeiro só quando o renderizador entrega imagem a um visor, uma para cada olho.
  const emVisor: boolean = renderer.xr.isPresenting;
  const declarado: Regime = regimePorId('inline');

  return LIMITES_DA_JANELA.map((limite: LimiteDoRegime): LimiteAferido => {
    switch (limite.id) {
      case 'profundidade-inferida':
        return {
          limite,
          afericao: emVisor ? 'contrariado' : 'confirmado',
          observacao: emVisor
            ? 'o renderizador está entregando uma imagem para cada olho'
            : 'o renderizador desenha uma imagem por quadro, a da câmera da página',
        };
      case 'sem-escala-corporal':
        return {
          limite,
          afericao: !emVisor && declarado.espacoDeReferencia === 'viewer' ? 'confirmado' : 'contrariado',
          observacao:
            `o regime foi declarado com o espaço de referência '${declarado.espacoDeReferencia}', ` +
            'que acompanha o olhar e não tem chão' +
            (emVisor ? ', mas há um visor apresentando, e com ele um chão' : ', e nenhum visor está apresentando'),
        };
      case 'sem-alcance-de-braco':
        return {
          limite,
          afericao: 'inconclusivo',
          observacao: 'nada no programa mede o braço de quem usa: o cursor não tem corpo',
        };
    }
  });
}

profundidade-inferida

false

sem-escala-corporal

true

sem-alcance-de-braco

atualizarFolhas() chama
aferirLimites(palco.renderer)

emVisor =
renderer.xr.isPresenting:
false

declarado =
regimePorId(‘inline’)

LIMITES_DA_JANELA.map()

switch (limite.id)

emVisor

confirmado: uma
imagem por quadro

!emVisor &&
declarado.espacoDeReferencia
=== ‘viewer’

confirmado: espaço
viewer, sem chão

inconclusivo: nada
no programa mede
o braço

linhasDaJanela(entrega, aferidos)

montarRegimeEmJanela(raizJanela,
orbita, regime)

Figura 8: Os três casos do switch, um por ramo, com o resultado desta execução, sem visor: confirmado, confirmado e inconclusivo.

1.4.11 descrever() e linhasDaJanela()

Recebe Decide Devolve
descrever(): nenhum parâmetro, e lê de montarOrbita() o alvo, os três números e a contagem; linhasDaJanela(): a entrega e os três limites aferidos descrever() escreve “vez” ou “vezes” pela contagem e tira o sinal de um zero que sairia −0.00; linhasDaJanela() só escreve a linha da cena inteira quando ela tem enquadramento, escreve “não está na cena” para a peça sem enquadramento e fecha com a frase da declaração do regime imersivo, o do visor as linhas da folha: as quatro da órbita e as da entrega e dos limites
orbita.ts
descrever(): string[] {
  return [
    // Um valor como -0,001 sairia escrito -0.00; a troca tira o sinal desse zero.
    `Alvo da órbita: ${[alvo.x, alvo.y, alvo.z].map((v: number) => v.toFixed(2).replace('-0.00', '0.00')).join(', ')} m`,
    `Azimute ${azimute.toFixed(1)} graus, elevação ${elevacao.toFixed(1)} graus, ` +
      `distância ${raio.toFixed(2)} m`,
    `Posição da câmera escrita ${escritas} ${escritas === 1 ? 'vez' : 'vezes'} desde que a página abriu`,
    `Travas: elevação de ${TRAVAS_DA_ORBITA.elevacaoMinimaGraus} a ` +
      `${TRAVAS_DA_ORBITA.elevacaoMaximaGraus} graus, distância de ` +
      `${TRAVAS_DA_ORBITA.raioMinimoM.toFixed(2)} a ${TRAVAS_DA_ORBITA.raioMaximoM.toFixed(0)} m`,
  ];
},
janela.ts (o regime)
export function linhasDaJanela(entrega: EntregaDaJanela, aferidos: readonly LimiteAferido[]): string[] {
  const linhas: string[] = [
    `Regime: ${entrega.regime.nome}. ${entrega.regime.papel}`,
    `O que ele rastreia, como foi declarado: ${entrega.regime.rastreia}`,
  ];

  if (entrega.cena !== undefined) {
    linhas.push(
      `A cena inteira cabe na tela a ${entrega.cena.distanciaConcedidaM.toFixed(2)} m do centro dela ` +
        `(esfera de ${entrega.cena.raioDaEsferaM.toFixed(2)} m de raio; a órbita vai até ` +
        `${TRAVAS_DA_ORBITA.raioMaximoM} m)`,
    );
  }

  linhas.push('', `Peças do domínio cobertas pela órbita: ${entrega.cobertas} de ${entrega.pecas.length}`);
  for (const cobertura of entrega.pecas) {
    const plano: Enquadramento | undefined = cobertura.enquadramento;
    if (plano === undefined) {
      linhas.push(`  ${cobertura.peca.nome}: não está na cena`);
      continue;
    }
    linhas.push(
      `  ${cobertura.peca.nome}: a ${plano.distanciaConcedidaM.toFixed(2)} m ` +
        `(a conta pedia ${plano.distanciaPedidaM.toFixed(2)} m), ocupa ` +
        `${(plano.fracaoDaAltura * 100).toFixed(0)}% da altura da tela`,
    );
  }

  linhas.push('', 'O que este regime não oferece:');
  for (const aferido of aferidos) {
    linhas.push(
      `${aferido.limite.enunciado} [${aferido.afericao}]`,
      `  Por quê: ${aferido.limite.porque}.`,
      `  Impede demonstrar: ${aferido.limite.impedeDemonstrar}.`,
      `  Aferição: ${aferido.observacao}.`,
    );
  }
  linhas.push('', `Contraste, como foi declarado para o regime imersivo: ${regimePorId('immersive-vr').papel}`);
  return linhas;
}

1.4.12 montarRegimeEmJanela()

Recebe Decide Devolve
o lugar da tela em que a folha é escrita, as linhas da órbita e as do regime a ordem: primeiro a câmera em órbita, em parágrafos; depois o que a janela entrega e não oferece, num bloco de texto que guarda as quebras de linha nada: reescreve a folha inteira, o que nesta execução acontece dez vezes: uma na abertura, uma na chegada da morsa, duas nos botões e seis nos cliques em “Medir agora”
janela.ts (a folha)
export function montarRegimeEmJanela(
  raiz: HTMLElement,
  orbita: readonly string[],
  regime: readonly string[],
): void {
  raiz.replaceChildren();

  raiz.appendChild(subtitulo('A câmera em órbita'));
  for (const linha of orbita) {
    raiz.appendChild(paragrafo(linha));
  }

  raiz.appendChild(subtitulo('O que a janela entrega, e o que ela não oferece'));
  const bloco: HTMLPreElement = document.createElement('pre');
  bloco.textContent = regime.join('\n');
  raiz.appendChild(bloco);
}

1.4.13 Os botões de enquadramento e atualizarFolhas()

Recebe Decide Devolve
a lista das peças do domínio e os nós da cena uma entrada para a cena inteira, uma para cada peça que está na cena e uma para a prateleira; peça fora da cena não ganha botão; cada clique pede o enquadramento à órbita e chama atualizarFolhas(), que reescreve a folha sete botões: A cena inteira, Corpo, Eixo, Engrenagem grande, Engrenagem pequena, Tampa e A prateleira do fundo
06_demo.ts
// Cada botão de enquadramento pede à órbita um nó da cena, e a distância sai da
// caixa dele, e não de um número escrito aqui. A câmera só se move pela órbita:
// escrever a posição dela direto daqui seria desfeito no primeiro arrasto.
const pedidos: { rotulo: string; no: Object3D }[] = [{ rotulo: 'A cena inteira', no: cena.sala }];
for (const peca of BANCADA.pecas) {
  const no: Object3D | undefined = cena.pecas.get(peca.id);
  if (no !== undefined) {
    pedidos.push({ rotulo: peca.nome, no });
  }
}
pedidos.push({ rotulo: 'A prateleira do fundo', no: prateleira.no });

for (const pedido of pedidos) {
  const botao: HTMLButtonElement = criarNo<HTMLButtonElement>('button', barraDeEnquadramento);
  botao.type = 'button';
  botao.textContent = pedido.rotulo;
  botao.addEventListener('click', () => {
    orbita.enquadrar(caixaDoQueSeVe(pedido.no));
    atualizarFolhas();
  });
}

1.5 Uma execução, passo a passo

As abas seguem uma execução desde a abertura da página do módulo até o último clique em “Medir agora”. A folha só é reescrita em quatro momentos: na abertura, quando chega a morsa (a peça trazida de fora no módulo sobre ativos), a cada botão e a cada clique em “Medir agora”. Por isso, depois de cada gesto, um clique em “Medir agora” a reescreveu, e é dessa folha que vem cada número das abas. Os desenhos das abas mostram só o gesto, e esses cliques de leitura não aparecem destacados neles. Em cada desenho, a órbita fica em cima e a folha embaixo. O que roda tem borda grossa e fundo amarelo; o que a aba muda tem borda grossa, fundo verde e letra em negrito; o que rodou numa aba anterior tem borda tracejada e fundo cinza; e o que ainda não rodou fica sem destaque.

os três números da órbita

a página
abre

montarOrbita()
mede a partida
na câmera

arrasto de
um ponteiro

aoMover()
um ponteiro
ou dois?

dois dedos
na tela

passo da
roda

aoRodar()
o zero, depois
o sinal

botão de
enquadramento

enquadrar()
e a conta
da esfera

girar()
com um ponteiro

aproximar()
dois ponteiros, ou
roda fora do zero

olharPara()
novo alvo e
nova distância

azimute e
elevação

distância

alvo

aplicar()
escreve a câmera

contagem
de escritas

clique em
Medir agora

custoContraOTeto()

atualizarFolhas()
também na abertura,
quando chega a morsa
e em cada botão

medirEntrega()
peça a peça

aferirLimites()

linhasDaJanela()

montarRegimeEmJanela()
escreve a folha

Figura 9: montarOrbita() mede o caminho do alvo inicial, em (0; 0,95; 0), até a câmera, (0; 0,60; 1,50), e guarda a distância de 1,62 m, a elevação de 21,8° e o azimute de 0,0°. aplicar() escreve a câmera em (0; 1,55; 1,50), o lugar em que ela já estava, e a contagem de escritas fica em 1.

os três números da órbita

a página
abre

montarOrbita()
mede a partida
na câmera

arrasto de
um ponteiro

aoMover()
um ponteiro
ou dois?

dois dedos
na tela

passo da
roda

aoRodar()
o zero, depois
o sinal

botão de
enquadramento

enquadrar()
e a conta
da esfera

girar()
com um ponteiro

aproximar()
dois ponteiros, ou
roda fora do zero

olharPara()
novo alvo e
nova distância

azimute e
elevação

distância

alvo

aplicar()
escreve a câmera

contagem
de escritas

clique em
Medir agora

custoContraOTeto()

atualizarFolhas()
também na abertura,
quando chega a morsa
e em cada botão

medirEntrega()
peça a peça

aferirLimites()

linhasDaJanela()

montarRegimeEmJanela()
escreve a folha

Figura 10: medirEntrega() passa cada peça do domínio pela conta do enquadramento: as cinco pedem menos que a trava de 0,35 m, ficam a 0,35 m e ocupam de 37% a 65% da altura da tela, e a folha escreve 5 de 5. aferirLimites() dá confirmado, confirmado e inconclusivo. Quando a morsa chega, a folha é reescrita, e os números desta aba são os da folha reescrita quando a morsa chega, que o desenho não mostra.

os três números da órbita

a página
abre

montarOrbita()
mede a partida
na câmera

arrasto de
um ponteiro

aoMover()
um ponteiro
ou dois?

dois dedos
na tela

passo da
roda

aoRodar()
o zero, depois
o sinal

botão de
enquadramento

enquadrar()
e a conta
da esfera

girar()
com um ponteiro

aproximar()
dois ponteiros, ou
roda fora do zero

olharPara()
novo alvo e
nova distância

azimute e
elevação

distância

alvo

aplicar()
escreve a câmera

contagem
de escritas

clique em
Medir agora

custoContraOTeto()

atualizarFolhas()
também na abertura,
quando chega a morsa
e em cada botão

medirEntrega()
peça a peça

aferirLimites()

linhasDaJanela()

montarRegimeEmJanela()
escreve a folha

Figura 11: Dez movimentos de dez pixels para a direita e quatro para baixo chegam a aoMover(), que acha um ponteiro só e chama girar(10, 4) dez vezes. O azimute vai a −35,0° e a elevação a 35,8°; a distância continua 1,62 m, e a contagem vai a 11.

os três números da órbita

a página
abre

montarOrbita()
mede a partida
na câmera

arrasto de
um ponteiro

aoMover()
um ponteiro
ou dois?

dois dedos
na tela

passo da
roda

aoRodar()
o zero, depois
o sinal

botão de
enquadramento

enquadrar()
e a conta
da esfera

girar()
com um ponteiro

aproximar()
dois ponteiros, ou
roda fora do zero

olharPara()
novo alvo e
nova distância

azimute e
elevação

distância

alvo

aplicar()
escreve a câmera

contagem
de escritas

clique em
Medir agora

custoContraOTeto()

atualizarFolhas()
também na abertura,
quando chega a morsa
e em cada botão

medirEntrega()
peça a peça

aferirLimites()

linhasDaJanela()

montarRegimeEmJanela()
escreve a folha

Figura 12: aoRodar() acha deltaY maior que zero e chama aproximar(FATOR_DA_RODA), que multiplica a distância por 1,12 e, apesar do nome, afasta a câmera: 1,62 × 1,12 = 1,81 m. A contagem vai a 12.

os três números da órbita

a página
abre

montarOrbita()
mede a partida
na câmera

arrasto de
um ponteiro

aoMover()
um ponteiro
ou dois?

dois dedos
na tela

passo da
roda

aoRodar()
o zero, depois
o sinal

botão de
enquadramento

enquadrar()
e a conta
da esfera

girar()
com um ponteiro

aproximar()
dois ponteiros, ou
roda fora do zero

olharPara()
novo alvo e
nova distância

azimute e
elevação

distância

alvo

aplicar()
escreve a câmera

contagem
de escritas

clique em
Medir agora

custoContraOTeto()

atualizarFolhas()
também na abertura,
quando chega a morsa
e em cada botão

medirEntrega()
peça a peça

aferirLimites()

linhasDaJanela()

montarRegimeEmJanela()
escreve a folha

Figura 13: Com deltaY menor que zero, aproximar(1 / FATOR_DA_RODA) divide a distância por 1,12, e 1,81 m volta a 1,62 m, a distância da abertura. A contagem vai a 13.

os três números da órbita

a página
abre

montarOrbita()
mede a partida
na câmera

arrasto de
um ponteiro

aoMover()
um ponteiro
ou dois?

dois dedos
na tela

passo da
roda

aoRodar()
o zero, depois
o sinal

botão de
enquadramento

enquadrar()
e a conta
da esfera

girar()
com um ponteiro

aproximar()
dois ponteiros, ou
roda fora do zero

olharPara()
novo alvo e
nova distância

azimute e
elevação

distância

alvo

aplicar()
escreve a câmera

contagem
de escritas

clique em
Medir agora

custoContraOTeto()

atualizarFolhas()
também na abertura,
quando chega a morsa
e em cada botão

medirEntrega()
peça a peça

aferirLimites()

linhasDaJanela()

montarRegimeEmJanela()
escreve a folha

Figura 14: A rolagem de lado chega com deltaY zero, e aoRodar() sai antes do teste do sinal, sem chamar aproximar(). Nenhum número muda: a distância continua 1,62 m, e a contagem, 13.

os três números da órbita

a página
abre

montarOrbita()
mede a partida
na câmera

arrasto de
um ponteiro

aoMover()
um ponteiro
ou dois?

dois dedos
na tela

passo da
roda

aoRodar()
o zero, depois
o sinal

botão de
enquadramento

enquadrar()
e a conta
da esfera

girar()
com um ponteiro

aproximar()
dois ponteiros, ou
roda fora do zero

olharPara()
novo alvo e
nova distância

azimute e
elevação

distância

alvo

aplicar()
escreve a câmera

contagem
de escritas

clique em
Medir agora

custoContraOTeto()

atualizarFolhas()
também na abertura,
quando chega a morsa
e em cada botão

medirEntrega()
peça a peça

aferirLimites()

linhasDaJanela()

montarRegimeEmJanela()
escreve a folha

Figura 15: O segundo dedo pousa a 100 pixels do primeiro, e aoDescer(), que o desenho não mostra, guarda essa distância. A cada aviso, um dos dedos se afasta dez pixels do outro, até 200, e aoMover() acha dois ponteiros e chama aproximar() dez vezes, cada vez pela razão daquele trecho. O produto das razões é 100/200: a distância vai de 1,62 m a 0,81 m, e a contagem, a 23.

os três números da órbita

a página
abre

montarOrbita()
mede a partida
na câmera

arrasto de
um ponteiro

aoMover()
um ponteiro
ou dois?

dois dedos
na tela

passo da
roda

aoRodar()
o zero, depois
o sinal

botão de
enquadramento

enquadrar()
e a conta
da esfera

girar()
com um ponteiro

aproximar()
dois ponteiros, ou
roda fora do zero

olharPara()
novo alvo e
nova distância

azimute e
elevação

distância

alvo

aplicar()
escreve a câmera

contagem
de escritas

clique em
Medir agora

custoContraOTeto()

atualizarFolhas()
também na abertura,
quando chega a morsa
e em cada botão

medirEntrega()
peça a peça

aferirLimites()

linhasDaJanela()

montarRegimeEmJanela()
escreve a folha

Figura 16: enquadrar() pede a conta a planejarEnquadramento(), e a distância pedida para a engrenagem pequena é 0,18 m, e aqui a trava decide: a distância concedida é 0,35 m, mais longe do que a conta pediu, e a peça ocupa 37% da altura da tela. olharPara() leva o alvo a (0,47; 0,93; −0,05) e a distância a 0,35 m, sem mudar o azimute nem a elevação. A contagem vai a 24, e a folha é reescrita.

os três números da órbita

a página
abre

montarOrbita()
mede a partida
na câmera

arrasto de
um ponteiro

aoMover()
um ponteiro
ou dois?

dois dedos
na tela

passo da
roda

aoRodar()
o zero, depois
o sinal

botão de
enquadramento

enquadrar()
e a conta
da esfera

girar()
com um ponteiro

aproximar()
dois ponteiros, ou
roda fora do zero

olharPara()
novo alvo e
nova distância

azimute e
elevação

distância

alvo

aplicar()
escreve a câmera

contagem
de escritas

clique em
Medir agora

custoContraOTeto()

atualizarFolhas()
também na abertura,
quando chega a morsa
e em cada botão

medirEntrega()
peça a peça

aferirLimites()

linhasDaJanela()

montarRegimeEmJanela()
escreve a folha

Figura 17: A esfera da cena inteira tem 1,87 m de raio, e a distância pedida, 5,07 m, fica entre as travas: a concedida é a mesma. O alvo vai a (0,00; 0,57; −1,24), entre a mesa e a prateleira, a contagem vai a 25, e a folha é reescrita.

os três números da órbita

a página
abre

montarOrbita()
mede a partida
na câmera

arrasto de
um ponteiro

aoMover()
um ponteiro
ou dois?

dois dedos
na tela

passo da
roda

aoRodar()
o zero, depois
o sinal

botão de
enquadramento

enquadrar()
e a conta
da esfera

girar()
com um ponteiro

aproximar()
dois ponteiros, ou
roda fora do zero

olharPara()
novo alvo e
nova distância

azimute e
elevação

distância

alvo

aplicar()
escreve a câmera

contagem
de escritas

clique em
Medir agora

custoContraOTeto()

atualizarFolhas()
também na abertura,
quando chega a morsa
e em cada botão

medirEntrega()
peça a peça

aferirLimites()

linhasDaJanela()

montarRegimeEmJanela()
escreve a folha

Figura 18: custoContraOTeto() lê o medidor, com 120 quadros medidos, todos os que ele guarda, e escreve o custo e a folga; na máquina que rodou a página, o pior intervalo não passou de 25,05 ms, e a linha da imagem perdida não saiu. A contagem continua 25: medir não move a câmera.

A folha do regime depois do último clique, como a página do módulo a escreve. A linha dos quadros desenhados e as duas linhas de custo vêm com … no lugar dos tempos medidos e da contagem de quadros desenhados, porque medem a máquina em que a página rodou, e não o código; pela mesma razão, em outra máquina a folga pode sair como “acima do teto”, e a linha da imagem perdida pode aparecer. Abaixo desta folha, a página do módulo mantém a do módulo sobre ativos e orçamento de cena.

A câmera em órbita

Alvo da órbita: 0.00, 0.57, -1.24 m

Azimute -35.0 graus, elevação 35.8 graus, distância 5.07 m

Posição da câmera escrita 25 vezes desde que a página abriu

Travas: elevação de 0 a 85 graus, distância de 0.35 a 8 m

Quadros desenhados desde que a página abriu: …

O que a janela entrega, e o que ela não oferece
Regime: Realidade virtual não imersiva. É o caso base e o destino de quem não tem headset: a bancada inteira precisa ser montável aqui.
O que ele rastreia, como foi declarado: nada do corpo; a câmera obedece ao mouse
A cena inteira cabe na tela a 5.07 m do centro dela (esfera de 1.87 m de raio; a órbita vai até 8 m)

Peças do domínio cobertas pela órbita: 5 de 5
  Corpo: a 0.35 m (a conta pedia 0.24 m), ocupa 51% da altura da tela
  Eixo: a 0.35 m (a conta pedia 0.30 m), ocupa 65% da altura da tela
  Engrenagem grande: a 0.35 m (a conta pedia 0.30 m), ocupa 64% da altura da tela
  Engrenagem pequena: a 0.35 m (a conta pedia 0.18 m), ocupa 37% da altura da tela
  Tampa: a 0.35 m (a conta pedia 0.25 m), ocupa 52% da altura da tela

O que este regime não oferece:
A profundidade é inferida da imagem, e não vista com os dois olhos. [confirmado]
  Por quê: a tela mostra a mesma imagem aos dois olhos; a distância se infere da perspectiva, de qual peça tampa qual e do tamanho relativo, e não da diferença entre duas vistas.
  Impede demonstrar: julgar, sem girar a câmera, qual de duas peças próximas está mais perto, que é o julgamento que encaixar uma peça na outra pede.
  Aferição: o renderizador desenha uma imagem por quadro, a da câmera da página.
Não há escala corporal. [confirmado]
  Por quê: o tampo está a 90 cm do chão da cena, mas o ambiente não sabe onde fica o chão da sala nem a altura dos olhos de quem olha; a bancada aparece do tamanho que a janela tiver.
  Impede demonstrar: que a bancada tem a altura certa para trabalhar de pé diante dela.
  Aferição: o regime foi declarado com o espaço de referência 'viewer', que acompanha o olhar e não tem chão, e nenhum visor está apresentando.
Não há alcance de braço. [inconclusivo]
  Por quê: o cursor chega a qualquer ponto da tela com o mesmo gesto, esteja a peça a 0.35 m ou a 8 m da câmera.
  Impede demonstrar: que uma peça está longe demais para ser apanhada sem dar um passo até ela.
  Aferição: nada no programa mede o braço de quem usa: o cursor não tem corpo.

Contraste, como foi declarado para o regime imersivo: É onde escala corporal e alcance de braço passam a existir — e nenhum dos dois tem equivalente na janela do desktop.

Trabalho do código por quadro: … ms em média, em 120 quadros, dentro do teto de 16.7 ms, com … ms de folga
Intervalo entre imagens: … ms em média, o pior … ms

1.6 Por que o código é assim

As decisões que o próprio código registra, cada uma no identificador em que mora.

NotaTRAVAS_DA_ORBITA

Cada trava existe por um defeito que ela evita. A câmera não desce abaixo da altura do alvo, porque dali para baixo o tampo fica entre ela e as peças. A trava de cima da elevação, 85°, fica cinco graus antes de a câmera ficar exatamente sobre o alvo: ali, olhar para o alvo deixa de dizer qual lado da imagem fica em cima, e passar dali vira a imagem de cabeça para baixo e inverte o sentido do arrasto. O alvo inicial fica dentro do corpo, a peça do meio do tampo, e sem a distância mínima a câmera entraria nessa peça; sem a máxima, a roda a levaria além dos 50 m em que o desenho é cortado, e a cena sumiria. Os dois valores de distância são escolha do projeto, e não saem de conta nenhuma.

NotamontarOrbita()

A órbita começa onde a câmera já está: os três números saem da posição que o palco deu à câmera, e não de valores escritos no arquivo da órbita, que fariam a câmera saltar ao abrir. Sobra uma ligação: se ALVO_INICIAL e o ponto para onde o palco aponta a câmera divergirem, a primeira escrita reorienta a câmera, e a vista muda sozinha ao abrir. E montarOrbita() põe touchAction = 'none' na tela de desenho: sem isso, o navegador trata o arrasto de um dedo como rolagem da página e os dois dedos como aumento da página inteira, e cancela os ponteiros da órbita.

Notaaplicar()

A câmera é escrita quando alguém mexe, e não a cada quadro: parada, a órbita produziria em todos os quadros o mesmo valor de antes. O ganho é de diagnóstico, e não de velocidade: com a escrita só no gesto e no botão, a contagem diz quantas vezes alguém moveu a câmera, e uma contagem que cresce com a página parada denuncia código que escreve a câmera sem ninguém pedir.

Notagirar()

Arrastar para a direita leva a câmera para a esquerda em volta do alvo, e a cena parece girar para a direita, junto com a mão: por isso o azimute diminui quando a mão vai para a direita.

NotaaoDescer() e aoMover()

A referência da pinça é medida no instante em que o segundo dedo pousa. Medida só no primeiro movimento, o primeiro trecho do gesto se perderia, e a mesma pinça pararia em 1,62 × 110/200 = 0,89 m, e não em 0,81. A distância da câmera ao alvo muda pela razão entre as distâncias dos dedos, e não pela diferença em pixels, para o gesto render igual perto e longe do alvo: a pinça que levou 1,62 m a 0,81 m levaria 8 m a 4 m, metade nos dois casos. E setPointerCapture() faz os movimentos de um ponteiro continuarem chegando à tela de desenho mesmo com o cursor fora dela no meio do arrasto.

NotaaoRodar()

A roda para baixo multiplica a distância pelo fator, e a roda para cima a divide pelo mesmo fator, e um passo para cada lado devolve a câmera à mesma distância: 1,62, 1,81 e 1,62 m nesta execução. O teste do zero vem antes do teste do sinal porque a rolagem de lado chega com deltaY zero: sem ele, ela cairia no caso da roda para cima e aproximaria a câmera. preventDefault() impede a página de rolar enquanto a câmera se move.

NotacaixaDoQueSeVe()

A caixa pronta da biblioteca soma também o que está escondido, e cada peça desta cena carrega, escondido, o desenho de uma forma simples, maior que ela, contra a qual se testa proximidade e toque: por isso só os nós visíveis entram. O lote é testado antes da malha porque um lote também é um nó de malha, e na ordem contrária a prateleira entraria pela caixa de uma cópia só. As posições no mundo (onde cada peça fica na cena inteira, conta que a biblioteca guarda e refaz a cada quadro desenhado) se atualizam antes de medir: antes do primeiro quadro, essa conta ainda não foi feita, e uma peça presa ao tampo sairia medida sem a altura dele, 0,90 m abaixo.

NotaplanejarEnquadramento() e enquadrar()

Caixa vazia não tem centro, e a biblioteca devolveria para ela uma esfera de raio −1; sem a saída em enquadrar(), a câmera iria para perto da origem do mundo, no chão embaixo da mesa. A esfera, e não a caixa, porque a esfera ocupa a mesma altura na tela vista de qualquer direção, e a órbita, que mantém o ângulo de quem olha, não precisa refazer a conta a cada ângulo. A conta mede só a altura da tela, e vale porque a tela de desenho tem proporção 16 por 9: numa tela mais alta do que larga, a esfera sairia pelos lados com a fração ainda abaixo de 1.

NotamedirEntrega()

A conferência da entrega usa a mesma conta que a órbita usa para se mover, porque uma conferência com conta própria poderia aprovar uma peça que a câmera não consegue mostrar. A lista de entrada é a das peças do domínio, e não a dos nós da cena: a peça que falta na cena não aparece num percurso pela cena, e a falta passaria sem aviso.

NotacustoContraOTeto()

A folga sai do custo, e não do intervalo: o intervalo acompanha o ritmo da tela e não desce abaixo dele, por mais leve que a cena seja, e serve para mostrar imagem perdida, e não para medir quanto ainda cabe. FATOR_DE_IMAGEM_PERDIDA é 1,5 porque o intervalo anda em múltiplos do ritmo da tela: a 60 imagens por segundo, perder uma leva o intervalo de 16,7 ms a 33,3 ms, e o valor do meio separa a perda do tremor de décimos de milissegundo que a medição tem. Com imagem perdida e o custo médio dentro do teto, a linha que custoContraOTeto() escreve aponta dois lugares onde procurar, porque o medidor guarda o pior intervalo, e não o pior custo: um quadro em que o código passou do teto, ou o que fica fora do código medido, como a placa de vídeo ou o compositor do sistema.

NotaLIMITES_DA_JANELA

Os limites ficam escritos como dado, e não como parágrafo num documento à parte: cada um traz a razão técnica e o que a falta impede de demonstrar, e recebe uma aferição feita no programa rodando. Nenhum é defeito de implementação: os três vêm de o ambiente aparecer numa superfície plana, a uma distância que não é a do corpo de quem olha. Na frase do alcance de braço, 0,35 m e 8 m saem das travas da órbita, e mudam junto com elas.

NotaaferirLimites()

A aferição tem três resultados possíveis, e um deles admite que o programa não tem como medir: nada no programa mede o braço de quem usa, e a função diz isso em vez de inventar um indicador. O switch não tem caso padrão, e por isso um quarto limite acrescentado ao tipo deixa a função com um caminho sem resultado, que a compilação acusa.

NotamontarRegimeEmJanela()

Os limites ficam na mesma página do ambiente, e não num documento separado: é assim que chegam a quem abre o endereço sem ninguém por perto para explicar.

NotaINSTRUCAO_DA_ORBITA

O que a página diz a quem chega fica escrito ao lado do código que atende cada gesto, e quem muda um gesto vê logo ao lado a frase que o descreve. A página do módulo escreve essa instrução antes da cena, para quem chega ler o que fazer antes de tentar, e não depois de desistir.

Notapedidos

Cada botão de enquadramento pede à órbita o enquadramento de um nó da cena, e a distância sai da caixa desse nó, e não de um número escrito na página do módulo. A câmera só se move pela órbita: um botão que escrevesse a posição dela direto teria o trabalho desfeito no primeiro arrasto.

1.7 Como o código se confere

O módulo não tem bateria de testes: quem confere o código é a própria folha, reescrita a cada botão e a cada clique em “Medir agora”, e, num caso só, o compilador.

O que confere O que traz Quando algo falha Nesta execução
a contagem de escritas quantas vezes a órbita escreveu a câmera, ao lado dos quadros desenhados uma contagem que cresce com a página parada denuncia código que escreve a câmera sem pedido 1 na abertura; 25 no fim, depois de 24 escritas pedidas: dez do arrasto, duas da roda, dez dos dois dedos e duas dos botões
a conferência da entrega cada peça do domínio pela mesma conta do enquadramento, e a cobertura do domínio peça que não está na cena: a linha “não está na cena” e a contagem em 4 de 5; peça cuja fração da altura passa de 1: fica fora da contagem 5 de 5, de 37% a 65% da altura da tela
a linha do custo o custo médio contra o teto, e a folga sem quadro medido: “Custo do quadro: ainda sem quadros medidos.”; pior intervalo acima de 25,05 ms: a linha de imagem perdida, com a frase que depende da folga 120 quadros medidos e, na máquina que rodou a página, o custo médio dentro do teto e nenhuma linha de imagem perdida
a aferição os três limites, cada um com o resultado e a razão um quarto limite sem caso no switch não compila; com um visor apresentando, os limites da profundidade e da escala corporal saem contrariados, e isso descreve o regime do visor, e não uma falha confirmado, confirmado e inconclusivo

Exemplo deste infográfico: a página do módulo aberta num navegador que roda sem mostrar a tela, um arrasto de 100 por 40 pixels em dez movimentos, um passo da roda para baixo e um para cima, uma rolagem de lado, dois dedos que se afastam de 100 para 200 pixels, os botões “Engrenagem pequena” e “A cena inteira” e um último clique em “Medir agora”, com um clique em “Medir agora” depois de cada gesto para a folha mostrá-lo; execução de 03/11/2026.