1 Técnicas de modelagem de ambientes virtuais — 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 ganha forma, e a peça mais importante do módulo continua invisível.

A Bancada do módulo anterior era feita de caixas e cilindros pedidos à biblioteca. A estrutura estava certa, e a aparência era um adiamento declarado. Agora o adiamento vence.

Duas coisas entram aqui. A primeira é a geometria escrita por nós, com vértices, faces, normais e coordenadas de textura decididos um a um. A segunda é o volume de contato de cada peça manipulável — a caixa contra a qual se testa encostar, que não é a forma que se vê.

A segunda é a que importa mais e a que ninguém pede. Ela não aparece na tela, não muda uma imagem, e não produz sintoma algum enquanto não houver manipulação. Quando houver, já será tarde para escrevê-la barato.

O código deste módulo nasce em uma pasta nova, ao lado das que já existiam, e a cena do módulo anterior não é reescrita. A árvore sobe igual e depois é vestida. Essa ordem mantém cada módulo com um assunto só, e permite exibir o antes e o depois lado a lado em aula.

1.2 Quatro listas, e três produzem defeito silencioso

Uma malha são quatro listas paralelas, e a forma é o que sobra delas.

Pedir uma caixa à biblioteca esconde o que existe dentro dela. Uma malha é uma lista de pontos, uma lista de triângulos que os liga, uma normal por vértice que decide como a luz responde, e um par de coordenadas por vértice que decide onde a textura cai.

A primeira lista errada produz forma errada, e isso se vê. As outras três não. Normal invertida deixa a peça escura com silhueta perfeita. Triângulo percorrido no sentido contrário abre um buraco visível só de um certo ângulo. Coordenada de textura mal distribuída estica o desenho sem mexer no contorno.

src/bancada/content/malha.ts
// ---------------------------------------------------------------------------
// Autoria de geometria: vértices, faces, normais e coordenadas de textura
// escritos aqui, e não recebidos prontos de um construtor da biblioteca.
//
// Até o módulo anterior a Bancada era feita de caixas e cilindros pedidos à
// biblioteca. Pedir é legítimo e continua sendo, para o que é caixa mesmo. O que
// não se aprende pedindo é o que existe dentro daquilo: uma malha é uma lista de
// pontos, uma lista de triângulos que os liga, uma normal por vértice que decide
// como a luz responde, e um par de coordenadas por vértice que decide onde a
// textura cai. Nenhuma das quatro é opcional, e três delas produzem defeito
// silencioso quando saem erradas.
//
// O construtor abaixo duplica vértice em toda face, sempre. Isso é escolha, e
// tem preço: a engrenagem sai com muito mais vértices do que teria se as faces
// compartilhassem pontos. O que se compra com esse preço é a aresta viva —
// vértice compartilhado obriga uma normal só a servir a duas faces, e o
// resultado é o canto arredondado que ninguém pediu. Peça mecânica com canto
// macio parece brinquedo.
// ---------------------------------------------------------------------------

import { BufferGeometry, Float32BufferAttribute } from 'three';

/** Um ponto do perfil, no plano horizontal. O perfil é o que se autora; a extrusão é mecânica. */
export interface Ponto2 {
  readonly x: number;
  readonly z: number;
}

/**
 * Acumulador de malha. Cada chamada de `vertice` acrescenta um ponto com a sua
 * normal e a sua coordenada de textura próprias, e devolve o índice dele — que é
 * o que os triângulos citam depois.
 */
export class ConstrutorDeMalha {
  private readonly posicoes: number[] = [];
  private readonly normais: number[] = [];
  private readonly uvs: number[] = [];
  private readonly indices: number[] = [];

// recorte:inicio quatro-listas-paralelas
    vertice(
    x: number,
    y: number,
    z: number,
    nx: number,
    ny: number,
    nz: number,
    u: number,
    v: number,
  ): number {
    this.posicoes.push(x, y, z);
    this.normais.push(nx, ny, nz);
    this.uvs.push(u, v);
    return this.posicoes.length / 3 - 1;
  }
  // recorte:fim quatro-listas-paralelas

  triangulo(a: number, b: number, c: number): void {
    this.indices.push(a, b, c);
  }

  /** Dois triângulos com a mesma orientação. A ordem dos quatro decide para que lado a face olha. */
  quadrilatero(a: number, b: number, c: number, d: number): void {
    this.triangulo(a, b, c);
    this.triangulo(a, c, d);
  }

  get totalDeVertices(): number {
    return this.posicoes.length / 3;
  }

  get totalDeTriangulos(): number {
    return this.indices.length / 3;
  }

  finalizar(nome: string): BufferGeometry {
    const geometria: BufferGeometry = new BufferGeometry();
    geometria.name = nome;
    geometria.setAttribute('position', new Float32BufferAttribute(this.posicoes, 3));
    geometria.setAttribute('normal', new Float32BufferAttribute(this.normais, 3));
    geometria.setAttribute('uv', new Float32BufferAttribute(this.uvs, 2));
    geometria.setIndex(this.indices);
    // A esfera envolvente é o que a biblioteca consulta para decidir se o objeto
    // entra no campo de visão. Malha sem ela é desenhada sempre, inclusive
    // quando está atrás da câmera — e isso é orçamento gasto em nada.
    geometria.computeBoundingSphere();
    geometria.computeBoundingBox();
    return geometria;
  }
}

export function contarTriangulos(geometria: BufferGeometry): number {
  const indice = geometria.getIndex();
  if (indice === null) {
    return geometria.getAttribute('position').count / 3;
  }
  return indice.count / 3;
}

export function contarVertices(geometria: BufferGeometry): number {
  return geometria.getAttribute('position').count;
}

/**
 * Estende um perfil fechado ao longo do eixo vertical, fechando as duas faces
 * planas e a casca lateral.
 *
 * O porquê da ordem dos vértices em cada face: a biblioteca desenha só a face
 * cuja borda percorre o sentido anti-horário vista de fora. Percorrer no sentido
 * errado não produz erro nem aviso — produz um buraco por onde se vê o interior
 * da peça, e o buraco só aparece de um certo ângulo. Cada bloco abaixo diz de
 * que conta saiu a sua ordem.
 *
 * O perfil chega em sentido anti-horário visto de cima. Nessa convenção, o
 * produto vetorial de dois raios consecutivos aponta para baixo — daí a face de
 * cima citar os pontos em ordem invertida, e a de baixo, na ordem natural.
 */
export function extrudarPerfil(
  perfil: readonly Ponto2[],
  espessura: number,
  nome: string,
): BufferGeometry {
  const construtor: ConstrutorDeMalha = new ConstrutorDeMalha();
  const meia: number = espessura / 2;
  const n: number = perfil.length;

  let raioMaximo: number = 0;
  let perimetro: number = 0;
  for (let i = 0; i < n; i += 1) {
    const p: Ponto2 = ponto(perfil, i);
    const q: Ponto2 = ponto(perfil, i + 1);
    raioMaximo = Math.max(raioMaximo, Math.hypot(p.x, p.z));
    perimetro += Math.hypot(q.x - p.x, q.z - p.z);
  }

  // Face de cima. A coordenada de textura é a projeção plana do próprio ponto,
  // reescalada para caber no intervalo de zero a um: numa face plana ela não
  // distorce nada, e é a mais barata de todas as projeções.
  const centroTopo: number = construtor.vertice(0, meia, 0, 0, 1, 0, 0.5, 0.5);
  const topo: number[] = [];
  for (let i = 0; i < n; i += 1) {
    const p: Ponto2 = ponto(perfil, i);
    topo.push(
      construtor.vertice(
        p.x,
        meia,
        p.z,
        0,
        1,
        0,
        0.5 + p.x / (2 * raioMaximo),
        0.5 + p.z / (2 * raioMaximo),
      ),
    );
  }
  for (let i = 0; i < n; i += 1) {
    construtor.triangulo(centroTopo, indiceDe(topo, i + 1), indiceDe(topo, i));
  }

  const centroBase: number = construtor.vertice(0, -meia, 0, 0, -1, 0, 0.5, 0.5);
  const base: number[] = [];
  for (let i = 0; i < n; i += 1) {
    const p: Ponto2 = ponto(perfil, i);
    base.push(
      construtor.vertice(
        p.x,
        -meia,
        p.z,
        0,
        -1,
        0,
        0.5 + p.x / (2 * raioMaximo),
        0.5 - p.z / (2 * raioMaximo),
      ),
    );
  }
  for (let i = 0; i < n; i += 1) {
    construtor.triangulo(centroBase, indiceDe(base, i), indiceDe(base, i + 1));
  }

// recorte:inicio costura-na-coordenada-de-textura
    // Casca lateral. Cada aresta do perfil vira um retângulo com normal própria,
  // perpendicular a ela: é isso que dá o dente com flanco chapado em vez de
  // ombro arredondado. A coordenada horizontal da textura acompanha o perímetro
  // percorrido, e a vertical vai da base ao topo.
  let percorrido: number = 0;
  // recorte:fim costura-na-coordenada-de-textura
  for (let i = 0; i < n; i += 1) {
    const p: Ponto2 = ponto(perfil, i);
    const q: Ponto2 = ponto(perfil, i + 1);
    const comprimento: number = Math.hypot(q.x - p.x, q.z - p.z);

// recorte:inicio normal-para-fora-ou-para-dentro
        // Normal para fora: o produto vetorial do eixo vertical pela direção da
    // aresta. Na ordem inversa ela apontaria para dentro da peça, e a luz
    // acertaria a face pelo lado errado — a peça fica escura e ninguém acha o
    // motivo, porque a silhueta continua perfeita.
    const nx: number = (q.z - p.z) / comprimento;
    const nz: number = -(q.x - p.x) / comprimento;
    // recorte:fim normal-para-fora-ou-para-dentro

    const u0: number = percorrido / perimetro;
    const u1: number = (percorrido + comprimento) / perimetro;

    const a: number = construtor.vertice(p.x, meia, p.z, nx, 0, nz, u0, 1);
    const b: number = construtor.vertice(q.x, meia, q.z, nx, 0, nz, u1, 1);
    const c: number = construtor.vertice(q.x, -meia, q.z, nx, 0, nz, u1, 0);
    const d: number = construtor.vertice(p.x, -meia, p.z, nx, 0, nz, u0, 0);
    construtor.quadrilatero(a, b, c, d);

    percorrido += comprimento;
  }

  return construtor.finalizar(nome);
}

/**
 * Perfil de uma engrenagem de dentes retos, com quatro pontos por dente: sobe ao
 * topo, atravessa a crista, desce à raiz e atravessa o vale.
 *
 * O número de dentes é o botão de densidade da peça, e ele custa: cada dente
 * acrescenta dezesseis triângulos à malha final. Vinte e quatro dentes numa peça
 * do tamanho de uma tampa de garrafa é resolução que ninguém enxerga e que a
 * placa paga do mesmo jeito.
 */
export function perfilDeEngrenagem(
  dentes: number,
  raioDaRaiz: number,
  raioDoTopo: number,
): Ponto2[] {
  const pontos: Ponto2[] = [];
  const passo: number = (Math.PI * 2) / dentes;
  for (let k = 0; k < dentes; k += 1) {
    const inicio: number = k * passo;
    pontos.push(polar(raioDaRaiz, inicio));
    pontos.push(polar(raioDoTopo, inicio + passo * 0.25));
    pontos.push(polar(raioDoTopo, inicio + passo * 0.55));
    pontos.push(polar(raioDaRaiz, inicio + passo * 0.8));
  }
  return pontos;
}

/** Perfil de um prisma regular. O eixo do mecanismo tem cabeça sextavada por isto. */
export function perfilRegular(lados: number, raio: number): Ponto2[] {
  const pontos: Ponto2[] = [];
  for (let k = 0; k < lados; k += 1) {
    pontos.push(polar(raio, (k * Math.PI * 2) / lados));
  }
  return pontos;
}

function polar(raio: number, angulo: number): Ponto2 {
  return { x: raio * Math.cos(angulo), z: raio * Math.sin(angulo) };
}

function ponto(perfil: readonly Ponto2[], i: number): Ponto2 {
  const p: Ponto2 | undefined = perfil[i % perfil.length];
  if (p === undefined) {
    throw new Error('Perfil vazio: não há o que extrudar.');
  }
  return p;
}

function indiceDe(indices: readonly number[], i: number): number {
  const valor: number | undefined = indices[i % indices.length];
  if (valor === undefined) {
    throw new Error('Índice fora do anel de vértices, o que a extrusão não deveria permitir.');
  }
  return valor;
}

O acumulador de malha deste arquivo duplica vértice em toda face, sempre. É escolha, e ela custa: a engrenagem grande sai com 338 vértices para 224 triângulos, quando poderia ter bem menos compartilhando pontos entre faces vizinhas.

O que se compra com esse preço é a aresta viva. Vértice compartilhado obriga uma normal só a servir a duas faces, e o resultado é o canto arredondado que ninguém pediu. Peça mecânica com canto macio parece brinquedo, e a impressão custa mais do que os vértices economizados.

A extrusão do perfil traz um comentário longo sobre a ordem dos vértices em cada face, e o motivo é operacional. A biblioteca desenha somente a face cuja borda percorre o sentido anti-horário vista de fora. Percorrer ao contrário não gera erro nem aviso: gera um buraco.

1.3 Tarefa 1: Construir geometria própria em código

A engrenagem que a turma vai passar o semestre inteiro apanhando nasce aqui, com catorze dentes escolhidos a dedo.

Enunciado da tarefa

Ao menos parte dos objetos da cena nasce de geometria escrita no próprio ambiente, com vértices, faces, normais e coordenadas de textura decididos por quem constrói. Material e propriedades de superfície entram junto, com o custo em tempo real pesando na escolha.

O que fica pronto é um conjunto de objetos do domínio construídos assim, integrados à hierarquia da cena e posicionados por decisão de projeto. É a peça que garante que a cena tenha sido composta, e não recebida inteira de um exemplo pronto.

1.3.1 O perfil, que é onde mora a autoria

Uma engrenagem é um contorno plano puxado para cima. Essa frase é a arquitetura inteira do arquivo: o contorno é o que se autora, e a extrusão é mecânica.

O contorno de cada dente tem quatro pontos. Sobe à crista, atravessa a crista, desce à raiz e atravessa o vale. Os ângulos em que cada um desses quatro acontece são a diferença entre um dente que parece dente e um serrilhado.

A mesma rotina de extrusão produz o eixo, com um perfil de doze lados em vez de dentes. À distância ele parece um cilindro; de perto a faceta aparece. Essa faceta é a evidência visível de que a normal foi decidida por face, e não interpolada entre vizinhas.

1.3.2 O botão de densidade, e o que ele cobra

Por que catorze dentes, e não vinte e quatro?

Cada dente acrescenta dezesseis triângulos à malha final. Uma engrenagem de quinze centímetros vista a meio metro não entrega ao olho a diferença entre um dente e o vizinho, e a placa desenha os dois do mesmo jeito. Vinte e quatro dentes numa peça desse tamanho é resolução que ninguém enxerga e que se paga em todo quadro.

Cada quadro é um envelope de onze milissegundos, e o que não couber nele não fica para depois: some. O envelope foi declarado no módulo anterior, antes de existir conteúdo pesado a que atribuir a culpa, e este é o primeiro módulo em que ele tem contra o que ser gasto.

As cinco peças somam 440 triângulos. É um número pequeno, e ele é a linha de base contra a qual os ativos importados do módulo seguinte vão ser comparados. Quem não mede antes não tem como saber o que o modelo importado custou.

1.3.3 As superfícies, e a segunda coluna da escolha

src/bancada/content/materiais.ts
// ---------------------------------------------------------------------------
// Os materiais da oficina, e o que cada um cobra.
//
// O material do módulo anterior era um só, cinza, repetido em tudo. Ele existia
// para não desviar a atenção da estrutura. Agora ele é o assunto, e a escolha
// passa a ter duas colunas: o que o material entrega ao olho e o que ele custa
// por quadro.
//
// O modelo usado é o de propriedades físicas: em vez de descrever a aparência
// ("um cinza brilhante"), descreve-se a superfície ("metálica, pouco rugosa") e
// deixa-se a luz da cena decidir a aparência. É o que faz a mesma peça parecer
// coerente na sala do desktop e sob o passthrough da RA, sem uma linha por
// regime — e é também o que torna o resultado dependente da iluminação, o que
// pega desprevenido quem vem de material chapado.
//
// Cada material aqui é criado UMA vez e compartilhado por quantas peças o usem.
// O motivo é de orçamento e não de arrumação: material é um programa compilado
// para a placa, e trocar de programa no meio do quadro custa. Duas engrenagens
// com o mesmo latão são desenhadas com uma troca; com dois latões idênticos
// porém distintos, com duas.
// ---------------------------------------------------------------------------

import { Color, MeshStandardMaterial } from 'three';

// recorte:inicio material-com-entrega-e-custo
export interface DescricaoDeMaterial {
  readonly nome: string;
  /** O que o material entrega ao olho, em uma frase. */
  readonly entrega: string;
  /** O que ele cobra em tempo real, em uma frase. Sem esta coluna a escolha é palpite. */
  readonly custo: string;
}
// recorte:fim material-com-entrega-e-custo

export interface MaterialDaOficina {
  readonly descricao: DescricaoDeMaterial;
  readonly material: MeshStandardMaterial;
}

/**
 * Catálogo da oficina. Quatro superfícies bastam para o mecanismo inteiro, e
 * essa contenção é decisão de projeto: cada material distinto é uma troca de
 * estado a mais no quadro, e o teto de hardware da disciplina é uma máquina de
 * vídeo integrado.
 */
export interface Catalogo {
  readonly acoEscovado: MaterialDaOficina;
  readonly latao: MaterialDaOficina;
  readonly plasticoFosco: MaterialDaOficina;
  readonly madeira: MaterialDaOficina;
}

function superficie(
  nome: string,
  cor: number,
  rugosidade: number,
  metalicidade: number,
  entrega: string,
  custo: string,
): MaterialDaOficina {
  const material: MeshStandardMaterial = new MeshStandardMaterial({
    color: new Color(cor),
    roughness: rugosidade,
    metalness: metalicidade,
  });
  material.name = nome;
  return { descricao: { nome, entrega, custo }, material };
}

export function montarCatalogo(): Catalogo {
  return {
    // Metálico e pouco rugoso é o par que produz reflexo direcional. Ele depende
    // inteiramente da luz da cena: sob uma fonte só, a peça metálica fica escura
    // e chapada, e o grupo culpa o material quando o problema é a iluminação.
    acoEscovado: superficie(
      'aco-escovado',
      0xb8bcc6,
      0.35,
      0.9,
      'reflexo alongado que denuncia a direção da luz e dá leitura de peça usinada',
      'metálico exige que a cena tenha o que refletir; sem ambiente, escurece',
    ),
    latao: superficie(
      'latao',
      0xb07d3b,
      0.45,
      0.75,
      'amarelo quente que separa a engrenagem do corpo sem precisar de rótulo',
      'mesmo custo do aço; o que pesa é ser um material a mais, não a cor',
    ),
    // Rugoso e não metálico é o mais barato de acertar: responde à luz difusa e
    // perdoa cena mal iluminada. É o material de quem tem orçamento apertado.
    plasticoFosco: superficie(
      'plastico-fosco',
      0x6d7280,
      0.85,
      0.05,
      'superfície opaca e legível em qualquer iluminação, sem brilho que confunda a forma',
      'o mais barato do catálogo em termos de dependência da cena',
    ),
    madeira: superficie(
      'madeira',
      0x5c4a35,
      0.8,
      0.0,
      'tom quente que separa o mobiliário das peças e ancora a escala da bancada',
      'idem plástico; a textura de veio, se entrasse, é que custaria memória de vídeo',
    ),
  };
}

/**
 * Linhas legíveis sobre o catálogo, para o painel diegético e para o relatório
 * da página. O número que interessa é o total de materiais distintos: ele é o
 * piso das trocas de estado por quadro.
 */
export function linhasDoCatalogo(catalogo: Catalogo): string[] {
  const materiais: readonly MaterialDaOficina[] = [
    catalogo.acoEscovado,
    catalogo.latao,
    catalogo.plasticoFosco,
    catalogo.madeira,
  ];
  const linhas: string[] = [`Materiais distintos na cena: ${materiais.length}`];
  for (const item of materiais) {
    linhas.push(
      `${item.descricao.nome}: rugosidade ${item.material.roughness.toFixed(2)}, ` +
        `metalicidade ${item.material.metalness.toFixed(2)}`,
    );
  }
  return linhas;
}

O modelo de material aqui descreve a superfície, e não a aparência. Em vez de “um cinza brilhante”, declara-se “metálica, pouco rugosa”, e a luz da cena decide o resto. É o que faz a mesma peça parecer coerente na sala do desktop e sob o passthrough da realidade aumentada, sem uma linha por regime.

Isso tem contrapartida, e ela pega desprevenido quem vem de material chapado. A peça metálica depende inteiramente do que existe para refletir. Numa cena com uma fonte de luz só, ela fica escura e sem leitura, e o grupo culpa o material quando o problema é a iluminação.

Cada material é criado uma vez e compartilhado por quantas peças o usem. As duas engrenagens dividem o mesmo latão. Material é um programa compilado para a placa, e dois latões idênticos porém distintos custam duas trocas de estado por quadro em vez de uma.

O catálogo tem quatro superfícies para o mecanismo inteiro, e a contenção é decisão de projeto. O teto de hardware desta disciplina é uma máquina de vídeo integrado, e cada material distinto é uma troca a mais no orçamento já declarado.

1.3.4 Vestir a árvore sem reescrevê-la

src/bancada/content/pecas.ts
// ---------------------------------------------------------------------------
// O ponto em que a oficina deixa de ser feita de caixas e passa a ter conteúdo.
//
// A cena do módulo anterior nasce igual: a árvore, os parentescos e a escala não
// mudam uma linha. O que este arquivo faz é vestir aquela árvore — trocar a
// forma provisória das peças que merecem forma própria, aplicar o catálogo de
// superfícies e pendurar em cada peça manipulável o volume contra o qual se vai
// testar contato.
//
// Vestir depois, em vez de já montar a cena assim, não é organização: é o que
// mantém cada módulo com um assunto só e permite exibir o antes e o depois lado
// a lado em aula. A árvore continua sendo do módulo da estrutura; a forma é
// deste.
//
// Nem toda peça ganha malha autorada, e isso é decisão declarada. O corpo é uma
// caixa e a tampa é uma placa: pedir uma caixa à biblioteca é a escolha certa
// para o que é caixa mesmo, e escrever à mão a malha de um paralelepípedo só
// para dizer que se escreveu é trabalho sem retorno. A geometria autorada vai
// onde a forma carrega significado — o dente que engrena, a faceta que denuncia
// a normal dura.
// ---------------------------------------------------------------------------

import { Mesh, Object3D, Vector3 } from 'three';

import type { CenaDaBancada } from '../core/cena';
import type { PecaId } from '../dominio/dominio';
import {
  contarTriangulos,
  contarVertices,
  extrudarPerfil,
  perfilDeEngrenagem,
  perfilRegular,
} from './malha';
import { montarCatalogo, type Catalogo } from './materiais';
import {
  RegistroDeContato,
  contemPonto,
  volumeDaMalha,
  type VolumeDeContato,
} from './colisao';

export interface LinhaDoInventario {
  readonly peca: string;
  readonly origem: 'autorada' | 'primitiva';
  readonly triangulos: number;
  readonly vertices: number;
}

export interface Conteudo {
  readonly catalogo: Catalogo;
  readonly contato: RegistroDeContato;
  readonly inventario: readonly LinhaDoInventario[];
  readonly totalDeTriangulos: number;
}

/** Espessura das duas engrenagens, em metros. A mesma do módulo anterior: a forma mudou, a escala não. */
const ESPESSURA_DA_ENGRENAGEM: number = 0.016;

/**
 * Folga de contato por peça, em metros, com a razão de cada número.
 *
 * Os valores não são iguais de propósito. A folga que serve para apanhar uma
 * engrenagem de quinze centímetros afogaria o eixo, que tem dois centímetros e
 * meio de diâmetro: a caixa dele passaria a ser quase toda folga, e qualquer
 * raio que cruzasse a vizinhança o capturaria.
 */
const FOLGAS: ReadonlyMap<PecaId, { readonly metros: number; readonly razao: string }> = new Map([
  [
    'corpo',
    { metros: 0.012, razao: 'peça de apoio, apanhada pela lateral com a mão inteira' },
  ],
  [
    'eixo',
    {
      metros: 0.008,
      razao: 'peça fina: folga maior faria o raio capturá-la de longe e roubar o alvo vizinho',
    },
  ],
  [
    'engrenagem-grande',
    { metros: 0.02, razao: 'a mão fecha sobre a silhueta, e os dentes já ampliam a caixa' },
  ],
  [
    'engrenagem-pequena',
    { metros: 0.018, razao: 'mesma pega da grande, com um pouco menos por conviver perto dela' },
  ],
  [
    'tampa',
    { metros: 0.012, razao: 'placa chata: sem folga vertical o dedo passa por cima sem tocar' },
  ],
]);

function exigirMalha(no: Object3D | undefined, id: string): Mesh {
  if (no === undefined || !(no instanceof Mesh)) {
    throw new Error(`A cena não trouxe "${id}" como malha, e o conteúdo do módulo depende disso.`);
  }
  return no;
}

/**
 * Aplica ao grafo já montado a geometria autorada, o catálogo de superfícies e
 * os volumes de contato. Devolve o inventário, que é o que o painel exibe e o
 * que a tutoria confere.
 */
export function vestirCena(cena: CenaDaBancada): Conteudo {
  const catalogo: Catalogo = montarCatalogo();
  const contato: RegistroDeContato = new RegistroDeContato();
  const inventario: LinhaDoInventario[] = [];

  const grande: Mesh = exigirMalha(cena.pecas.get('engrenagem-grande'), 'engrenagem-grande');
  // Catorze dentes, e não vinte e quatro. Uma peça de quinze centímetros vista a
  // meio metro não entrega a diferença entre um dente e outro, e cada dente
  // custa dezesseis triângulos que a placa desenha do mesmo jeito.
  trocarMalha(
    grande,
    extrudarPerfil(perfilDeEngrenagem(14, 0.068, 0.078), ESPESSURA_DA_ENGRENAGEM, 'engrenagem-grande'),
  );
  grande.material = catalogo.latao.material;

  const pequena: Mesh = exigirMalha(cena.pecas.get('engrenagem-pequena'), 'engrenagem-pequena');
  trocarMalha(
    pequena,
    extrudarPerfil(perfilDeEngrenagem(9, 0.04, 0.047), ESPESSURA_DA_ENGRENAGEM, 'engrenagem-pequena'),
  );
  // O mesmo material das duas engrenagens, e não dois latões idênticos: uma
  // instância, um programa na placa, uma troca de estado por quadro.
  pequena.material = catalogo.latao.material;

  const eixo: Mesh = exigirMalha(cena.pecas.get('eixo'), 'eixo');
  // Doze lados, com normal dura em cada face. À distância parece um cilindro; de
  // perto a faceta aparece, e ela é a evidência visível de que a normal foi
  // decidida por face e não interpolada entre vizinhas.
  trocarMalha(eixo, extrudarPerfil(perfilRegular(12, 0.012), 0.22, 'eixo'));
  eixo.material = catalogo.acoEscovado.material;

  const corpo: Mesh = exigirMalha(cena.pecas.get('corpo'), 'corpo');
  corpo.material = catalogo.plasticoFosco.material;

  const tampa: Mesh = exigirMalha(cena.pecas.get('tampa'), 'tampa');
  tampa.material = catalogo.plasticoFosco.material;

  for (const [id, malha] of [
    ['corpo', corpo],
    ['eixo', eixo],
    ['engrenagem-grande', grande],
    ['engrenagem-pequena', pequena],
    ['tampa', tampa],
  ] as ReadonlyArray<readonly [PecaId, Mesh]>) {
    const folga = FOLGAS.get(id);
    if (folga === undefined) {
      throw new Error(`A peça "${id}" está na cena e não tem folga de contato declarada.`);
    }
    contato.registrar(id, volumeDaMalha(malha, folga.metros, folga.razao));
    inventario.push({
      peca: id,
      origem: id === 'corpo' || id === 'tampa' ? 'primitiva' : 'autorada',
      triangulos: contarTriangulos(malha.geometry),
      vertices: contarVertices(malha.geometry),
    });
  }

  let total: number = 0;
  for (const linha of inventario) {
    total += linha.triangulos;
  }

  return { catalogo, contato, inventario, totalDeTriangulos: total };
}

/**
 * Troca a malha de um nó liberando a anterior.
 *
 * Liberar não é zelo: geometria vive na memória da placa, e trocar sem liberar
 * deixa a antiga lá. Numa troca isso não se nota; num ambiente que recarrega
 * conteúdo várias vezes por sessão, a memória de vídeo acaba — e o sintoma é o
 * navegador perder o contexto de desenho, que é a tela ficando preta sem
 * mensagem de erro.
 */
function trocarMalha(no: Mesh, nova: Mesh['geometry']): void {
  no.geometry.dispose();
  no.geometry = nova;
}

export interface ComparacaoDeFolga {
  readonly distanciaDeTeste: number;
  readonly folgaAdotada: number;
  readonly capturaComAFolgaAdotada: boolean;
  readonly folgaGenerosa: number;
  readonly capturaComAFolgaGenerosa: boolean;
}

/**
 * A demonstração dos dois defeitos simétricos, medida em vez de descrita.
 *
 * Um ponto de teste é colocado a uma distância declarada da engrenagem grande,
 * ao lado dela, e a pergunta é se o volume o captura. Com a folga adotada, não
 * captura: o ponto está fora, e é isso o que se quer, porque ali a mão ainda não
 * encostou. Com uma folga generosa, captura — e a peça passa a grudar em quem
 * apenas passou perto.
 *
 * O defeito oposto não precisa de demonstração própria: é o mesmo teste com o
 * ponto encostado e a folga em zero, e a caixa da malha dentada tem canto o
 * bastante para deixar passar o dedo que entrou pelo vale entre dois dentes.
 */
export function compararFolgas(
  conteudo: Conteudo,
  distanciaDeTeste: number,
  folgaGenerosa: number,
): ComparacaoDeFolga {
  const volume: VolumeDeContato | undefined = conteudo.contato.volume('engrenagem-grande');
  if (volume === undefined) {
    throw new Error('A engrenagem grande não tem volume registrado, e a comparação depende dele.');
  }

  const centro: Vector3 = volume.no.localToWorld(volume.centroLocal.clone());
  const pontoDeTeste: Vector3 = centro.clone().add(new Vector3(distanciaDeTeste, 0, 0));

  const generoso: VolumeDeContato = volumeDaMalha(
    volume.no,
    folgaGenerosa,
    'folga deliberadamente exagerada, só para a comparação',
  );

  return {
    distanciaDeTeste,
    folgaAdotada: volume.folga,
    capturaComAFolgaAdotada: contemPonto(volume, pontoDeTeste),
    folgaGenerosa,
    capturaComAFolgaGenerosa: contemPonto(generoso, pontoDeTeste),
  };
}

Nem toda peça ganha malha autorada, e a decisão está escrita no arquivo. O corpo é uma caixa e a tampa é uma placa. Pedir uma caixa à biblioteca é a escolha certa para o que é caixa mesmo, e escrever à mão a malha de um paralelepípedo só para dizer que se escreveu é trabalho sem retorno.

A geometria autorada vai onde a forma carrega significado: o dente que engrena, a faceta que denuncia a normal dura. Essa fronteira é o que a tutoria cobra quando um grupo apresenta a cena: por que este e não aquele.

Trocar a malha de um nó libera a anterior antes de instalar a nova. Numa troca isso não se nota. Num ambiente que recarrega conteúdo várias vezes por sessão, a memória de vídeo acaba, e o sintoma é o navegador perder o contexto de desenho — a tela fica preta, sem mensagem de erro.

1.3.5 Onde é fácil errar, e como conferir

O erro previsível desta tarefa é a normal invertida na casca lateral. A silhueta continua perfeita, a peça escurece, e o grupo passa a tarde mexendo na luz.

A conferência é de segundos e não precisa de instrumento. Gire a peça devagar e observe a lateral contra a luz direcional: normal invertida escurece justamente quando a face encara a fonte. Se preferir a via mecânica, troque o material por um que ignore a iluminação — a face errada aparece com cor cheia, e a certa também, o que isola o problema na normal.

O segundo erro é a coordenada de textura esquecida. Ela não custa nada hoje, porque as superfícies deste módulo são de cor lisa. Custa no módulo dos ativos, quando entrar a primeira textura e o desenho aparecer esticado numa face e comprimido na vizinha.

1.4 Tarefa 2: Separar o que se vê do que colide

A peça mais cara do módulo é uma caixa que ninguém enxerga.

Enunciado da tarefa

A malha exibida e o volume contra o qual se testa contato raramente são o mesmo objeto. A separação precisa existir desde já: descobri-la tarde significa depurar colisões que acontecem em lugar diferente do que a tela mostra.

A tarefa fecha quando cada objeto manipulável da cena tem sua forma visível e seu volume de contato definidos em separado, com a relação entre os dois registrada. Volume folgado demais captura o que passa perto; apertado demais recusa o que já encostou.

1.4.1 Por que não testar contra a malha

A engrenagem grande tem 224 triângulos e catorze dentes. Testar o dedo, o raio do controle ou outra peça contra esses 224 triângulos, noventa vezes por segundo, é caro sem ser útil.

E é pior do que caro: é errado. Ninguém encaixa uma peça pelo flanco de um dente específico. Quem manipula mira a peça, e a peça é a silhueta dela — a caixa. Testar contra a malha exata faz o dedo escapar pelo vale entre dois dentes e a pega falhar por um motivo que quem usa não tem como adivinhar.

A analogia que a turma reconhece é a da vaga de estacionamento. O carro tem retrovisor, para-choque e espelho, e ninguém estaciona pensando em nenhum dos três: pensa no retângulo que o carro ocupa. O retângulo é maior que o carro, e é por isso que ele serve.

src/bancada/content/colisao.ts
// ---------------------------------------------------------------------------
// O volume de contato: a forma contra a qual se testa encostar, que não é a
// forma que se vê.
//
// A engrenagem que aparece na tela tem duzentos e poucos triângulos e catorze
// dentes. Testar o dedo, o raio do controle ou outra peça contra esses duzentos
// triângulos, sessenta ou noventa vezes por segundo, é caro sem ser útil:
// ninguém encaixa uma peça pelo flanco de um dente específico. O que se testa é
// uma caixa, e a caixa é escrita aqui.
//
// Duas propriedades desta separação valem ser ditas antes do código. A primeira
// é que ela é INVISÍVEL: nada na tela denuncia a sua ausência, porque a peça
// continua desenhando igual. A segunda é que ela cobra tarde — no módulo de
// manipulação, quando pegar e encaixar chegam e não há contra o que testar. É a
// combinação mais cara que existe num projeto de ambiente virtual: defeito que
// não aparece agora e que custa reescrever depois.
//
// A folga é o parâmetro que decide o comportamento, e ela tem dois defeitos
// simétricos. Folga grande captura o que passa perto — a peça gruda no ar, a um
// palmo do encaixe, e quem usa não entende por quê. Folga pequena recusa o que
// já encostou — a peça atravessa o socket sem prender, e a tentativa seguinte é
// feita com mais força, o que piora. Nenhum dos dois é bug de código: os dois
// são um número mal escolhido, e o número está declarado abaixo, peça por peça.
// ---------------------------------------------------------------------------

import {
  Box3,
  BoxGeometry,
  EdgesGeometry,
  LineBasicMaterial,
  LineSegments,
  Mesh,
  Vector3,
} from 'three';

/** A folga é medida em metros e some no relatório. Número mágico no código é número que ninguém revisa. */
// recorte:inicio volume-de-contato-com-folga-justificada
export interface VolumeDeContato {
  /** O nó cuja forma visível este volume representa. */
  readonly no: Mesh;
  /** Meia-extensão da caixa, no espaço local do nó, já com a folga somada. */
  readonly meiaExtensao: Vector3;
  /** Centro da caixa no espaço local do nó. Raramente é a origem: malha assimétrica desloca. */
  readonly centroLocal: Vector3;
  /** Folga adotada, em metros, e a justificativa dela em uma frase. */
  readonly folga: number;
  readonly razaoDaFolga: string;
}
// recorte:fim volume-de-contato-com-folga-justificada

/**
 * Deriva o volume de contato da caixa envolvente da malha visível e soma a folga
 * declarada.
 *
 * Derivar da malha é o começo honesto, e não o fim: a caixa de uma engrenagem
 * dentada é maior do que a engrenagem em quase toda direção, porque encosta nas
 * pontas dos dentes. Para agarrar, isso é o que se quer — a mão fecha sobre a
 * silhueta. Para encaixar, o volume derivado costuma ser trocado por um menor,
 * escrito à mão, e o módulo de montagem faz essa troca com o motivo escrito.
 */
export function volumeDaMalha(no: Mesh, folga: number, razaoDaFolga: string): VolumeDeContato {
  // A caixa sai da geometria, e não do nó montado na cena. Duas razões. A caixa
  // da geometria já está no espaço local da peça, que é onde o volume tem de
  // viver — assim ele gira, escala e é reparentado junto, de graça. E ela ignora
  // o que estiver pendurado no nó, inclusive o contorno de depuração que este
  // mesmo arquivo acrescenta: volume derivado do nó inteiro engordaria a cada
  // vez que fosse recalculado.
  no.geometry.computeBoundingBox();
  const caixa: Box3 | null = no.geometry.boundingBox;
  if (caixa === null) {
    throw new Error(`A malha de "${no.name}" não tem caixa envolvente, e o volume sai dela.`);
  }
  const centroLocal: Vector3 = caixa.getCenter(new Vector3());
  const tamanho: Vector3 = caixa.getSize(new Vector3());

  return {
    no,
    meiaExtensao: new Vector3(
      tamanho.x / 2 + folga,
      tamanho.y / 2 + folga,
      tamanho.z / 2 + folga,
    ),
    centroLocal,
    folga,
    razaoDaFolga,
  };
}

/** Volume escrito à mão, para quando a caixa da malha não descreve o comportamento desejado. */
export function volumeDeclarado(
  no: Mesh,
  meiaExtensao: Vector3,
  centroLocal: Vector3,
  folga: number,
  razaoDaFolga: string,
): VolumeDeContato {
  return {
    no,
    meiaExtensao: meiaExtensao.clone().addScalar(folga),
    centroLocal: centroLocal.clone(),
    folga,
    razaoDaFolga,
  };
}

/**
 * Um ponto do mundo está dentro do volume?
 *
 * O ponto é levado ao espaço local do nó antes do teste, e é essa conversão que
 * torna o volume solidário à peça: a peça girada gira o volume junto, de graça.
 * Testar em coordenadas de mundo obrigaria a recalcular a caixa a cada quadro, e
 * a caixa recalculada de um objeto girado cresce — o volume aumenta sozinho
 * quando a peça inclina, e a captura fica maior num ângulo do que no outro.
 */
export function contemPonto(volume: VolumeDeContato, pontoDeMundo: Vector3): boolean {
  volume.no.updateWorldMatrix(true, false);
  const local: Vector3 = volume.no.worldToLocal(pontoDeMundo.clone()).sub(volume.centroLocal);
  return (
    Math.abs(local.x) <= volume.meiaExtensao.x &&
    Math.abs(local.y) <= volume.meiaExtensao.y &&
    Math.abs(local.z) <= volume.meiaExtensao.z
  );
}

/** Distância do centro do volume até um ponto do mundo. É o que decide qual encaixe está mais perto. */
export function distanciaAoCentro(volume: VolumeDeContato, pontoDeMundo: Vector3): number {
  const centro: Vector3 = volume.no.localToWorld(volume.centroLocal.clone());
  return centro.distanceTo(pontoDeMundo);
}

/**
 * A exibição de depuração dos volumes: um contorno em linha, filho do próprio nó.
 *
 * Isto não é enfeite. O volume é a única parte do ambiente que decide
 * comportamento sem aparecer, e um recurso barato que o torna visível durante a
 * construção economiza dois módulos de confusão — a peça que gruda longe e a que
 * atravessa o encaixe têm o mesmo sintoma para quem olha só a tela.
 */
export function contornoDoVolume(volume: VolumeDeContato): LineSegments {
  const caixa: BoxGeometry = new BoxGeometry(
    volume.meiaExtensao.x * 2,
    volume.meiaExtensao.y * 2,
    volume.meiaExtensao.z * 2,
  );
  const contorno: LineSegments = new LineSegments(
    new EdgesGeometry(caixa),
    new LineBasicMaterial({ color: 0x4ade80 }),
  );
  contorno.name = `volume-${volume.no.name}`;
  contorno.position.copy(volume.centroLocal);
  contorno.visible = false;
  volume.no.add(contorno);
  caixa.dispose();
  return contorno;
}

/** Registro dos volumes da cena, com a exibição de depuração ligada e desligada de uma vez. */
export class RegistroDeContato {
  private readonly volumes: Map<string, VolumeDeContato> = new Map<string, VolumeDeContato>();
  private readonly contornos: LineSegments[] = [];
  private exibindo: boolean = false;

  registrar(chave: string, volume: VolumeDeContato): void {
    this.volumes.set(chave, volume);
    this.contornos.push(contornoDoVolume(volume));
  }

  volume(chave: string): VolumeDeContato | undefined {
    return this.volumes.get(chave);
  }

  alternarExibicao(): boolean {
    this.exibindo = !this.exibindo;
    for (const contorno of this.contornos) {
      contorno.visible = this.exibindo;
    }
    return this.exibindo;
  }

  /** Uma linha por volume, com a folga e a razão dela. É o que a tutoria lê para conferir a escolha. */
  linhas(): string[] {
    const saida: string[] = [];
    for (const [chave, volume] of this.volumes) {
      const cm: (n: number) => string = (n) => (n * 100).toFixed(1);
      saida.push(
        `${chave}: caixa de ${cm(volume.meiaExtensao.x * 2)} x ` +
          `${cm(volume.meiaExtensao.y * 2)} x ${cm(volume.meiaExtensao.z * 2)} cm, ` +
          `folga de ${cm(volume.folga)} cm — ${volume.razaoDaFolga}`,
      );
    }
    return saida;
  }
}

A caixa sai da geometria, e não do nó montado na cena. Assim ela já nasce no espaço local da peça, que é onde o volume tem de viver: ele gira, escala e é reparentado junto, de graça. Volume guardado em coordenadas de mundo se desprende do objeto no primeiro deslocamento.

O teste leva o ponto ao espaço local antes de comparar. Isso parece detalhe e decide comportamento. Recalcular a caixa em coordenadas de mundo a cada quadro faz o volume de uma peça inclinada crescer sozinho, e a captura fica maior num ângulo do que no outro.

1.4.2 Os dois defeitos simétricos, medidos

A folga é o número que decide tudo, e ela erra para os dois lados.

Folga grande captura o que passa perto. A peça gruda no ar, a um palmo do encaixe, e quem usa não entende por quê. Folga pequena recusa o que já encostou: a peça atravessa o socket sem prender, a tentativa seguinte é feita com mais força, e a força piora a mira.

A oficina demonstra o primeiro caso com número. Um ponto de teste é posto a 12 cm do centro da engrenagem grande, o que é fora dela com margem. Com a folga adotada de 2 cm, o volume não o captura, e é isso o que se quer, porque ali a mão ainda não encostou. Com uma folga de 6 cm, captura.

As folgas do projeto não são iguais entre si, e a razão de cada uma está escrita ao lado dela. O eixo leva 0,8 cm porque é fino: a folga que serve para apanhar uma engrenagem de quinze centímetros o afogaria, e qualquer raio que cruzasse a vizinhança o roubaria do alvo pretendido.

1.4.3 O contorno que torna visível o invisível

Como se depura o que não se desenha?

Desenhando. Cada volume ganha um contorno em linha, filho do próprio nó, desligado por padrão e ligado por um botão. É o recurso de depuração mais barato do percurso inteiro: a peça que gruda longe e a que atravessa o encaixe têm exatamente o mesmo sintoma para quem olha só a tela.

Com o contorno à vista, os dois casos ficam distintos em um olhar. A caixa que engole a vizinha explica a primeira; a caixa menor que a silhueta explica a segunda. O botão fica na página desde já, e é ele que a tutoria pede para ligar antes de qualquer conversa sobre encaixe.

1.4.4 Onde é fácil errar, e como conferir

O erro desta tarefa é não escrever volume nenhum.

Ele custa zero hoje. A cena desenha igual, a nota do módulo não depende dele, e nada na tela sugere que falte alguma coisa. O preço aparece no módulo de manipulação, quando pegar e encaixar chegam e não há contra o que testar — e ali a correção custa reescrever o que já estava funcionando.

A conferência é o relatório das caixas, lido em centímetros ao lado da razão escrita de cada folga. Volume sem justificativa é número tirado do ar, e número tirado do ar é o que a tutoria procura. Peça manipulável sem linha nesse relatório é peça que não vai ser apanhada.

1.5 Modelar fora, e o que a viagem cobra

Ferramenta externa entra no módulo seguinte, e o que se perde ao começar por ela é vocabulário.

Nada impede modelar a engrenagem numa ferramenta de autoria e trazê-la pronta. É o que a indústria faz, e o módulo seguinte trata disso com seriedade, incluindo o formato de troca e o orçamento da cena com ativos de verdade.

O que se perde ao começar por ali é o vocabulário. Quem nunca escreveu uma normal não tem como diagnosticar a peça que chegou escura, e quem nunca distribuiu uma coordenada de textura não reconhece a costura na primeira vez que a vê. A ferramenta externa resolve os quatro atributos por você e não os ensina.

Há também o que a viagem cobra em números, e ele é concreto. Uma malha exportada de ferramenta costuma chegar com contagem de triângulos decidida para render offline, com material que assume iluminação que a cena não tem, e com escala em outra unidade. Os três defeitos são invisíveis na ferramenta e caros no ambiente em execução.

1.6 Por dentro da implementação

O módulo anterior deixou uma árvore montada, com parentesco justificado e peças de forma provisória. Este acrescenta sete coisas, e nenhuma delas mexe naquela árvore: o que entra é uma camada que a veste depois de ela subir.

alteração o que ela passou a fazer
perfil-e-extrusao a forma de uma peça sai de um contorno escrito aqui, em vez de um construtor da biblioteca
catalogo-de-superficies a superfície é declarada por propriedade física, e uma instância serve a várias peças
vestir-a-arvore a cena do módulo anterior recebe forma, material e volume sem ser remontada
volume-de-contato cada peça manipulável ganha uma caixa própria, derivada da malha e engordada por uma folga
teste-de-captura a folga escolhida passa a ser conferida por medição, e não por opinião
contorno-alternado os volumes invisíveis podem ser exibidos por um botão, durante a construção
folha-do-conteudo forma, superfície e volume de cada peça viram texto legível fora da cena

A costura com o que já existia cabe numa frase: vestirCena() pede a cena.pecas os nós que montarCena() criou e troca o conteúdo deles no lugar. Nenhum nó é criado, removido ou reparentado aqui, e a estrutura continua pertencendo ao módulo da estrutura.

1.6.1 O perfil autorado, e a extrusão que o levanta

O cenário é a construção da engrenagem grande. perfilDeEngrenagem(14, 0.068, 0.078) devolve 56 pontos, quatro por dente, e extrudarPerfil() os levanta em 338 vértices e 224 triângulos — os números que a folha do marco imprime.

flowchart TD
  ini["vestirCena() pede a forma de uma peca"] --> qual{"dente ou prisma?"}
  qual -->|"engrenagem"| eng["perfilDeEngrenagem(14, 0.068, 0.078)"]
  qual -->|"eixo"| reg["perfilRegular(12, 0.012)"]
  eng --> pol["polar(raio, angulo), quatro pontos por dente"]
  reg --> pol
  pol --> perf["perfil fechado com 56 pontos"]
  perf --> ext["extrudarPerfil(perfil, 0.016, engrenagem-grande)"]
  ext --> med["mede raioMaximo e perimetro com ponto(perfil, i)"]
  med --> topo["face de cima: vertice() no centro e um por ponto do perfil"]
  topo --> ttri["triangulo(centroTopo, indiceDe(topo, i + 1), indiceDe(topo, i))"]
  ttri --> base["face de baixo: os mesmos pontos na ORDEM INVERTIDA"]
  base --> casca{"para cada aresta do perfil"}
  casca -->|"nx e nz perpendiculares a aresta"| quatro["quatro chamadas de vertice(), todas com a mesma normal"]
  quatro --> quad["quadrilatero(a, b, c, d)"]
  quad --> casca
  casca -->|"56 arestas percorridas"| fim["finalizar(engrenagem-grande)"]
  fim --> saida["BufferGeometry com 338 vertices e 224 triangulos"]

A conta dos 338 fecha à mão, e vale fazê-la diante da turma: 57 vértices na face de cima, que são o centro mais um por ponto do perfil; outros 57 na de baixo; e 224 na casca lateral, quatro por aresta, nenhum compartilhado com a aresta vizinha. O eixo percorre o mesmo caminho com perfilRegular(12, 0.012) e sai com 74 vértices e 48 triângulos.

sequenceDiagram
  autonumber
  participant pecas as content/pecas.ts
  participant malha as content/malha.ts
  pecas->>malha: perfilDeEngrenagem(14, 0.068, 0.078)
  loop uma vez por dente, catorze vezes
    malha->>malha: polar(raioDaRaiz, inicio)
    malha->>malha: polar(raioDoTopo, inicio + passo * 0.25)
  end
  malha-->>pecas: 56 pontos, quatro por dente
  pecas->>malha: extrudarPerfil(perfil, 0.016, "engrenagem-grande")
  malha->>malha: ponto(perfil, i) para medir raioMaximo e perimetro
  malha->>malha: vertice(...) na face de cima, 57 chamadas
  malha->>malha: indiceDe(topo, i + 1) para fechar o anel no ultimo ponto
  malha->>malha: vertice(...) e triangulo(...) na face de baixo, na ordem natural
  loop uma aresta por vez, 56 vezes
    malha->>malha: vertice(...) quatro vezes, com nx e nz proprios da aresta
    malha->>malha: quadrilatero(a, b, c, d)
  end
  malha->>malha: finalizar("engrenagem-grande")
  malha-->>pecas: BufferGeometry com 338 vertices e 224 triangulos

Duplicar vértice em toda face, e o que isso custou. A alternativa é compartilhar os pontos entre faces vizinhas, que é o que qualquer construtor da biblioteca faz, e a engrenagem sairia com bem menos de 338 vértices. O custo do compartilhamento é a normal: um vértice só teria de servir a duas faces de orientações diferentes, e a média entre elas arredonda o canto. Peça mecânica com canto macio parece brinquedo, e a impressão custa mais do que os vértices economizados.

A ordem invertida na face de cima, e o que a ordem certa evita. A alternativa seria percorrer as duas faces na mesma ordem, que é o que a leitura ingênua sugere. O custo aparece só de um certo ângulo: a face percorrida no sentido errado deixa de ser desenhada, e a peça ganha um buraco por onde se vê o próprio interior. Não há erro nem aviso — o que existe é uma engrenagem que some pela metade quando a câmera passa por cima, e trinta linhas plausíveis para inspecionar.

As coordenadas de textura escritas agora, sem textura alguma para usar. A alternativa é deixá-las para o módulo em que a primeira textura entrar. O custo é que a distribuição errada não produz sintoma até lá, e ali o sintoma aparece como desenho esticado numa face e comprimido na vizinha — um defeito de geometria diagnosticado como defeito de imagem.

1.6.2 O catálogo, e a instância compartilhada

O cenário é a abertura da página do marco. montarCatalogo() roda uma vez, cria quatro superfícies, e linhasDoCatalogo() abre o relatório com a linha que interessa: Materiais distintos na cena: 4.

flowchart TD
  ini["montarCatalogo()"] --> aco["superficie(aco-escovado, rugosidade 0.35, metalicidade 0.90)"]
  aco --> lat["superficie(latao, rugosidade 0.45, metalicidade 0.75)"]
  lat --> pla["superficie(plastico-fosco, rugosidade 0.85, metalicidade 0.05)"]
  pla --> mad["superficie(madeira, rugosidade 0.80, metalicidade 0.00)"]
  mad --> cat["Catalogo com quatro MaterialDaOficina"]
  cat --> uso{"que superficie a peca recebe?"}
  uso -->|"engrenagem grande e pequena"| mesmo["as duas apontam para catalogo.latao.material, a MESMA instancia"]
  uso -->|"eixo"| aco2["catalogo.acoEscovado.material"]
  uso -->|"corpo e tampa"| pla2["catalogo.plasticoFosco.material"]
  mesmo --> lin["linhasDoCatalogo(catalogo)"]
  aco2 --> lin
  pla2 --> lin
  lin --> conta["primeira linha: Materiais distintos na cena: 4"]
  conta --> det["uma linha por superficie, com rugosidade e metalicidade"]

As duas engrenagens recebem catalogo.latao.material, e recebem a mesma instância — a mesma referência, guardada uma vez e apontada duas.

sequenceDiagram
  autonumber
  participant demo as 04_demo.ts
  participant pecas as content/pecas.ts
  participant mat as content/materiais.ts
  pecas->>mat: montarCatalogo()
  loop quatro superficies
    mat->>mat: superficie(nome, cor, rugosidade, metalicidade, entrega, custo)
    mat-->>mat: MaterialDaOficina com descricao e MeshStandardMaterial
  end
  mat-->>pecas: Catalogo
  pecas->>pecas: grande.material recebe catalogo.latao.material
  pecas->>pecas: pequena.material recebe a MESMA instancia de latao
  demo->>mat: linhasDoCatalogo(conteudo.catalogo)
  mat-->>demo: 5 linhas, a primeira dizendo "Materiais distintos na cena: 4"

Um latão para as duas engrenagens, e o que dois latões custariam. A alternativa é chamar superficie() uma vez por peça, o que produziria dois materiais idênticos em valor e distintos em identidade. O custo é uma troca de estado a mais por quadro: material é um programa compilado para a placa, e ela não compara valores para descobrir que os dois programas fazem a mesma coisa. Num catálogo de quatro superfícies isso é invisível; a disciplina inteira roda sobre vídeo integrado, e o hábito é o que se está formando aqui.

A descrição por propriedade, e o que a cor chapada custaria. A alternativa é declarar “um cinza brilhante” e acabar. O custo aparece na mudança de regime: a peça descrita por cor fica igual sob a luz da sala do desktop e sob o passthrough da câmera, o que a faz parecer recortada e colada no segundo caso. A superfície descrita por rugosidade e metalicidade responde à luz que houver, e é isso que a torna coerente nos três regimes sem uma linha por regime.

1.6.3 Vestir a árvore, peça a peça

O cenário é a mesma abertura. vestirCena(cena) roda uma vez e devolve um inventário de cinco linhas somando 440 triângulos — 224 na engrenagem grande, 144 na pequena, 48 no eixo e 12 em cada primitiva.

flowchart TD
  ini["vestirCena(cena)"] --> cat["montarCatalogo() e um RegistroDeContato vazio"]
  cat --> ex["exigirMalha(cena.pecas.get(engrenagem-grande), engrenagem-grande)"]
  ex --> tipo{"o no existe e e Mesh?"}
  tipo -->|"nao"| erro["throw Error: a cena nao trouxe a peca como malha"]
  tipo -->|"sim"| troca["trocarMalha(grande, extrudarPerfil(...))"]
  troca --> disp["a geometria anterior e liberada ANTES de a nova ser instalada"]
  disp --> mater["grande.material recebe a superficie do catalogo"]
  mater --> resto["o mesmo caminho para pequena, eixo, corpo e tampa"]
  resto --> laco{"para cada uma das cinco pecas"}
  laco -->|"FOLGAS.get(id) indefinido"| erro2["throw Error: peca na cena sem folga declarada"]
  laco -->|"folga encontrada"| vol["contato.registrar(id, volumeDaMalha(malha, folga.metros, folga.razao))"]
  vol --> inv["inventario recebe origem, contarTriangulos() e contarVertices()"]
  inv --> laco
  laco -->|"cinco percorridas"| soma["totalDeTriangulos = 440"]
  soma --> fim["devolve Conteudo com catalogo, contato e inventario"]

Duas peças recusam a viagem inteira, e a recusa está escrita no arquivo. O corpo é uma caixa e a tampa é uma placa, e para o que é caixa mesmo pedir a caixa é a decisão certa. A geometria autorada vai onde a forma carrega significado.

sequenceDiagram
  autonumber
  participant demo as 04_demo.ts
  participant pecas as content/pecas.ts
  participant cena as core/cena.ts
  participant malha as content/malha.ts
  participant col as content/colisao.ts
  demo->>pecas: vestirCena(cena)
  pecas->>cena: cena.pecas.get("engrenagem-grande")
  cena-->>pecas: o Object3D montado no modulo anterior
  pecas->>pecas: exigirMalha(no, "engrenagem-grande")
  pecas-->>pecas: Mesh
  pecas->>malha: extrudarPerfil(perfilDeEngrenagem(14, 0.068, 0.078), 0.016, "engrenagem-grande")
  malha-->>pecas: BufferGeometry de 224 triangulos
  pecas->>pecas: trocarMalha(grande, nova)
  loop cinco pecas
    pecas->>col: volumeDaMalha(malha, folga.metros, folga.razao)
    col-->>pecas: VolumeDeContato ja com a folga somada
    pecas->>malha: contarTriangulos(malha.geometry)
    malha-->>pecas: 224 na engrenagem grande, 12 no corpo
  end
  pecas-->>demo: Conteudo com inventario de cinco linhas e 440 triangulos

Vestir depois, em vez de montar a cena já vestida. A alternativa é escrever a geometria autorada dentro de montarCena(), que é onde a peça nasce, e economizar um arquivo. O custo é que os dois módulos passam a ter um assunto só entre eles: a estrutura e a forma deixariam de poder ser exibidas lado a lado, e o marco anterior — que existe para mostrar uma cena feia com parentesco correto — sairia bonito e sem assunto.

exigirMalha() derrubando a execução, em vez de seguir com o que veio. A alternativa é aceitar qualquer nó e ignorar o que não for malha. O custo é o silêncio: a peça continuaria na cena com a forma provisória, ninguém receberia mensagem alguma, e a diferença entre “esta peça é uma caixa por decisão” e “esta peça é uma caixa porque a troca falhou” desapareceria da tela. Um erro lançado no ato nomeia a peça.

A folga buscada em FOLGAS com falha dura quando não há. A alternativa é um valor padrão para quem não declarou. O custo é que a peça sem folga pensada passaria a ter uma folga plausível, que é exatamente o número que ninguém revisa depois.

1.6.4 O volume que ninguém vê

O cenário é o registro da engrenagem grande. volumeDaMalha() recebe a folga de 2 cm e devolve uma caixa de 19,5 por 5,6 por 19,6 centímetros, que é a silhueta da peça mais dois centímetros em cada direção.

flowchart TD
  ini["volumeDaMalha(grande, 0.02, a mao fecha sobre a silhueta)"] --> bb["no.geometry.computeBoundingBox()"]
  bb --> nula{"boundingBox === null"}
  nula -->|"sim"| erro["throw Error: a malha nao tem caixa envolvente, e o volume sai dela"]
  nula -->|"nao"| centro["centroLocal = caixa.getCenter(new Vector3())"]
  centro --> tam["tamanho = caixa.getSize(new Vector3())"]
  tam --> meia["meiaExtensao = tamanho / 2 + folga, nos tres eixos"]
  meia --> vol["VolumeDeContato: caixa de 19.5 x 5.6 x 19.6 cm"]
  vol --> reg["registrar(engrenagem-grande, volume)"]
  reg --> cont["contornoDoVolume(volume)"]
  cont --> filho["LineSegments volume-engrenagem-grande, filho do proprio no"]
  filho --> oculto["visible = false: o contorno existe e nao aparece"]

O contorno nasce junto, pendurado no próprio nó, e nasce invisível.

sequenceDiagram
  autonumber
  participant pecas as content/pecas.ts
  participant col as content/colisao.ts
  participant reg as RegistroDeContato
  pecas->>col: volumeDaMalha(grande, 0.02, "a mao fecha sobre a silhueta")
  col->>col: no.geometry.computeBoundingBox()
  col-->>pecas: VolumeDeContato com meiaExtensao de 9.8 x 2.8 x 9.8 cm
  pecas->>reg: registrar("engrenagem-grande", volume)
  reg->>col: contornoDoVolume(volume)
  col-->>reg: LineSegments "volume-engrenagem-grande", com visible false
  reg-->>pecas: volume guardado sob a chave da peca

A caixa derivada da geometria, e não do nó. A alternativa é medir o nó montado, que é a chamada mais curta e a que quase todo exemplo usa. O custo tem dois lados. O primeiro é que a medida sairia em coordenadas de mundo, e o volume se desprenderia da peça no primeiro deslocamento. O segundo é mais sorrateiro: o contorno de depuração é filho do nó, então medir o nó inteiro incluiria o contorno na conta, e cada recálculo engordaria a caixa sobre a caixa anterior. A peça ficaria mais fácil de apanhar a cada vez que alguém pedisse a medida.

A folga somada na criação, e não no teste. A alternativa é guardar a meia-extensão crua e somar a folga a cada comparação. O custo é aritmético e é por quadro: três somas em cada teste, multiplicadas pelo número de volumes e pelo número de ponteiros. Somar uma vez, na criação, deixa contemPonto() com três comparações e nada mais.

1.6.5 As duas folgas, medidas

O cenário tem número. Um ponto de teste é posto a 12 cm do centro da engrenagem grande, deslocado no eixo horizontal, e a pergunta é se o volume o captura. Com a folga adotada de 2,0 cm a meia-extensão é de 9,8 cm, e o volume não captura, que é o que se quer — ali a mão ainda não encostou. Com uma folga de 6,0 cm a meia-extensão vai a 13,8 cm, e o volume captura.

flowchart TD
  ini["compararFolgas(conteudo, 0.12, 0.06)"] --> busca["conteudo.contato.volume(engrenagem-grande)"]
  busca --> falta{"volume === undefined"}
  falta -->|"sim"| erro["throw Error: a comparacao depende do volume registrado"]
  falta -->|"nao"| centro["centro = volume.no.localToWorld(centroLocal)"]
  centro --> ponto["pontoDeTeste = centro deslocado 12 cm no eixo x"]
  ponto --> gen["volumeDaMalha(volume.no, 0.06, folga exagerada so para a comparacao)"]
  gen --> t1["contemPonto(volume, pontoDeTeste)"]
  t1 --> r1{"12 cm cabe na meia-extensao de 9.8 cm?"}
  r1 -->|"nao"| falso["false: a folga adotada NAO captura quem passou perto"]
  falso --> t2["contemPonto(generoso, pontoDeTeste)"]
  t2 --> r2{"12 cm cabe na meia-extensao de 13.8 cm?"}
  r2 -->|"sim"| verd["true: a folga generosa captura, e a peca gruda no ar"]

A frase que o marco imprime sai de frasesDaFolga(), e ela é a demonstração inteira: com a folga adotada, não captura; com seis centímetros, captura. O segundo caso é a peça grudando em quem só passou perto.

sequenceDiagram
  autonumber
  participant demo as 04_demo.ts
  participant pecas as content/pecas.ts
  participant col as content/colisao.ts
  demo->>pecas: compararFolgas(conteudo, 0.12, 0.06)
  pecas->>pecas: conteudo.contato.volume("engrenagem-grande")
  pecas->>col: volumeDaMalha(volume.no, 0.06, "folga deliberadamente exagerada")
  col-->>pecas: VolumeDeContato de meia-extensao 13.8 cm
  pecas->>col: contemPonto(volume, pontoDeTeste)
  col->>col: leva o ponto ao espaco local do no antes de comparar
  col-->>pecas: false
  pecas->>col: contemPonto(generoso, pontoDeTeste)
  col-->>pecas: true
  pecas-->>demo: ComparacaoDeFolga com os dois resultados
  demo->>demo: frasesDaFolga()

A demonstração como código executável, e não como parágrafo. A alternativa era afirmar em prosa que folga demais captura o que passa perto. O custo é que ninguém consegue discordar de um parágrafo com um número: a discussão sobre folga em grupo de projeto degenera em preferência estética, e cada integrante defende a sua sem nada que os separe. Um booleano medido com o mesmo ponto encerra a conversa.

O ponto de teste ao lado, e não encostado. A alternativa é o defeito simétrico — ponto encostado e folga zero, para mostrar a recusa do que já tocou. Ele não tem demonstração própria porque a caixa da malha dentada já o contém: o dedo que entra pelo vale entre dois dentes está dentro do retângulo e fora da peça, e é a própria caixa que salva o caso. O defeito da folga apertada aparece no encaixe, e é lá que ele tem cenário.

1.6.6 O contorno, ligado por um botão

O cenário é um clique. A página do marco abre com o botão dizendo Mostrar os volumes de contato; depois do clique ele diz Esconder os volumes de contato, e as cinco caixas verdes aparecem sobre as peças.

flowchart TD
  ini["clique no botao Mostrar os volumes de contato"] --> alt["alternarExibicao()"]
  alt --> inv["exibindo passa de false para true"]
  inv --> laco{"para cada contorno registrado"}
  laco -->|"cinco LineSegments"| set["contorno.visible = exibindo"]
  set --> laco
  laco -->|"lista esgotada"| dev["devolve true"]
  dev --> rot{"exibindo?"}
  rot -->|"true"| esconder["botaoVolumes passa a dizer Esconder os volumes de contato"]
  rot -->|"false"| mostrar["botaoVolumes volta a dizer Mostrar os volumes de contato"]

O rótulo do botão sai do valor devolvido por alternarExibicao(), e essa é a única razão de a função devolver algo em vez de nada.

sequenceDiagram
  autonumber
  participant botao as botaoVolumes
  participant demo as 04_demo.ts
  participant reg as RegistroDeContato
  botao->>demo: evento de clique
  demo->>reg: alternarExibicao()
  loop cinco contornos guardados
    reg->>reg: contorno.visible = exibindo
  end
  reg-->>demo: true
  demo->>botao: textContent = "Esconder os volumes de contato"

O contorno criado sempre e escondido, em vez de criado sob demanda. A alternativa é montar as linhas na hora em que alguém pedir para vê-las. O custo é que o contorno passaria a ser construído a partir do estado do momento, e o instrumento que existe para conferir a caixa deixaria de mostrar a caixa registrada. Cinco conjuntos de arestas ocupando memória invisível é um preço que cabe com folga no orçamento; medir uma coisa e desenhar outra, não.

O botão na página, em vez de na cena. A alternativa é pendurar o comando no painel diegético, que já existe e já é um objeto do mundo. O custo é de ordem: ainda não há ponteiro, e sem ponteiro não há como apertar nada dentro da cena. O botão desce para o painel no módulo em que houver com o que apontar para ele.

1.6.7 A folha do conteúdo

O cenário é a mesma abertura da página. montarConteudo() escreve três seções, e a última traz as cinco linhas que linhas() produz — cada volume em centímetros, com a folga e a razão dela ao lado.

flowchart TD
  ini["montarConteudo(raizConteudo, inventario, materiais, volumes, comparacao)"] --> limpa["raiz.replaceChildren()"]
  limpa --> s1["subtitulo(De onde veio a forma de cada peca)"]
  s1 --> l1{"uma linha por peca do inventario"}
  l1 -->|"cinco"| p1["paragrafo(eixo: forma autorada, 48 triangulos e 74 vertices)"]
  p1 --> l1
  l1 -->|"fim"| s2["subtitulo(As superficies, e o que cada uma cobra)"]
  s2 --> l2["um paragrafo por linha de linhasDoCatalogo()"]
  l2 --> s3["subtitulo(O que se ve e o que colide)"]
  s3 --> l3["um paragrafo por linha de linhas(), do RegistroDeContato"]
  l3 --> comp["paragrafo(comparacao): a frase da folga medida em centimetros"]

A linha do eixo é a que a tutoria costuma parar para ler: caixa de 4,0 por 23,6 por 4,0 cm, folga de 0,8 cm, e a razão escrita de por que ela é a menor da lista.

sequenceDiagram
  autonumber
  participant demo as 04_demo.ts
  participant reg as RegistroDeContato
  participant rel as relatorio/conteudo.ts
  participant cap as relatorio/capacidades.ts
  demo->>reg: linhas()
  reg-->>demo: cinco linhas, uma por volume, com a folga e a razao dela
  demo->>rel: montarConteudo(raizConteudo, inventario, materiais, volumes, comparacao)
  rel->>cap: subtitulo("De onde veio a forma de cada peca")
  loop uma vez por linha recebida
    rel->>cap: paragrafo(linha)
    cap-->>rel: elemento de paragrafo
  end
  rel-->>demo: folha do conteudo escrita na pagina

A razão da folga guardada junto do número, e não em comentário. A alternativa é escrever a justificativa num comentário ao lado da constante, que é onde ela naturalmente cairia. O custo é que o comentário não sai do arquivo: quem lê o relatório vê folga de 0.8 cm e não tem como saber se aquilo foi pensado ou digitado. Levar a frase junto do valor obriga quem escolhe o número a escrever a razão na mesma linha, e é essa obrigação — mais do que o texto resultante — que a estrutura existe para criar.

O volume relatado em texto, e não só desenhado. A alternativa é confiar no contorno, que já mostra a caixa. O custo é a precisão: a olho, duas caixas parecem próximas ou distantes, e ninguém estima dois centímetros olhando uma projeção. O contorno responde onde; o relatório responde quanto.

1.7 Verificação do estado deste módulo

O que precisa estar verdadeiro antes de o módulo seguinte começar.

Verificação Como se confere
Parte das peças tem geometria escrita no projeto ler o inventário e conferir quais dizem geometria autorada
A escolha entre autorar e pedir tem razão dita perguntar por que o corpo é primitiva e a engrenagem não
Nenhuma face está com a normal invertida girar cada peça contra a luz direcional e observar a lateral
A densidade da malha tem justificativa ler a contagem de triângulos por peça e o total
Os materiais são compartilhados entre peças iguais conferir que as duas engrenagens citam a mesma superfície
Toda peça manipulável tem volume de contato ler o relatório das caixas e cruzar com a lista de peças
Cada folga tem razão escrita ao lado do número ler a justificativa e recusar as que forem genéricas
Os volumes podem ser exibidos durante a construção ligar o contorno e observar sobreposição entre caixas
O código analisa limpo em modo estrito verificação de tipos do projeto, sem emissão

A penúltima linha é a que decide o módulo de manipulação. Duas caixas que se sobrepõem em repouso vão brigar pela mesma pega no dia em que houver pega, e descobrir isso agora custa mover uma peça dez centímetros.

O que fica pronto ao fim deste módulo é uma oficina com forma própria e com a metade invisível já escrita. As peças têm dente, faceta e superfície com custo declarado. E cada uma delas carrega uma caixa que ninguém vê, com uma folga que alguém justificou — que é a diferença entre uma cena para olhar e uma cena para trabalhar dentro.