1 Ativos, formatos de troca e orçamento de cena — 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

A oficina passa de 440 triângulos para mais de 7.000, e nenhum deles entra sem justificativa escrita.

Até aqui toda a forma da Bancada nasceu dentro do projeto. As cinco peças do mecanismo somavam 440 triângulos, e esse número pequeno era decisão, não acaso: ele existia para servir de linha de base.

Agora a linha de base é gasta. Entra conteúdo de fora, entra repetição em quantidade, e entra a medição que decide se o resultado cabe na máquina que a turma tem. Os três assuntos são de engenharia de desempenho, e nenhum deles se resolve com impressão sobre fluidez.

O código deste módulo ocupa três arquivos novos em content/, mais o ativo em public/. A cena do módulo anterior não é reescrita: ela ganha uma borda parafusada, um objeto importado e uma prateleira ao fundo.

1.2 O arquivo que chega não sabe o tamanho da peça

Um formato de troca aberto transporta geometria, material e animação — e não transporta a única informação que decide a escala.

O formato usado aqui fixa duas convenções na própria especificação: a unidade é o metro e o vertical é o eixo Y. Isso deveria encerrar o assunto. Não encerra, porque quem exporta nem sempre obedece, e o arquivo continua formalmente válido.

Um exportador que escreveu centímetro produz um arquivo correto e mentiroso ao mesmo tempo. Nenhum campo do cabeçalho denuncia a troca. O que denuncia é alguém saber que a peça tem 18 centímetros de boca e comparar esse número com o que veio.

A convenção desta cena, declarada uma vez

Unidade: metro. Vertical: eixo Y. Origem de cada peça: no ponto em que ela toca a superfície de apoio. Frente: o sentido positivo de Z.

Cena sem convenção escrita não tem contra o que acusar divergência. Cada ativo importado passa a trazer a sua, e a oficina vira uma colcha de escalas que ninguém consegue reconciliar depois.

1.3 Tarefa 1: Trazer ativos externos e ajustá-los ao projeto

A morsa chega deitada, gigante e a 30 centímetros de onde deveria estar — e os três defeitos são previsíveis.

Enunciado da tarefa

Parte do conteúdo da cena chega de fora, num formato de troca aberto que carrega geometria, materiais e animação. Trazer não basta: escala, orientação, unidade de medida e posição na hierarquia são decisões a tomar aqui, e um modelo importado sem ajuste costuma aparecer deitado, gigante ou a dez metros da origem.

O que fica pronto é o caminho completo entre o arquivo de origem e o objeto integrado à cena, com cada ajuste registrado. É esse caminho que distingue um ambiente construído de uma cena de demonstração aberta e publicada como está.

1.3.1 O ativo, e por que ele chega errado de propósito

A morsa aparafusada ao tampo é o ativo externo desta cena. Ela tem quatro malhas, dois materiais e uma animação de abrir e fechar o mordente móvel.

O arquivo foi autorado fora do projeto, e foi autorado em centímetros, com o vertical em Z e com a base 30 centímetros acima da origem. Os três desvios são deliberados. Eles são exatamente o que chega de uma ferramenta de modelagem configurada por outra pessoa, e um ativo perfeito não ensinaria a corrigir nada.

Quem monta a cena não controla a origem do conteúdo. Controla o que faz com ele na chegada, e é essa fronteira que o arquivo abaixo materializa.

src/bancada/content/ativos.ts
// ---------------------------------------------------------------------------
// O caminho entre um arquivo produzido fora e um objeto integrado à cena.
//
// Este arquivo existe porque "carregar" é a parte fácil e a parte curta. Uma
// linha pede o arquivo, a biblioteca devolve a árvore pronta, e a peça aparece
// deitada, gigante e a trinta centímetros de onde deveria estar. Nenhuma dessas
// três coisas é defeito do arquivo: são convenções diferentes entre quem autorou
// e quem recebe, e alguém precisa declarar de que lado a conversão acontece.
//
// A convenção desta cena está declarada abaixo, uma vez, e é contra ela que tudo
// o que chega é medido. Cena sem convenção escrita não tem como acusar
// divergência — cada ativo importado passa a trazer a sua, e a oficina vira uma
// colcha de escalas.
//
// A separação que este arquivo mantém: o que é MEDIDO e o que é DECLARADO. O
// fator de escala e o assentamento na base são derivados da caixa envolvente do
// que chegou, e por isso não têm como estar errados por distração. A orientação
// e o tamanho real da peça são declaração de quem importa, porque o arquivo não
// os carrega — o formato de troca fixa metro e vertical em Y, de modo que um
// exportador que escreveu centímetro produz um arquivo formalmente correto e
// materialmente mentiroso. Quem sabe que a morsa tem dezoito centímetros de boca
// é uma pessoa, não o cabeçalho.
// ---------------------------------------------------------------------------

import {
  AnimationClip,
  AnimationMixer,
  Box3,
  Material,
  MathUtils,
  Mesh,
  Object3D,
  Vector3,
} from 'three';
import { GLTFLoader, type GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js';

import { contarTriangulos } from './malha';

/**
 * A convenção da cena, escrita uma vez. Toda importação é medida contra ela, e é
 * ela que este projeto não negocia com o ativo que chega.
 */
// recorte:inicio convencao-da-cena-declarada
export const CONVENCAO_DA_CENA = {
  unidade: 'metro',
  eixoVertical: 'Y',
  origemDaPeca: 'no ponto em que ela toca a superfície de apoio',
  frente: '+Z',
} as const;
// recorte:fim convencao-da-cena-declarada

// recorte:inicio escala-nao-se-digita
/**
 * O que quem importa precisa declarar, porque o arquivo não responde.
 *
 * `maiorLadoRealM` é a medida do objeto no mundo, em metros, obtida por quem
 * conhece a peça. Dela sai o fator de escala, por divisão contra o que foi
 * medido no arquivo — e é por isso que o fator não é digitado: digitar 0.01
 * funciona até o dia em que chega um ativo em milímetro.
 */
export interface AjusteDeImportacao {
  readonly maiorLadoRealM: number;
// recorte:fim escala-nao-se-digita
  /** Giro em torno de X, em graus, para trazer o vertical do autor ao vertical da cena. */
  readonly giroEmXGraus: number;
  /** Assenta a base do objeto no zero local do pai. Derivado da caixa envolvente. */
  readonly assentarNaBase: boolean;
  /** Centra o objeto no plano horizontal do pai. Também derivado. */
  readonly centrarNoPlano: boolean;
}

export interface DimensoesM {
  readonly largura: number;
  readonly altura: number;
  readonly profundidade: number;
}

/**
 * O laudo da importação. Ele não existe para enfeitar o relatório: é o que
 * permite a alguém conferir, sem abrir o arquivo, que a peça na cena tem o
 * tamanho que a peça no mundo tem.
 */
// recorte:inicio diagnostico-como-chegou-como-ficou
export interface DiagnosticoDeImportacao {
  readonly arquivo: string;
  readonly comoChegou: DimensoesM;
  readonly comoFicou: DimensoesM;
  readonly fatorDeEscala: number;
  readonly giroEmXGraus: number;
  readonly deslocamentoCorrigidoM: Vector3;
  readonly divergencias: readonly string[];
}
// recorte:fim diagnostico-como-chegou-como-ficou

export interface AtivoImportado {
  readonly no: Object3D;
  readonly diagnostico: DiagnosticoDeImportacao;
  readonly animacoes: readonly AnimationClip[];
  readonly malhas: number;
  readonly triangulos: number;
  readonly materiais: readonly string[];
  /** Avança as animações do ativo. Chamado pelo laço, com o delta em segundos. */
  avancar(delta: number): void;
  /** Liga a animação de nome dado. Devolve falso quando ela não existe no arquivo. */
  tocar(nome: string): boolean;
}

function dimensoes(caixa: Box3): DimensoesM {
  const tamanho: Vector3 = new Vector3();
  caixa.getSize(tamanho);
  return { largura: tamanho.x, altura: tamanho.y, profundidade: tamanho.z };
}

function maiorLado(d: DimensoesM): number {
  return Math.max(d.largura, d.altura, d.profundidade);
}

/**
 * Traz o arquivo, mede o que chegou, aplica os ajustes e devolve o laudo.
 *
 * A ordem dos ajustes não é livre, e trocá-la põe o objeto no lugar errado sem
 * qualquer aviso. Gira-se primeiro, escala-se depois e só então se mede o
 * deslocamento residual: a caixa envolvente de um objeto deitado tem outra base
 * que não a do mesmo objeto em pé, e assentar antes de girar assenta a peça pelo
 * lado que vai virar lateral.
 */
export async function importarAtivo(
  url: string,
  ajuste: AjusteDeImportacao,
): Promise<AtivoImportado> {
  const gltf: GLTF = await new GLTFLoader().loadAsync(url);
  const no: Object3D = gltf.scene;
  no.name = url.split('/').pop() ?? 'ativo';

  const caixaBruta: Box3 = new Box3().setFromObject(no);
  const comoChegou: DimensoesM = dimensoes(caixaBruta);

  // 1. Orientação. Declarada, porque o formato de troca já fixa o vertical em Y
  //    e o arquivo não tem onde dizer que o autor usou outro.
  no.rotation.x = MathUtils.degToRad(ajuste.giroEmXGraus);
  no.updateMatrixWorld(true);

  // 2. Escala. Medida: o maior lado do que chegou contra o maior lado real.
  const medidoAposGirar: number = maiorLado(dimensoes(new Box3().setFromObject(no)));
  const fator: number = medidoAposGirar === 0 ? 1 : ajuste.maiorLadoRealM / medidoAposGirar;
  no.scale.setScalar(fator);
  no.updateMatrixWorld(true);

  // 3. Origem. Medida: onde a caixa envolvente ficou depois dos dois anteriores.
  const caixaAjustada: Box3 = new Box3().setFromObject(no);
  const centro: Vector3 = new Vector3();
  caixaAjustada.getCenter(centro);
  const correcao: Vector3 = new Vector3(
    ajuste.centrarNoPlano ? -centro.x : 0,
    ajuste.assentarNaBase ? -caixaAjustada.min.y : 0,
    ajuste.centrarNoPlano ? -centro.z : 0,
  );
  no.position.add(correcao);
  no.updateMatrixWorld(true);

  const comoFicou: DimensoesM = dimensoes(new Box3().setFromObject(no));

  let malhas: number = 0;
  let triangulos: number = 0;
  const materiais: Set<string> = new Set<string>();
  no.traverse((filho: Object3D) => {
    if (filho instanceof Mesh) {
      malhas += 1;
      triangulos += contarTriangulos(filho.geometry);
      const material: Material | Material[] = filho.material;
      for (const item of Array.isArray(material) ? material : [material]) {
        materiais.add(item.name === '' ? '(sem nome)' : item.name);
      }
    }
  });

  const mistura: AnimationMixer = new AnimationMixer(no);

  return {
    no,
    diagnostico: {
      arquivo: url,
      comoChegou,
      comoFicou,
      fatorDeEscala: fator,
      giroEmXGraus: ajuste.giroEmXGraus,
      deslocamentoCorrigidoM: correcao,
      divergencias: apontarDivergencias(comoChegou, fator, ajuste, correcao),
    },
    animacoes: gltf.animations,
    malhas,
    triangulos,
    materiais: [...materiais],
    avancar(delta: number): void {
      mistura.update(delta);
    },
    tocar(nome: string): boolean {
      const trilha: AnimationClip | undefined = gltf.animations.find((a) => a.name === nome);
      if (trilha === undefined) {
        return false;
      }
      mistura.clipAction(trilha).play();
      return true;
    },
  };
}

/**
 * Nomeia, em uma frase por item, cada divergência entre o que chegou e a
 * convenção da cena.
 *
 * O critério de divergente é de tolerância, e não de igualdade: um fator de
 * 0,998 é ruído de exportação e não interessa a ninguém. Os limiares abaixo são
 * frouxos de propósito — a lista existe para acusar troca de unidade e de eixo,
 * que erram por ordens de grandeza, e não para reclamar de milímetro.
 */
function apontarDivergencias(
  comoChegou: DimensoesM,
  fator: number,
  ajuste: AjusteDeImportacao,
  correcao: Vector3,
): string[] {
  const divergencias: string[] = [];

  if (fator < 0.5 || fator > 2) {
    const unidadesPorMetro: number = 1 / fator;
    divergencias.push(
      `Unidade divergente: o arquivo mede ${maiorLado(comoChegou).toFixed(2)} para uma peça de ` +
        `${ajuste.maiorLadoRealM.toFixed(2)} m, o que dá ${unidadesPorMetro.toFixed(0)} unidades ` +
        `por metro. O formato de troca fixa metro, e o exportador não obedeceu.`,
    );
  }

  if (ajuste.giroEmXGraus !== 0) {
    divergencias.push(
      `Eixo vertical divergente: o ativo veio com o vertical em outro eixo, e foi girado ` +
        `${ajuste.giroEmXGraus} graus em X para o vertical da cena ser ` +
        `${CONVENCAO_DA_CENA.eixoVertical}.`,
    );
  }

  if (correcao.length() > 0.01) {
    divergencias.push(
      `Origem divergente: a peça vinha deslocada ${correcao.length().toFixed(2)} m da própria ` +
        `base, e a correção a assentou na superfície de apoio.`,
    );
  }

  if (divergencias.length === 0) {
    divergencias.push('Nenhuma divergência acima da tolerância: o ativo chegou na convenção.');
  }

  return divergencias;
}

/** As linhas do laudo, para o relatório da página e para a conferência em tutoria. */
export function linhasDoAtivo(ativo: AtivoImportado): string[] {
  const d: DiagnosticoDeImportacao = ativo.diagnostico;
  const cm = (v: number): string => (v * 100).toFixed(1);
  return [
    `Arquivo: ${d.arquivo}`,
    `Como chegou: ${d.comoChegou.largura.toFixed(2)} x ${d.comoChegou.altura.toFixed(2)} x ` +
      `${d.comoChegou.profundidade.toFixed(2)} unidades do arquivo`,
    `Como ficou: ${cm(d.comoFicou.largura)} x ${cm(d.comoFicou.altura)} x ` +
      `${cm(d.comoFicou.profundidade)} cm`,
    `Fator de escala aplicado: ${d.fatorDeEscala.toFixed(4)} (medido, não digitado)`,
    `Conteúdo: ${ativo.malhas} malhas, ${ativo.triangulos} triângulos, ` +
      `${ativo.materiais.length} materiais (${ativo.materiais.join(', ')})`,
    `Animações no arquivo: ${
      ativo.animacoes.length === 0
        ? 'nenhuma'
        : ativo.animacoes.map((a) => `${a.name} (${a.duration.toFixed(1)} s)`).join(', ')
    }`,
    ...d.divergencias,
  ];
}

1.3.2 O que é medido e o que é declarado

A divisão que sustenta este arquivo tem duas colunas, e confundi-las é o defeito mais comum da importação feita às pressas.

Do lado medido ficam o fator de escala e o assentamento na base. Os dois saem da caixa envolvente do que chegou, por divisão e por subtração. Não há como errá-los por distração, e trocar o ativo por outro os recalcula sozinho.

Do lado declarado ficam a orientação e o tamanho real da peça. O arquivo não os carrega, e não tem onde carregá-los. Quem sabe que a morsa tem 18 centímetros de boca é uma pessoa, e essa pessoa precisa escrever o número em algum lugar.

Repare no que isso evita. Digitar 0.01 como fator de escala funciona até o dia em que chega um ativo em milímetro, e nesse dia o defeito é silencioso: a peça aparece dez vezes maior e alguém a corrige à mão, num terceiro lugar.

1.3.3 A ordem dos ajustes, que não é livre

Gira-se primeiro, escala-se depois, e só então se mede o deslocamento residual.

A razão é geométrica e cabe em uma frase. A caixa envolvente de um objeto deitado tem outra base que não a do mesmo objeto em pé. Assentar antes de girar assenta a peça pelo lado que vai virar lateral, e o resultado é um objeto flutuando ou enterrado — sem erro, sem aviso, e com todos os números do laudo parecendo corretos.

1.3.4 O laudo, e por que ele existe

Cada importação devolve um laudo com o que chegou, o que ficou e o que foi corrigido. Ele não é enfeite de relatório.

O laudo é o que permite a alguém conferir, sem abrir o arquivo, que a peça na cena tem o tamanho que a peça no mundo tem. Na tutoria, é a primeira coisa a pedir: um grupo que importou e não sabe dizer o fator aplicado não importou, arrastou.

A tolerância do laudo é frouxa de propósito. Um fator de 0,998 é ruído de exportação e não interessa a ninguém; a lista existe para acusar troca de unidade e de eixo, que erram por ordens de grandeza.

1.3.5 Onde é fácil errar, e como conferir

O erro previsível aqui é a animação com relógio próprio.

A trilha que veio dentro do arquivo é avançada pelo mesmo intervalo que move o resto da cena. Fosse por um relógio dela, andaria mais rápido na máquina que desenha mais rápido, e a peça importada passaria a viver noutro tempo. O sintoma aparece só quando alguém compara dois computadores, o que raramente acontece antes da apresentação.

A conferência do ajuste é de olho e leva segundos: a morsa tem de encostar no tampo, e não atravessá-lo nem pairar sobre ele. A conferência do laudo é de leitura, e ela é a que a tutoria cobra.

1.4 Tarefa 2: Tratar repetição como repetição

206 parafusos idênticos custam 206 conversas com a placa, ou uma só — e a diferença não aparece na contagem de triângulos.

Enunciado da tarefa

Uma cena com quatrocentos parafusos pode custar quatrocentas chamadas de desenho ou uma só. Objetos repetidos passam a ser desenhados como repetição, e os que existem para serem vistos de longe ganham versão simplificada.

A tarefa fecha quando a cena completa do domínio escolhido está montada com esses dois recursos aplicados onde eles compensam, e com o critério de onde compensa escrito. Simplificar o objeto que fica sempre perto do olho é trabalho jogado fora.

src/bancada/content/repeticao.ts
// ---------------------------------------------------------------------------
// Repetição tratada como repetição, e detalhe cobrado por distância.
//
// Os dois recursos deste arquivo resolvem problemas diferentes e são
// confundidos o tempo todo. Instanciar ataca a CHAMADA DE DESENHO: duzentos e
// vinte parafusos idênticos são duzentas e vinte conversas com a placa, e cada
// conversa custa mesmo quando o objeto é minúsculo. Nível de detalhe ataca o
// TRIÂNGULO: o que está longe não entrega ao olho a densidade que tem, e paga
// por ela do mesmo jeito.
//
// Quem confunde os dois simplifica o parafuso — que já era barato em triângulo e
// caro em chamada — e instancia o que aparece uma vez só. Os dois trabalhos
// somem no ruído da medição, e o quadro continua estourado.
//
// O critério deste arquivo, e ele é uma frase: instancia-se o que se repete, e
// simplifica-se o que está longe do olho. A mesma engrenagem aparece nos dois
// lados dessa frase — a da bancada, que a mão vai apanhar, não recebe nível de
// detalhe nenhum; as vinte e quatro da prateleira do fundo recebem, e são a
// mesma malha. O que decide é a distância, não o objeto.
// ---------------------------------------------------------------------------

import {
  BufferGeometry,
  Camera,
  Group,
  InstancedMesh,
  Material,
  Matrix4,
  Object3D,
  Quaternion,
  Vector3,
} from 'three';
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import { SimplifyModifier } from 'three/examples/jsm/modifiers/SimplifyModifier.js';

import { contarTriangulos, contarVertices } from './malha';

/** Espaçamento entre parafusos ao longo da borda do tampo, em metros. */
export const ESPACAMENTO_DOS_PARAFUSOS: number = 0.02;

/** Distância, em metros, a partir da qual a prateleira do fundo troca de nível. */
export const DISTANCIA_DE_TROCA: number = 2.2;

/**
 * Posições ao longo do retângulo da borda de uma superfície, em coordenadas
 * locais dela. A conta é de perímetro dividido por espaçamento, e o número de
 * parafusos sai daí em vez de ser digitado — trocar o tampo por um maior passa a
 * acrescentar parafusos sozinho, que é o comportamento que se espera de uma
 * borda parafusada.
 */
export function disporNaBorda(
  largura: number,
  profundidade: number,
  espacamento: number,
  recuo: number,
  altura: number,
): Vector3[] {
  const x: number = largura / 2 - recuo;
  const z: number = profundidade / 2 - recuo;
  const porLado: number = Math.max(2, Math.floor((x * 2) / espacamento));
  const porTopo: number = Math.max(2, Math.floor((z * 2) / espacamento));
  const posicoes: Vector3[] = [];

  for (let i: number = 0; i < porLado; i += 1) {
    const t: number = -x + (i * (x * 2)) / (porLado - 1);
    posicoes.push(new Vector3(t, altura, -z));
    posicoes.push(new Vector3(t, altura, z));
  }
  for (let i: number = 1; i < porTopo - 1; i += 1) {
    const t: number = -z + (i * (z * 2)) / (porTopo - 1);
    posicoes.push(new Vector3(-x, altura, t));
    posicoes.push(new Vector3(x, altura, t));
  }

  return posicoes;
}

export interface CustoDaRepeticao {
  readonly objetos: number;
  readonly chamadasSemInstanciar: number;
  readonly chamadasInstanciando: number;
  readonly triangulosPorObjeto: number;
  readonly triangulosTotais: number;
}

// recorte:inicio matriz-escrita-uma-vez
/**
 * Monta um lote instanciado a partir de uma malha e de uma lista de posições.
 *
 * A matriz de cada cópia é escrita uma vez, na montagem, e não a cada quadro. O
 * aviso de que a matriz não muda mais é o que autoriza a biblioteca a mandar o
 * lote inteiro para a placa e não voltar a tocá-lo — e é ele que transforma
 * duzentas e vinte chamadas em uma.
 */
export function montarLoteInstanciado(
// recorte:fim matriz-escrita-uma-vez
  geometria: BufferGeometry,
  material: Material,
  posicoes: readonly Vector3[],
  nome: string,
): InstancedMesh {
  const lote: InstancedMesh = new InstancedMesh(geometria, material, posicoes.length);
  lote.name = nome;
  const matriz: Matrix4 = new Matrix4();
  const semGiro: Quaternion = new Quaternion();
  const semEscala: Vector3 = new Vector3(1, 1, 1);

  for (let i: number = 0; i < posicoes.length; i += 1) {
    const posicao: Vector3 | undefined = posicoes[i];
    if (posicao === undefined) {
      continue;
    }
    matriz.compose(posicao, semGiro, semEscala);
    lote.setMatrixAt(i, matriz);
  }
  lote.instanceMatrix.needsUpdate = true;
  return lote;
}

/**
 * O que a instanciação comprou, em número.
 *
 * A chamada de desenho é a unidade certa aqui, e não o triângulo: os triângulos
 * continuam os mesmos, um por um, e a placa os desenha igual. O que sai da conta
 * é a conversa — o custo fixo de preparar cada objeto, que independe de quão
 * pequeno ele seja e que é justamente o que faz a cena de mil parafusos
 * minúsculos engasgar sem que a contagem de triângulos assuste ninguém.
 */
export function custoDaRepeticao(
  geometria: BufferGeometry,
  quantidade: number,
): CustoDaRepeticao {
  const porObjeto: number = contarTriangulos(geometria);
  return {
    objetos: quantidade,
    chamadasSemInstanciar: quantidade,
    chamadasInstanciando: 1,
    triangulosPorObjeto: porObjeto,
    triangulosTotais: porObjeto * quantidade,
  };
}

export interface NivelSimplificado {
  readonly geometria: BufferGeometry;
  readonly triangulos: number;
  readonly vertices: number;
  /** Falso quando a ferramenta não entregou resultado utilizável e a malha original ficou. */
  readonly simplificou: boolean;
  readonly observacao: string;
}

/**
 * Simplifica uma malha pela ferramenta, e não à mão.
 *
 * Dois passos, e o primeiro é o que quase todo mundo esquece. A malha da oficina
 * duplica vértice em toda face, de propósito, para ter aresta viva. Para o
 * algoritmo de simplificação, vértices duplicados são pontos que não se tocam:
 * ele enxerga a peça como um monte de triângulos soltos e não tem que colapsar.
 * Unir os vértices coincidentes antes é o que devolve a ele uma superfície.
 *
 * Unir custa a aresta viva, e no nível distante isso é exatamente o que se quer
 * pagar: a três metros, ninguém distingue canto duro de canto macio, e a
 * diferença é toda em triângulo.
 *
 * O resultado é conferido. A ferramenta pode devolver malha vazia ou com
 * coordenada não finita em geometrias que ela não digere, e uma peça que some da
 * cena é um defeito muito pior do que um nível distante mais pesado que o ideal.
 */
export function simplificar(
  geometria: BufferGeometry,
  proporcaoRemovida: number,
): NivelSimplificado {
  const unida: BufferGeometry = mergeVertices(geometria);
  const aRemover: number = Math.floor(contarVertices(unida) * proporcaoRemovida);

  if (aRemover < 1) {
    return {
      geometria,
      triangulos: contarTriangulos(geometria),
      vertices: contarVertices(geometria),
      simplificou: false,
      observacao: 'malha pequena demais para valer a simplificação; o original ficou',
    };
  }

  const candidata: BufferGeometry = new SimplifyModifier().modify(unida, aRemover);
  const posicoes: ArrayLike<number> = candidata.getAttribute('position').array;
  let naoFinitos: number = 0;
  for (let i: number = 0; i < posicoes.length; i += 1) {
    if (!Number.isFinite(posicoes[i] ?? Number.NaN)) {
      naoFinitos += 1;
    }
  }

  if (contarTriangulos(candidata) === 0 || naoFinitos > 0) {
    candidata.dispose();
    return {
      geometria,
      triangulos: contarTriangulos(geometria),
      vertices: contarVertices(geometria),
      simplificou: false,
      observacao: 'a ferramenta não devolveu malha utilizável; o original ficou no lugar',
    };
  }

  // A normal precisa ser recalculada: os triângulos que sobraram têm outra
  // inclinação, e manter a normal antiga produz iluminação que não corresponde à
  // forma — o defeito é sutil e aparece como mancha, não como buraco.
  candidata.computeVertexNormals();
  candidata.computeBoundingSphere();
  candidata.computeBoundingBox();
  candidata.name = `${geometria.name}-simplificada`;

  return {
    geometria: candidata,
    triangulos: contarTriangulos(candidata),
    vertices: contarVertices(candidata),
    simplificou: true,
    observacao: 'simplificada pela ferramenta, com as normais recalculadas',
  };
}

export interface Prateleira {
  readonly no: Object3D;
  readonly quantidade: number;
  readonly triangulosPerto: number;
  readonly triangulosLonge: number;
  readonly nivel: NivelSimplificado;
  /** Escolhe o nível pela distância da câmera. Devolve verdadeiro quando está no nível distante. */
  atualizar(camera: Camera): boolean;
  linhas(): string[];
}

/**
 * A prateleira de peças de reposição do fundo da oficina: as duas técnicas
 * juntas, que é como elas aparecem em ambiente real.
 *
 * Cada nível é um lote instanciado inteiro, e a troca liga um e desliga o outro.
 * Alternar o objeto um a um, como faz o mecanismo pronto de nível de detalhe,
 * devolveria uma chamada de desenho por peça e desfaria a instanciação — as duas
 * técnicas se anulariam, e o ganho de uma pagaria a perda da outra.
 *
 * A troca é por distância da câmera ao centro da prateleira, e não por peça. A
 * prateleira tem meio metro de largura: a diferença de distância entre a peça
 * mais próxima e a mais distante dela não muda a decisão.
 */
export function montarPrateleira(
  geometria: BufferGeometry,
  material: Material,
  quantidade: number,
  proporcaoRemovida: number,
): Prateleira {
  const nivel: NivelSimplificado = simplificar(geometria, proporcaoRemovida);
  const posicoes: Vector3[] = [];
  const porFileira: number = 6;
  for (let i: number = 0; i < quantidade; i += 1) {
    const coluna: number = i % porFileira;
    const fileira: number = Math.floor(i / porFileira);
    posicoes.push(new Vector3(-0.25 + coluna * 0.1, 0.02 + fileira * 0.18, 0));
  }

  const perto: InstancedMesh = montarLoteInstanciado(
    geometria,
    material,
    posicoes,
    'reposicao-perto',
  );
  const longe: InstancedMesh = montarLoteInstanciado(
    nivel.geometria,
    material,
    posicoes,
    'reposicao-longe',
  );
  longe.visible = false;

  const no: Group = new Group();
  no.name = 'prateleira-de-reposicao';
  no.add(perto);
  no.add(longe);

  const centro: Vector3 = new Vector3();
  const daCamera: Vector3 = new Vector3();

  return {
    no,
    quantidade,
    triangulosPerto: contarTriangulos(geometria) * quantidade,
    triangulosLonge: nivel.triangulos * quantidade,
    nivel,
    atualizar(camera: Camera): boolean {
      no.getWorldPosition(centro);
      camera.getWorldPosition(daCamera);
      const distante: boolean = daCamera.distanceTo(centro) > DISTANCIA_DE_TROCA;
      perto.visible = !distante;
      longe.visible = distante;
      return distante;
    },
    linhas(): string[] {
      const economia: number =
        this.triangulosPerto === 0
          ? 0
          : ((this.triangulosPerto - this.triangulosLonge) / this.triangulosPerto) * 100;
      return [
        `Peças de reposição na prateleira: ${quantidade}, em dois níveis instanciados`,
        `Nível de perto: ${contarTriangulos(geometria)} triângulos por peça, ` +
          `${this.triangulosPerto} no total`,
        `Nível de longe: ${nivel.triangulos} triângulos por peça, ${this.triangulosLonge} no total ` +
          `(${economia.toFixed(0)}% a menos)`,
        `Troca a ${DISTANCIA_DE_TROCA.toFixed(1)} m da câmera — ${nivel.observacao}`,
        'A engrenagem da bancada é a mesma malha e não recebe nível nenhum: ela fica a meio metro ' +
          'do olho, e simplificar o que está perto é trabalho jogado fora.',
      ];
    },
  };
}

/** As linhas do lote de parafusos, para o relatório e para a tutoria. */
export function linhasDaRepeticao(custo: CustoDaRepeticao): string[] {
  return [
    `Parafusos na borda: ${custo.objetos}, a cada ` +
      `${(ESPACAMENTO_DOS_PARAFUSOS * 100).toFixed(0)} cm de perímetro`,
    `Chamadas de desenho sem instanciar: ${custo.chamadasSemInstanciar}`,
    `Chamadas de desenho instanciando: ${custo.chamadasInstanciando}`,
    `Triângulos: ${custo.triangulosPorObjeto} por parafuso, ${custo.triangulosTotais} no total — ` +
      'e este número não muda com a instanciação, porque ela não é sobre triângulo',
  ];
}

1.4.1 Duas técnicas, dois problemas diferentes

Instanciar e simplificar são confundidos o tempo todo, e o motivo é que ambos aparecem sob o rótulo de otimização de cena.

Instanciar ataca a chamada de desenho. Cada objeto desenhado é uma conversa com a placa, com custo fixo que independe do tamanho do objeto. É por isso que uma cena de mil parafusos minúsculos engasga sem que a contagem de triângulos assuste ninguém.

Simplificar ataca o triângulo. O que está longe não entrega ao olho a densidade que tem, e paga por ela do mesmo jeito em todo quadro.

Quem troca os dois simplifica o parafuso, que já era barato em triângulo, e instancia o que aparece uma vez só. Os dois trabalhos somem no ruído da medição, e o quadro continua estourado.

1.4.2 A borda parafusada, e o número que ela produz

O tampo mede 1,4 por 0,8 metro, e os parafusos ficam a cada 2 centímetros de perímetro. Dessa conta saem 206 cabeças sextavadas, e o número não é digitado: trocar o tampo por um maior acrescenta parafusos sozinho.

Cada cabeça tem 24 triângulos, o que dá 4.944 no lote inteiro. Esse número não muda com a instanciação, e dizer isso em voz alta em aula economiza meia dúvida. O que muda é a coluna ao lado: 206 chamadas de desenho passam a ser uma.

O parafuso é a cabeça e nada mais. O corpo dele está dentro da madeira, e modelar o que ninguém vê é orçamento gasto em nada.

1.4.3 A mesma engrenagem, duas decisões opostas

A prateleira do fundo guarda 24 peças de reposição. Elas são a mesma malha da engrenagem que está sobre a bancada, e essa identidade é o argumento inteiro.

A engrenagem da bancada fica a meio metro do olho e a mão vai apanhá-la. Ela não recebe nível de detalhe nenhum. As 24 da prateleira ficam a quase três metros, e recebem. O que decide é a distância.

A ferramenta corta a malha de 224 para 82 triângulos, uma redução de 63% que a três metros ninguém enxerga. Nas 24 peças, isso são 5.376 triângulos que viram 1.968.

1.4.4 Unir vértices antes de simplificar

Por que o algoritmo de simplificação não funciona direto na nossa malha?

A malha da oficina duplica vértice em toda face, de propósito, para ter aresta viva. Para o algoritmo, vértices duplicados são pontos que não se tocam: ele enxerga a peça como um monte de triângulos soltos e não encontra aresta nenhuma para colapsar.

Unir os vértices coincidentes antes devolve a ele uma superfície. Unir custa a aresta viva, e no nível distante isso é exatamente o que se quer pagar — a três metros ninguém distingue canto duro de canto macio, e a diferença é toda em triângulo.

Depois de simplificar, as normais são recalculadas. Os triângulos que sobraram têm outra inclinação, e manter a normal antiga produz iluminação que não corresponde à forma. O defeito é sutil e aparece como mancha, não como buraco.

1.4.5 Onde é fácil errar, e como conferir

O erro previsível desta tarefa é usar o mecanismo pronto de nível de detalhe por objeto sobre um lote instanciado.

Ele alterna a malha peça por peça, e ao fazer isso devolve uma chamada de desenho por peça. As duas técnicas se anulam, o ganho de uma paga a perda da outra, e o relatório mostra o mesmo número de antes. Aqui cada nível é um lote inteiro, e a troca liga um e desliga o outro.

A conferência é o botão que aproxima a câmera da prateleira. A contagem de triângulos sobe ao chegar perto e desce ao afastar, e o número na tela é a prova de que a troca acontece.

A ferramenta pode falhar, e a falha precisa ser tratada. O algoritmo devolve malha vazia ou com coordenada não finita em geometrias que ele não digere. Uma peça que some da cena é defeito mais grave do que um nível distante mais pesado que o ideal, e por isso o resultado é conferido antes de entrar. Quando não passa, a malha original fica no lugar e a observação vai para o relatório — em vez de sumir do relatório junto com a peça.

1.5 Tarefa 3: Medir o custo em número

“Está rodando bem” é uma frase sem sujeito, e é a resposta que a tutoria recusa.

Enunciado da tarefa

Impressão sobre desempenho não sustenta decisão alguma. Triângulos e chamadas de desenho passam a ser contados, e o número passa a caber num teto declarado antes da medição.

O que fica pronto é a medição registrada junto da máquina em que foi obtida, tomando a máquina mais modesta prevista como referência. Um número sem a máquina ao lado não permite a ninguém repetir a conta.

src/bancada/content/medicao.ts
// ---------------------------------------------------------------------------
// A medição, e a máquina ao lado do número.
//
// Este arquivo separa duas contas que costumam ser dadas como a mesma. O CENSO é
// estático: percorre a árvore da cena e soma o que existe, independentemente de
// a câmera estar olhando. Ele responde "quanto esta cena pesa". A LEITURA do
// orçamento é dinâmica e vem do módulo do laço: conta o que a placa desenhou no
// quadro que acabou de sair. Ela responde "quanto custou o que estava à vista".
//
// A diferença entre os dois números não é erro: é o descarte do que ficou fora
// do campo de visão. Um censo alto com leitura baixa é uma cena grande vista por
// um buraco de fechadura, e vira problema no instante em que alguém gira a
// cabeça — o que, num visor, acontece o tempo todo.
//
// A máquina viaja junto do número por uma razão prosaica: "roda a sessenta" é
// uma frase sem sujeito. Sessenta na máquina de quem tem placa dedicada e vinte
// e dois na do laboratório é o mesmo ambiente, e a decisão que se toma a partir
// dos dois é oposta.
// ---------------------------------------------------------------------------

import { InstancedMesh, Material, Mesh, Object3D, WebGLRenderer } from 'three';

import { contarTriangulos } from './malha';
import type { LeituraDoOrcamento } from '../core/orcamento';

/**
 * A máquina contra a qual esta cena é orçada. É a mais modesta do parque
 * previsto, e não a do professor.
 *
 * Medir na máquina mais forte da sala produz um teto que a turma não alcança, e
 * o resultado prático é o ambiente estourando justamente nos computadores em que
 * ele vai ser apresentado.
 */
// recorte:inicio maquina-de-referencia-declarada
export const MAQUINA_DE_REFERENCIA = {
  descricao: 'desktop x64 do laboratório, com vídeo integrado e sem placa dedicada',
  navegador: 'Chrome com WebGL 2 habilitado',
  observacao:
    'é o limite duro do parque: nenhum número deste ambiente pode depender de placa dedicada',
} as const;
// recorte:fim maquina-de-referencia-declarada

// recorte:inicio censo-conta-lote-nao-instancia
export interface Censo {
  readonly malhas: number;
  readonly lotesInstanciados: number;
  readonly instancias: number;
  readonly triangulos: number;
  readonly materiaisDistintos: number;
  /** Piso de chamadas de desenho: uma por malha, uma por lote — não uma por instância. */
  readonly chamadasPrevistas: number;
}
// recorte:fim censo-conta-lote-nao-instancia

/**
 * Percorre a árvore e soma o que existe.
 *
 * O lote instanciado conta como UMA chamada e como o produto de triângulos por
 * instância. É essa assimetria que a instanciação compra, e é ela que o censo
 * precisa expor separada — somar tudo num número só esconderia exatamente a
 * decisão que o módulo ensina a tomar.
 */
export function censoDaCena(raiz: Object3D): Censo {
  let malhas: number = 0;
  let lotes: number = 0;
  let instancias: number = 0;
  let triangulos: number = 0;
  const materiais: Set<string> = new Set<string>();

  raiz.traverse((no: Object3D) => {
    if (!(no instanceof Mesh)) {
      return;
    }
    const porMalha: number = contarTriangulos(no.geometry);
    if (no instanceof InstancedMesh) {
      lotes += 1;
      instancias += no.count;
      triangulos += porMalha * no.count;
    } else {
      malhas += 1;
      triangulos += porMalha;
    }
    const material: Material | Material[] = no.material;
    for (const item of Array.isArray(material) ? material : [material]) {
      materiais.add(item.uuid);
    }
  });

  return {
    malhas,
    lotesInstanciados: lotes,
    instancias,
    triangulos,
    materiaisDistintos: materiais.size,
    chamadasPrevistas: malhas + lotes,
  };
}

/**
 * O que o navegador conta sobre a placa, quando conta.
 *
 * A extensão que revela o nome do adaptador é opcional e frequentemente
 * bloqueada por privacidade, e nesse caso a resposta honesta é dizer que não se
 * sabe. Inventar um nome de placa num relatório de desempenho é pior do que não
 * ter nome nenhum: quem lê passa a acreditar que a medição é reproduzível.
 */
export function identificarPlaca(renderer: WebGLRenderer): string {
  const contexto: WebGLRenderingContext | WebGL2RenderingContext = renderer.getContext();
  const extensao: unknown = contexto.getExtension('WEBGL_debug_renderer_info');
  if (extensao === null || typeof extensao !== 'object') {
    return 'placa não identificada (o navegador não expõe o nome do adaptador)';
  }
  const chave: unknown = (extensao as Record<string, unknown>)['UNMASKED_RENDERER_WEBGL'];
  if (typeof chave !== 'number') {
    return 'placa não identificada (o navegador não expõe o nome do adaptador)';
  }
  const nome: unknown = contexto.getParameter(chave);
  return typeof nome === 'string' && nome !== '' ? nome : 'placa não identificada';
}

/**
 * As linhas da medição: o censo, a leitura e a máquina, na mesma folha.
 *
 * Estar na mesma folha é o requisito, e não detalhe de apresentação. Número de
 * desempenho sem a máquina ao lado não permite a ninguém repetir a conta, e um
 * relatório que não se repete não é medição — é impressão com casas decimais.
 */
export function linhasDaMedicao(
  censo: Censo,
  leitura: LeituraDoOrcamento,
  placa: string,
): string[] {
  const linhas: string[] = [
    `Máquina de referência: ${MAQUINA_DE_REFERENCIA.descricao}`,
    `Navegador previsto: ${MAQUINA_DE_REFERENCIA.navegador}`,
    `Placa relatada por este aparelho: ${placa}`,
    `Teto declarado por quadro: ${leitura.tetoMs.toFixed(1)} ms`,
    '',
    `Censo estático da cena: ${censo.triangulos} triângulos em ${censo.malhas} malhas soltas e ` +
      `${censo.lotesInstanciados} lotes instanciados (${censo.instancias} instâncias)`,
    `Chamadas de desenho previstas pelo censo: ${censo.chamadasPrevistas}`,
    `Materiais distintos: ${censo.materiaisDistintos}`,
  ];

  if (leitura.quadrosMedidos === 0) {
    linhas.push('', 'Ainda sem quadros medidos: a leitura dinâmica entra depois do primeiro quadro.');
    return linhas;
  }

  const folga: number = leitura.tetoMs - leitura.intervaloMedioMs;
  linhas.push(
    '',
    `Medido em ${leitura.quadrosMedidos} quadros: ${leitura.chamadasDeDesenho} chamadas e ` +
      `${leitura.triangulos} triângulos efetivamente desenhados`,
    `Intervalo médio: ${leitura.intervaloMedioMs.toFixed(1)} ms — ` +
      `${folga >= 0 ? `${folga.toFixed(1)} ms de folga` : `${(-folga).toFixed(1)} ms acima do teto`}`,
    `Pior intervalo da janela: ${leitura.piorIntervaloMs.toFixed(1)} ms`,
    `Diferença entre censo e medição: ${censo.triangulos - leitura.triangulos} triângulos ficaram ` +
      'fora do campo de visão neste quadro, e voltam assim que a câmera girar',
  );
  return linhas;
}

1.5.1 Censo e leitura são contas diferentes

O censo é estático. Ele percorre a árvore da cena e soma o que existe, esteja ou não à vista. Responde quanto esta cena pesa.

A leitura é dinâmica e vem do laço. Conta o que a placa desenhou no quadro que acabou de sair. Responde quanto custou o que estava no campo de visão.

A diferença entre os dois é o descarte do que ficou fora do enquadramento. Um censo alto com leitura baixa é uma cena grande vista por um buraco de fechadura, e vira problema no instante em que alguém gira a cabeça — o que, dentro de um visor, acontece o tempo todo.

O censo também separa o lote instanciado do resto. Um lote conta como uma chamada e como o produto de triângulos por instância. Somar tudo num número só esconderia justamente a decisão que este módulo ensina a tomar.

1.5.2 O envelope, agora com o que gastá-lo

Cada quadro é um envelope de onze milissegundos, e o que não couber nele não fica para depois: some.

O envelope foi declarado antes de existir conteúdo pesado a que atribuir culpa. Era um teto sem nada contra o que ser gasto. Agora há: mais de 7.000 triângulos, um ativo importado com animação própria e dois lotes instanciados.

O teto adotado continua sendo o do visor, e não o do computador de mesa. A mesma cena vai subir nos dois, e orçar pelo aparelho mais folgado garante estouro no outro — justamente aquele em que o estouro produz mal-estar físico, e não só uma animação menos lisa.

1.5.3 A máquina viaja junto do número

Sessenta quadros por segundo na máquina de quem tem placa dedicada e vinte e dois na do laboratório são o mesmo ambiente. A decisão que se toma a partir de cada um é oposta.

A máquina de referência declarada aqui é a mais modesta do parque previsto: o computador de mesa do laboratório, com vídeo integrado e sem placa dedicada. Medir na máquina mais forte da sala produz um teto que a turma não alcança, e o ambiente estoura exatamente nos computadores em que vai ser apresentado.

O nome da placa é pedido ao navegador, e o navegador frequentemente recusa por privacidade. Quando recusa, o relatório diz que não sabe. Inventar um nome de placa é pior do que não ter nome nenhum: quem lê passa a acreditar que a medição é reproduzível.

1.5.4 Onde é fácil errar, e como conferir

O erro previsível é medir a média e parar aí.

A média dilui o engasgo até ele desaparecer do número, e o engasgo é o que o corpo sente. Por isso a leitura traz também o pior intervalo da janela observada e a proporção de quadros acima do teto. Uma cena com média confortável e 12% dos quadros estourados é desconfortável no visor, e o relatório precisa dizer isso.

A conferência é o botão de medir com a câmera em duas posições: junto à bancada e junto à prateleira. Se os dois números forem iguais, alguma coisa não está sendo contada.

1.6 Por dentro da implementação

As seções acima explicam as decisões. Esta explica o percurso: qual função chama qual, com que valor, e o que volta. A execução de referência é a página deste módulo com a morsa chegando, e os números citados abaixo são os que ela imprimiu.

alteração o que ela passou a fazer
importacao-medida um arquivo autorado fora entra na cena com escala, orientação e origem resolvidas na chegada
laudo-de-divergencias cada desvio entre o que chegou e a convenção vira uma frase, por tolerância
censo-estatico a árvore inteira passa a ser contada, separando malha solta de lote
medicao-com-maquina o número de desempenho passa a sair acompanhado do aparelho que o produziu

A costura com o que já existia cabe em duas frases. importarAtivo() devolve um nó, e quem o pendura é cena.bancada.add(), o mesmo agrupador que montarCena() criou no módulo da estrutura — nenhum nó é reparentado aqui. E censoDaCena() percorre cena.sala, isto é, a árvore que vestirCena() acabou de vestir, sem saber de nada que este módulo tenha acrescentado.

1.6.1 A chegada, medida e não digitada

O cenário é a morsa. importarAtivo('/ativos/morsa.gltf', ...) recebe maiorLadoRealM 0,18 e giroEmXGraus −90; a caixa envolvente do que veio mede 18,00 × 12,00 × 17,00 unidades do arquivo, e o objeto sai da função medindo 18,0 × 17,0 × 12,0 cm.

flowchart TD
  ini["05_demo.ts chama importarAtivo('/ativos/morsa.gltf', ajuste)"] --> carga["new GLTFLoader().loadAsync(url)"]
  carga --> bruta["new Box3().setFromObject(no)"]
  bruta --> chegou["dimensoes(caixaBruta) devolve comoChegou: 18.00 x 12.00 x 17.00"]
  chegou --> giro["1. orientacao DECLARADA: MathUtils.degToRad(ajuste.giroEmXGraus), -90 graus em X"]
  giro --> remede["2. escala MEDIDA: maiorLado(dimensoes(new Box3().setFromObject(no))) devolve 18.00"]
  remede --> zero{"medidoAposGirar === 0"}
  zero -->|"true"| um["fator = 1: sem medida nao ha divisao possivel"]
  zero -->|"false"| div["fator = ajuste.maiorLadoRealM / medidoAposGirar = 0.18 / 18.00 = 0.0100"]
  um --> aplica["no.scale.setScalar(fator)"]
  div --> aplica
  aplica --> res["3. origem MEDIDA: caixaAjustada.getCenter(centro) sobre a caixa ja girada e escalada"]
  res --> base{"ajuste.assentarNaBase"}
  base -->|"true"| sobe["y da correcao = -caixaAjustada.min.y"]
  base -->|"false"| nada["y da correcao = 0"]
  sobe --> plano{"ajuste.centrarNoPlano"}
  nada --> plano
  plano -->|"true"| centra["x e z da correcao = -centro.x e -centro.z"]
  plano -->|"false"| fica["x e z da correcao = 0"]
  centra --> soma["no.position.add(correcao), deslocamento de 0.30 m"]
  fica --> soma
  soma --> ficou["dimensoes(new Box3().setFromObject(no)) devolve comoFicou: 18.0 x 17.0 x 12.0 cm"]
  ficou --> censo["no.traverse conta o que chegou"]
  censo --> emalha{"filho instanceof Mesh"}
  emalha -->|"sim"| conta["contarTriangulos(filho.geometry) e materiais.add(item.name)"]
  emalha -->|"nao"| segue["nada a somar neste no"]
  conta --> censo
  segue --> censo
  censo -->|"arvore percorrida"| total["4 malhas, 48 triangulos, 2 materiais: FerroFundido e AcoUsinado"]
  total --> mistura["new AnimationMixer(no), que avancar(delta) usara no laco"]
  mistura --> saida["AtivoImportado com no, diagnostico, animacoes e avancar()"]

O fator aparece numa divisão só: ajuste.maiorLadoRealM / medidoAposGirar, com 0,18 sobre 18,00, que dá 0,0100. Repare de onde vem o denominador — maiorLado(dimensoes(...)) é chamado depois do giro, sobre uma caixa remedida, e não sobre a caixa bruta guardada em comoChegou. É essa segunda medição que faz a ordem dos três ajustes ser obrigatória em vez de recomendada.

sequenceDiagram
  autonumber
  participant demo as demos/05_demo.ts
  participant ativos as content/ativos.ts
  participant loader as GLTFLoader
  participant malha as content/malha.ts
  demo->>ativos: importarAtivo('/ativos/morsa.gltf', maiorLadoRealM 0.18, giroEmXGraus -90)
  ativos->>loader: loadAsync('/ativos/morsa.gltf')
  loader-->>ativos: GLTF com scene, 4 malhas e a trilha AbrirFechar
  ativos->>ativos: dimensoes(caixaBruta)
  ativos-->>ativos: comoChegou 18.00 x 12.00 x 17.00 unidades do arquivo
  ativos->>ativos: MathUtils.degToRad(-90) e no.updateMatrixWorld(true)
  ativos->>ativos: maiorLado(dimensoes(caixa apos girar))
  ativos-->>ativos: 18.00, o maior lado ja em pe
  ativos->>ativos: no.scale.setScalar(0.18 / 18.00)
  ativos-->>ativos: fator 0.0100
  ativos->>ativos: caixaAjustada.getCenter(centro) e no.position.add(correcao)
  ativos-->>ativos: correcao de 0.30 m, medida depois de girar e escalar
  loop um no por vez, na arvore do ativo
    ativos->>malha: contarTriangulos(filho.geometry)
    malha-->>ativos: triangulos daquela malha
  end
  ativos-->>demo: AtivoImportado: 48 triangulos, comoFicou 18.0 x 17.0 x 12.0 cm
  demo->>demo: cena.bancada.add(importado.no)

A troca de eixo aparece no resultado sem estar escrita em lugar nenhum: 12,00 e 17,00 chegam nessa ordem e saem trocadas, porque o giro de −90° em X leva a profundidade para a altura. Quem confere o laudo em tutoria tem aí a evidência de que o giro aconteceu.

A guarda medidoAposGirar === 0, e o que a divisão nua custaria. A alternativa é dividir direto. O custo é um objeto vazio ou degenerado, que dá caixa de lado zero e fator infinito: escalar por infinito não lança exceção, e a peça some da cena sem uma linha de erro. Com a guarda, o fator vira 1 e o objeto fica visível no tamanho em que veio — errado, mas à vista.

Os quatro campos de AjusteDeImportacao, e o que um só custaria. A alternativa é receber uma matriz de correção pronta, o que é mais curto e é o que a maioria dos exemplos faz. O custo é que a matriz mistura numa coisa só o que foi medido e o que foi declarado: quem trocar o ativo por outro precisa recalcular a matriz inteira à mão, em vez de trocar um número de dezoito centímetros.

O contador de malhas dentro de importarAtivo(), e o que adiá-lo custaria. A alternativa é deixar censoDaCena() responder também por isso, já que ele percorre a árvore de qualquer jeito. O custo é perder a separação entre o que o ativo trouxe — 4 malhas, 48 triângulos, 2 materiais — e o que a cena tem no total: com um número só, ninguém consegue dizer se o orçamento estourou por causa do arquivo importado ou do resto.

1.6.2 As três divergências, por tolerância

O cenário é o mesmo, e a saída é a que a página imprime nas três últimas linhas do laudo: unidade divergente com 100 unidades por metro, eixo vertical divergente com o giro de −90°, e origem divergente com 0,30 m de deslocamento.

flowchart TD
  ini["importarAtivo() monta o diagnostico e chama apontarDivergencias(comoChegou, fator, ajuste, correcao)"] --> uni{"fator < 0.5 || fator > 2"}
  uni -->|"0.0100, fora da faixa"| conta["unidadesPorMetro = 1 / fator = 100"]
  uni -->|"dentro da faixa"| pulaUni["nada a acusar sobre unidade"]
  conta --> msgUni["'Unidade divergente: o arquivo mede 18.00 para uma peca de 0.18 m, o que da 100 unidades por metro'"]
  msgUni --> eixo{"ajuste.giroEmXGraus !== 0"}
  pulaUni --> eixo
  eixo -->|"-90"| msgEixo["'Eixo vertical divergente: girado -90 graus em X para o vertical da cena ser Y', lido de CONVENCAO_DA_CENA.eixoVertical"]
  eixo -->|"0"| pulaEixo["o autor ja usava o vertical da cena"]
  msgEixo --> org{"correcao.length() > 0.01"}
  pulaEixo --> org
  org -->|"0.30 m"| msgOrg["'Origem divergente: a peca vinha deslocada 0.30 m da propria base'"]
  org -->|"abaixo da tolerancia"| pulaOrg["ruido de exportacao, que a lista nao reclama"]
  msgOrg --> vazia{"divergencias.length === 0"}
  pulaOrg --> vazia
  vazia -->|"true"| ok["'Nenhuma divergencia acima da tolerancia: o ativo chegou na convencao'"]
  vazia -->|"false, tres itens"| lista["as tres frases, uma por divergencia"]
  ok --> laudo["linhasDoAtivo(ativo) monta a folha"]
  lista --> laudo
  laudo --> cm["cm(v) converte comoFicou para centimetro; comoChegou fica na unidade do arquivo"]
  cm --> fim["nove linhas na tela, terminadas pelas divergencias"]

Os três testes de apontarDivergencias() são independentes, e nenhum interrompe o seguinte: a lista sai com três itens porque as três condições deram verdadeiro no mesmo ativo. O último teste existe para o caso oposto — sem ele, um ativo correto produziria uma folha que termina em branco, e branco lê-se como “ninguém conferiu”.

sequenceDiagram
  autonumber
  participant demo as demos/05_demo.ts
  participant ativos as content/ativos.ts
  participant tela as div id ativo
  ativos->>ativos: apontarDivergencias(comoChegou, 0.0100, ajuste, correcao)
  ativos-->>ativos: 'Unidade divergente: 100 unidades por metro'
  ativos-->>ativos: 'Eixo vertical divergente: girado -90 graus em X'
  ativos-->>ativos: 'Origem divergente: deslocada 0.30 m da propria base'
  ativos-->>demo: DiagnosticoDeImportacao com tres divergencias
  demo->>ativos: linhasDoAtivo(importado)
  ativos->>ativos: cm(d.comoFicou.largura)
  ativos-->>ativos: '18.0', ja em centimetro
  ativos-->>demo: 'Fator de escala aplicado: 0.0100 (medido, nao digitado)' e mais oito linhas
  demo->>tela: escrever(raizAtivo, linhas)

linhasDoAtivo() mostra as duas colunas na mesma folha e em unidades diferentes de propósito: comoChegou fica na unidade do arquivo, e comoFicou passa por cm() antes de ser escrito. Pôr as duas em metro esconderia justamente o que se quer que salte aos olhos.

A faixa fator < 0.5 || fator > 2, e o que a igualdade custaria. A alternativa é acusar sempre que o fator for diferente de 1. O custo é uma folha em que todo ativo tem divergência de unidade, inclusive o correto que veio com 0,998 por arredondamento de exportação — e uma lista que acusa sempre deixa de ser lida na segunda semana.

Anunciar a divergência de eixo a partir de ajuste.giroEmXGraus, e o que detectá-la custaria. A alternativa é inferir o eixo vertical do próprio arquivo, comparando as proporções da caixa envolvente. O custo é adivinhação: uma peça mais larga que alta é comum e legítima, e o palpite erraria em silêncio. Aqui a frase só é escrita porque alguém declarou o giro, e ela relata uma decisão em vez de fingir uma medição.

1.6.3 O censo, que conta lote e não instância

O cenário é o clique em Medir agora, com a morsa já pendurada. censoDaCena(cena.sala) devolve 524 triângulos em 12 malhas soltas, 0 lotes instanciados, 8 materiais distintos e 12 chamadas previstas — as quatro malhas da morsa somadas às oito que a cena já tinha.

flowchart TD
  ini["clique em 'Medir agora' chama censoDaCena(cena.sala)"] --> anda["raiz.traverse, um no por vez"]
  anda --> ehMalha{"no instanceof Mesh"}
  ehMalha -->|"false"| volta["nada a somar: luz, grupo ou eixo"]
  volta --> anda
  ehMalha -->|"true"| pm["porMalha = contarTriangulos(no.geometry)"]
  pm --> lote{"no instanceof InstancedMesh"}
  lote -->|"true"| somaLote["lotes += 1, instancias += no.count, triangulos += porMalha * no.count"]
  lote -->|"false"| somaSolta["malhas += 1, triangulos += porMalha"]
  somaLote --> mat["materiais.add(item.uuid), uma vez por material do no"]
  somaSolta --> mat
  mat --> anda
  anda -->|"arvore percorrida"| saida["Censo: 524 triangulos, 12 malhas soltas, 0 lotes, 8 materiaisDistintos"]
  saida --> prev["chamadasPrevistas = malhas + lotes = 12, uma por lote e nao uma por instancia"]

O ramo que decide está em no instanceof InstancedMesh, e ele é testado depois de no instanceof Mesh, não em vez dele: um lote também é uma malha, e inverter os dois testes faria todo lote cair na coluna das malhas soltas. chamadasPrevistas sai de malhas + lotes, sem instancias em lugar nenhum da soma — é essa linha que expõe o que a instanciação compra.

sequenceDiagram
  autonumber
  participant demo as demos/05_demo.ts
  participant med as content/medicao.ts
  participant sala as cena.sala
  participant malha as content/malha.ts
  demo->>med: censoDaCena(cena.sala)
  med->>sala: traverse do grafo montado em montarCena() e vestido por vestirCena()
  loop um no por vez
    sala-->>med: no, ja com a morsa que importarAtivo() pendurou em cena.bancada
    med->>malha: contarTriangulos(no.geometry)
    malha-->>med: triangulos daquela malha
  end
  med-->>demo: Censo com 524 triangulos, 12 malhas, 0 lotesInstanciados, chamadasPrevistas 12

Contar material por identidade, e o que contar por nome custaria. A alternativa é usar o nome do material, que é legível e é o que se quer no relatório. O custo é contar dois materiais idênticos em valor como um só, e o número deixaria de responder à pergunta que importa: quantas trocas de estado a placa vai fazer.

Percorrer a árvore a cada clique, e o que um contador incremental custaria. A alternativa é manter os totais atualizados a cada nó adicionado. O custo é um número que diverge em silêncio na primeira vez que alguém remover uma peça sem avisar o contador — e a árvore desta cena tem doze malhas, de modo que a travessia inteira é mais barata que o defeito que ela evita.

1.6.4 A medição, com a máquina na mesma folha

O cenário é o mesmo clique. orcamento.ler() devolve 120 quadros medidos, o intervalo médio e o pior intervalo da janela, contra o teto de 16,7 ms que o computador de mesa declara; os dois intervalos variam com a máquina que roda a página, e por isso nenhum valor deles é transcrito aqui; identificarPlaca(palco.renderer) devolve o que aquele navegador quis dizer.

flowchart TD
  ini["clique em 'Medir agora' chama identificarPlaca(palco.renderer)"] --> ctx["renderer.getContext() e getExtension('WEBGL_debug_renderer_info')"]
  ctx --> temExt{"extensao === null || typeof extensao !== 'object'"}
  temExt -->|"true"| naoSabe["'placa nao identificada (o navegador nao expoe o nome do adaptador)'"]
  temExt -->|"false"| chave["chave = extensao['UNMASKED_RENDERER_WEBGL']"]
  chave --> ehNum{"typeof chave !== 'number'"}
  ehNum -->|"true"| naoSabe
  ehNum -->|"false"| nome["contexto.getParameter(chave)"]
  nome --> ehStr{"typeof nome === 'string' && nome !== ''"}
  ehStr -->|"true"| revela["o nome relatado por este aparelho"]
  ehStr -->|"false"| naoSabe
  revela --> folha["linhasDaMedicao(censo, orcamento.ler(), placa)"]
  naoSabe --> folha
  folha --> cab["MAQUINA_DE_REFERENCIA.descricao, .navegador e o teto de 16.7 ms na mesma folha"]
  cab --> temQuadro{"leitura.quadrosMedidos === 0"}
  temQuadro -->|"true"| adiar["'Ainda sem quadros medidos: a leitura dinamica entra depois do primeiro quadro'"]
  temQuadro -->|"false, 120 quadros"| folga["folga = leitura.tetoMs - leitura.intervaloMedioMs"]
  folga --> sinal{"folga >= 0"}
  sinal -->|"true"| ms["'<folga> ms de folga'"]
  sinal -->|"false"| estouro["'acima do teto', com o sinal invertido para nao imprimir menos"]
  ms --> pior["pior intervalo da janela, que a media esconderia"]
  estouro --> pior
  pior --> dif["censo.triangulos - leitura.triangulos = 524 - 524 = 0 fora do campo de visao"]

identificarPlaca() tem três saídas para o mesmo desconhecimento, e todas as três dizem a mesma frase: extensão ausente, chave que não é número, e nome vazio. Escrever a recusa por extenso, em vez de devolver cadeia vazia, é o que impede a folha de sair com um campo em branco que cada leitor preenche de cabeça.

sequenceDiagram
  autonumber
  participant botao as botaoMedir
  participant demo as demos/05_demo.ts
  participant med as content/medicao.ts
  participant orc as core/orcamento.ts
  participant palco as core/palco.ts
  botao->>demo: click, depois de a cena ja ter desenhado quadros
  demo->>orc: orcamento.ler()
  orc-->>demo: LeituraDoOrcamento: 120 quadrosMedidos, intervalo medio e pior intervalo da janela
  demo->>palco: identificarPlaca(palco.renderer)
  palco-->>demo: o nome que o navegador entregou, ou a recusa dita por extenso
  demo->>med: linhasDaMedicao(censo, leitura, placa)
  med-->>demo: 'Teto declarado por quadro: 16.7 ms' e 'Intervalo medio: <medio> ms - <folga> ms de folga'
  demo->>demo: escrever(raizCusto, linhas)

linhasDaMedicao() monta o cabeçalho — máquina, navegador, placa e teto — antes de olhar para leitura.quadrosMedidos. É por isso que a folha nunca sai sem a máquina ao lado, inclusive no caso em que ainda não há quadro medido e a função retorna cedo.

A saída antecipada em quadrosMedidos === 0, e o que a conta protegida custaria. A alternativa é calcular a média com uma guarda contra divisão por zero e imprimir 0,0 ms. O custo é uma linha que se lê como medição excelente quando é ausência de medição, e essa é a leitura que alguém vai levar para a apresentação.

A folga como subtração impressa, e o que o percentual custaria. A alternativa é dizer que sobrou um quarto do orçamento. O custo é perder a unidade em que a decisão é tomada: quem precisa acrescentar sombra ao quadro quer saber quantos milissegundos tem, e não que fração deles sobrou.

1.6.5 O que a página deste módulo ainda não exercita

A instanciação da borda e a troca de nível por distância estão escritas no código deste módulo, e as seções acima as explicam. A página de demonstração não passa por elas: quem monta a borda parafusada e a prateleira é a composição do ambiente completo, que não pertence a módulo nenhum do percurso. Sem execução não há cenário honesto, e por isso disporNaBorda(), montarLoteInstanciado(), simplificar() e montarPrateleira() não recebem diagrama aqui — recebem quando a demonstração que as exercita existir.

1.7 O que este módulo deixa pronto

A cena está completa, e pela primeira vez existe um número contra o qual as próximas decisões vão ser julgadas.

A oficina agora tem conteúdo de três procedências: geometria autorada dentro do projeto, um ativo trazido de fora e ajustado, e repetição tratada como repetição. O eixo de modelagem fecha aqui.

O que sobrevive deste módulo para todos os seguintes é a medição. A partir do módulo da câmera orbital entram interação, sessão imersiva e ancoragem, e cada um deles acrescenta trabalho por quadro. Sem a linha de base medida agora, a primeira queda de desempenho será atribuída ao último recurso adicionado — que quase nunca é o culpado.