Files
SteelXR2/melhorias.md
T

19 KiB

TrackSteelXR — Análise de Melhorias

Data da análise: 2026-05-15
Analisado por: Hermes (VPS BrainSteel)
Escopo: /host_root/root/Apps/tracksteelxr/ (98 arquivos, ~7647 linhas TS/TSX)


1. DIAGNÓSTICO GERAL

Stack Tecnológica

  • Frontend: React 18 + TypeScript 5 + Vite
  • 3D Engine: Three.js 0.160 + @react-three/fiber + @react-three/drei
  • UI: shadcn-ui + Radix UI + Tailwind CSS
  • State: Zustand 4.5
  • PDF: jsPDF 4.2
  • Format support: GLB, OBJ, STL, IFC via web-ifc
  • Gerenciador: Bun (bun.lockb)

Qualidade Geral

  • Projeto bem estruturado com separação de concerns
  • Boas práticas de tipagem TypeScript
  • Componentes bem modularizados (UI, 3D, stores, hooks)
  • Algoritmos de SmartMeasure interesting (circle fit, vertex snap)
  • ⚠️ Performance comprometida por loops desnecessários
  • ⚠️ Memory leaks em event handlers
  • ⚠️ Dependências desatualizadas (CVE risks)
  • ⚠️ Validação de input ausente

2. CRÍTICAS URGENTES (🔴 Alta Prioridade)

2.1 Performance: Loop Infinito Anula frameloop="demand"

Arquivo: src/components/three/ModelViewer.tsx
Linha: ~485 (no onCreated do Canvas)

Problema:

<Canvas
  frameloop="demand"  // deveria renderizar só quando algo muda
  onCreated={({ gl, invalidate }) => {
    const loop = () => { invalidate(); requestAnimationFrame(loop); }; // ❌ RENDERIZA SEMPRE
    loop();
  }}
>

O frameloop="demand" faz o Canvas renderizar apenas quando invalidate() é chamado (normalmente por mudanças de state). Mas o onCreated cria um requestAnimationFrame loop que chama invalidate() a cada frame, forçando renderização contínua — igual ao frameloop="always".

Impacto: Em modelos grandes (>500k triângulos), isso causa 60%+ de uso de CPU desnecessário. Dispositivos móveis根本无法正常运行。

Correção:

<Canvas
  frameloop="demand"  // manter assim
  onCreated={({ gl }) => {
    gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
    // NÃO criar loop aqui
  }}
>

Ou, se precisa de animação constante (não é o caso aqui), usar frameloop="always" e remover o loop manual.


2.2 Memory Leak em SmartSnapHandler e HoverDetector

Arquivo: src/components/three/ModelViewer.tsx
Linhas: ~200-350

Problema: Event listeners adicionados com gl.domElement.addEventListener não são removidos adequadamente quando componentes desmontam ou quando callbacks mudam (muda onMove referência). Cada re-render do componente pode adicionar novos listeners sem remover os antigos.

gl.domElement.addEventListener('pointermove', onMove);
// ...
return () => {
  gl.domElement.removeEventListener('pointermove', onMove);
  // ❌ hoverTimer.current também vaza se callback mudar
};

Impacto: Após extended use, centenas de listeners acumulados no DOM. Memory leak progressivo até tab crash.

Correção:

useEffect(() => {
  if (!measureMode) return () => {};
  
  const onMove = (e: MouseEvent) => { /* ... */ };
  const onLeave = () => { /* ... */ };
  
  gl.domElement.addEventListener('pointermove', onMove);
  gl.domElement.addEventListener('pointerleave', onLeave);
  
  return () => {
    gl.domElement.removeEventListener('pointermove', onMove);
    gl.domElement.removeEventListener('pointerleave', onLeave);
    hoverTimer.current && clearTimeout(hoverTimer.current);
  };
}, [measureMode, camera, scene, gl, raycaster, mouse, setHoverInfo]); // incluir todas deps

Arquivo: src/lib/SmartMeasure.ts
Função: findNearestVertex()

Problema: Para cada mousemove, itera todos os vértices do mesh:

for (let i = 0; i < posAttr.count; i++) {
  _v.fromBufferAttribute(posAttr, i);
  _v.applyMatrix4(mesh.matrixWorld);
  _projected.copy(_v).project(camera);
  // cálculo de distância
}

Modelo STL médio: 100k-500k vértices. Isso é 500k cálculos por mousemove, a 60fps = 30 milhões de operações/segundo.

Impacto: Lag severo em modelos grandes, especialmente em dispositivos com GPU limitada.

Correção (usar Octree spatial indexing):

import { Octree } from 'three/examples/jsm/math/Octree.js';
import { OctreeHelper } from 'three/examples/jsm/objects/OctreeHelper.js';

// Pré-calcular octree uma vez quando modelo carrega
const octree = useMemo(() => {
  const tree = new Octree();
  tree.fromGeometry(mesh.geometry, 8); // 8 = subdivisions
  return tree;
}, [mesh.geometry]);

// Buscar vértices no octree ao invés de iterar todos
const nearbyVertices = octree.search(worldPoint, searchRadius, true);

Alternativa mais simples: reduzir search radius ou usar grid spatial hash.


2.4 Dependências Desatualizadas — Risco de CVE

Arquivo: package.json

Pacote Atual Latest Status
three ^0.160.1 0.170+ ⚠️ Vulnerabilidades conhecidas
@react-three/drei ^9.122.0 9.134+ ⚠️ Atualizar
vite ^5.4.19 6.x disponível 🔴 CVE em versões 5.x < 5.4.11
jspdf ^4.2.0 4.2.0 (atual) Mas monitorar
react-router-dom ^6.30.1 7.x beta ⚠️ Breaking changes em 7

Riscos específicos:

  • Vite 5.x: Múltiplos CVEs de 2024 (CVE-2024-23331, CVE-2024-29041, etc.) permitiriam path traversal e XSS.
  • jsPDF: Histórico de vulnerabilidades de deserialização. Sempre manter atualizado.

Correção:

# Atualizar com cuidado (alguns são breaking changes)
npm update three @react-three/drei vite react-router-dom

# Ou verificar manualmente antes de atualizar
npm audit
npm audit fix

3. REFATORAÇÃO (🟡 Média Prioridade)

3.1 Zustand Store Acoplado — 30+ states misturados

Arquivo: src/stores/useModelStore.ts

Problema: Um único store com responsibilities demais:

  • UI state: opacity, renderMode, anchorMode, showGrid, wireframeColor
  • 3D state: measurePoints, measurements, snapPoint, hoverInfo
  • Business state: checklist, inspectionResult
  • File state: model, compareImage
  • Meta state: screenshots, xrSupported

Cada mudança de qualquer state causa re-render em todos os componentes que usam useModelStore.

Correção: Separar em stores modulares:

// stores/uiStore.ts — UI-only (opacity, renderMode, showGrid, etc.)
const useUIStore = create<UIStore>((set) => ({
  opacity: 1,
  setOpacity: (opacity) => set({ opacity }),
  // ...
}));

// stores/measurementStore.ts — 3D/measurement state
const useMeasurementStore = create<MeasurementStore>((set) => ({
  measurePoints: [],
  addMeasurePoint: (p) => set((state) => /* ... */),
  // ...
}));

// stores/inspectionStore.ts — checklist/inspection
const useInspectionStore = create<InspectionStore>((set) => ({
  checklist: defaultChecklist,
  setChecklistItemStatus: (id, status) => set((state) => /* ... */),
  // ...
}));

// stores/uploadStore.ts — model upload handling
const useUploadStore = create<UploadStore>((set) => ({
  model: null,
  setModel: (model) => set({ model }),
  // ...
}));

Benefício: Components só re-renderizam quando o state específico muda. 50-70% menos re-renders.


3.2 GLBModel.useEffect — 700+ linhas de lógica acoplada

Arquivo: src/components/three/ModelViewer.tsx (função GLBModel)

Problema: Um único useEffect de ~150 linhas que faz:

  1. Material cloning em todos os meshes
  2. Cleanup de edge lines anteriores
  3. Lógica de cor (rejected/approved/default)
  4. Criação de edge lines novas
  5. Atualização de opacity/wireframe

Correção: Extrair para hooks customizados:

// hooks/useMaterialManager.ts
export function useMaterialManager(scene: THREE.Group, renderMode: string) {
  const originalColors = useRef<Map<THREE.Material, THREE.Color>>(new Map());
  
  useEffect(() => {
    // material cloning logic
  }, [scene, renderMode]);
  
  return { originalColors };
}

// hooks/useEdgeRenderer.ts
export function useEdgeRenderer(
  mesh: THREE.Mesh,
  enabled: boolean,
  thresholdAngle: number
) {
  useEffect(() => {
    if (!enabled) return;
    // create edge lines
    return () => { /* cleanup */ };
  }, [mesh, enabled, thresholdAngle]);
}

// hooks/useModelColors.ts
export function useModelColors(
  scene: THREE.Group,
  checklist: ChecklistItem[],
  renderMode: string
) {
  // color logic based on checklist status
}

3.3 convertToGLB.ts — Material Hardcoded

Arquivo: src/lib/convertToGLB.ts

Problema:

const material = new THREE.MeshStandardMaterial({
  color: 0x8899aa,
  metalness: 0.85,
  roughness: 0.35,
});

Material fixo para todos os modelos convertidos. Usuário não pode customizar cor/textura.

Correção:

interface ConvertOptions {
  color?: number;
  metalness?: number;
  roughness?: number;
}

export async function convertToGLB(
  buffer: ArrayBuffer,
  ext: 'obj' | 'stl',
  fileName: string,
  options: ConvertOptions = {}
): Promise<{ blob: Blob; fileName: string; fileSize: number }> {
  const defaultMaterial = {
    color: 0x8899aa,
    metalness: 0.85,
    roughness: 0.35,
    ...options,
  };
  const material = new THREE.MeshStandardMaterial(defaultMaterial);
  // ...
}

3.4 generateReport.ts — Tratamento de Erro Inadequado

Arquivo: src/lib/generateReport.ts

Problema:

try {
  pdf.addImage(data.screenshots[i], 'PNG', margin, y, imgW, imgH);
} catch {
  pdf.text(`[Erro ao inserir screenshot ${i + 1}]`, margin, y);
}

Catch genérico oculta o erro real. Falha silenciosamente sem logging.

Correção:

try {
  pdf.addImage(data.screenshots[i], 'PNG', margin, y, imgW, imgH);
} catch (err) {
  console.error(`[generateReport] Failed to add screenshot ${i + 1}:`, err);
  // Fallback: tentar converter ou redimensionar
  try {
    const imgData = resizeImage(data.screenshots[i], 1920);
    pdf.addImage(imgData, 'PNG', margin, y, imgW, imgH);
  } catch (fallbackErr) {
    pdf.text(`[Screenshot ${i + 1} não disponível]`, margin, y);
  }
}

3.5 Validação de Upload Ausente

Arquivo: src/lib/convertToGLB.ts

Problema:

export function getSupportedExtension(fileName: string): SupportedExt | null {
  const ext = fileName.split('.').pop()?.toLowerCase();
  if (ext === 'glb' || ext === 'obj' || ext === 'stl' || ext === 'ifc') return ext;
  return null;
}

Só checa extensão, não conteúdo. Arquivo .glb com conteúdo corrompido/crasha o loader.

Correção:

export async function validateModelFile(file: File): Promise<ValidationResult> {
  // 1. Check extension
  const ext = file.name.split('.').pop()?.toLowerCase();
  if (!['glb', 'obj', 'stl', 'ifc'].includes(ext)) {
    return { valid: false, error: 'Formato não suportado' };
  }
  
  // 2. Check MIME type (if browser provides)
  const validMimes = {
    glb: ['model/gltf-binary', 'application/octet-stream'],
    obj: ['model/obj', 'text/plain', 'application/octet-stream'],
    stl: ['model/stl', 'application/sla', 'application/octet-stream'],
    ifc: ['application/x-step', 'application/octet-stream'],
  };
  
  // 3. Check file size (max 500MB for IFC, 100MB for others)
  const maxSizes = { ifc: 500 * 1024 * 1024, default: 100 * 1024 * 1024 };
  const maxSize = maxSizes[ext] || maxSizes.default;
  if (file.size > maxSize) {
    return { valid: false, error: `Arquivo muito grande (max ${maxSize / 1024 / 1024}MB)` };
  }
  
  // 4. Try to parse header (for GLB: first 4 bytes = 0x46546C67 = 'glTF')
  if (ext === 'glb') {
    const header = await file.slice(0, 20).arrayBuffer();
    const view = new DataView(header);
    const magic = view.getUint32(0, true);
    if (magic !== 0x46546C67) {
      return { valid: false, error: 'Arquivo GLB inválido (magic bytes incorretos)' };
    }
  }
  
  return { valid: true };
}

4. OTIMIZAÇÕES / BOAS PRÁTICAS (🟢 Baixa Prioridade)

4.1 preserveDrawingBuffer: true Gasto de Memória

Arquivo: src/components/three/ModelViewer.tsx

gl={{
  antialias: true,
  alpha: true,
  powerPreference: 'high-performance',
  preserveDrawingBuffer: true  // ⚠️ CUSTO EXTRA
}}

preserveDrawingBuffer: true mantém o framebuffer em memória após cada render, permitindo toDataURL() para screenshots. Mas isso duplica o consumo de memória GPU.

Solução: Ativar só quando usuário vai fazer screenshot:

// No state
const [screenshots, addScreenshot] = useModelStore(s => [s.screenshots, s.addScreenshot]);

// Mudar Canvas config baseado em necessidade
const glConfig = useMemo(() => ({
  antialias: true,
  alpha: true,
  powerPreference: 'high-performance' as const,
  preserveDrawingBuffer: screenshots.length > 0, // só ativa se há screenshots
}), [screenshots.length]);

4.2 pixelRatio Limitado a 1.5

gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));

Limitar em 1.5 é bom para performance, mas em dispositivos Apple Retina (2x, 3x) poderia ir a 2 sem impacto perceptível na qualidade vs custo de render.

Sugestão:

const maxPixelRatio = /Mac|iPhone|iPad/.test(navigator.platform) ? 2 : 1.5;
gl.setPixelRatio(Math.min(window.devicePixelRatio, maxPixelRatio));

4.3 Test Coverage Ausente

O projeto tem configuração Vitest e ESLint mas:

  • Apenas 2 arquivos de teste placeholder em src/test/
  • Nenhum teste de unidade ou integração
  • Cobertura: 0%

Sugestão de testes prioritários:

// src/lib/__tests__/convertToGLB.test.ts
describe('convertToGLB', () => {
  it('should convert STL buffer to GLB', async () => { /* ... */ });
  it('should convert OBJ buffer to GLB', async () => { /* ... */ });
  it('should reject invalid file types', async () => { /* ... */ });
});

// src/stores/__tests__/useModelStore.test.ts
describe('useModelStore', () => {
  it('should add measurement point after two clicks', () => { /* ... */ });
  it('should clear measurements', () => { /* ... */ });
});

4.4 Fallback para WebXR Inexistente

Arquivo: src/stores/useModelStore.ts

xrSupported: boolean | null;  // null = não checou ainda

Quando xrSupported === false, não há fallback ou mensagem ao usuário.

Sugestão:

// No componente XRSession
const xrSupported = useModelStore(s => s.xrSupported);

if (xrSupported === false) {
  return (
    <div className="flex flex-col items-center justify-center h-full">
      <p className="text-muted-foreground">WebXR não disponível neste dispositivo.</p>
      <Button variant="outline" onClick={() => setAnchorMode('manual')}>
        Usar modo manual
      </Button>
    </div>
  );
}

4.5 Inconsistência Bun/npm no package.json

"scripts": {
  "dev": "vite"  // npm script
}

Mas o projeto usa bun.lockb como lockfile. README menciona npm mas o ambiente usa bun.

Correção: Padronizar para bun ou npm:

# Se usando bun (recomendado - mais rápido)
npm install -g bun
bun install
bun run dev

# OU remover bun.lockb e usar npm
rm bun.lockb
npm install

4.6 Screenshot Compression Ausente

screenshots: string[];  // data:image/png;base64,...

Screenshots em base64 sem compressão crescem indefinidamente na memória. Sessões longas com 10+ screenshots podem causar OOM em dispositivos móveis.

Sugestão:

async function compressScreenshot(dataUrl: string, quality = 0.8): Promise<string> {
  return new Promise((resolve) => {
    const img = new Image();
    img.onload = () => {
      const canvas = document.createElement('canvas');
      const maxDim = 1920;
      const scale = Math.min(maxDim / img.width, maxDim / img.height, 1);
      canvas.width = img.width * scale;
      canvas.height = img.height * scale;
      
      const ctx = canvas.getContext('2d')!;
      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
      
      resolve(canvas.toDataURL('image/jpeg', quality));
    };
    img.src = dataUrl;
  });
}

4.7 Audio Validation Ausente

audioUrl: string | null;

Não valida se URL é blob:// ou file:// válido. Não limpa strings maliciosas.

Sugestão:

function isValidAudioUrl(url: string): boolean {
  if (!url) return true; // null é válido
  try {
    const parsed = new URL(url);
    return ['blob:', 'file:'].includes(parsed.protocol);
  } catch {
    return false;
  }
}

function sanitizeAudioUrl(url: string | null): string | null {
  if (!url) return null;
  return isValidAudioUrl(url) ? url : null;
}

5. SEGURANÇA

5.1 Nenhuma Sanitização de Input de Arquivo

  • Upload aceita qualquer extensão de arquivo
  • Não há validação de MIME type real
  • Não há scan de conteúdo
  • Não há limite de tamanho de arquivo

Recomendação: Implementar validação conforme item 3.5.


5.2 Nenhuma Proteção Contra DoS por Memória

  • Arquivos IFC podem ter gigabytes
  • web-ifc carrega arquivo completo em memória
  • Não há limite de tamanho de arquivo antes do parsing

Recomendação:

const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB

function validateIFCFile(file: File): boolean {
  if (file.size > MAX_FILE_SIZE) {
    throw new Error(`Arquivo IFC muito grande: ${file.size / 1024 / 1024}MB (max 500MB)`);
  }
  return true;
}

5.3 Sem CSP Headers ou XSS Protection

TrackSteelXR é uma React SPA sem headers de segurança configurados. web-ifc carrega arquivos potencialmente maliciosos.

Recomendação: Configurar CSP no servidor que servir a app (ex: Nginx, Caddy, Cloudflare).

Exemplo CSP mínimo:

Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self';

6. RESUMO PRIORITÁRIO

Prioridade Item Impacto Esforço
🔴 Alta Remover loop em onCreated 60%+ CPU 5 min
🔴 Alta Fix memory leak listeners Estabilidade 15 min
🔴 Alta Update vite (CVE) Segurança 10 min
🟡 Média Octree para vertex search Performance 1-2h
🟡 Média Separate Zustand stores Manutenibilidade 2-3h
🟡 Média Validação de upload Segurança 1h
🟡 Média Fix generateReport error handling Confiabilidade 30 min
🟢 Baixa Test coverage Confiabilidade 4h+
🟢 Baixa Screenshot compression Performance 1h
🟢 Baixa Configurar CSP headers Segurança 30 min

7. PRÓXIMOS PASSOS RECOMENDADOS

  1. Imediato: Corrigir loop infinito + memory leak (2 items, ~20 min)
  2. Esta semana: Update vite + outras deps críticas
  3. Esta semana: Adicionar validação de upload
  4. Próxima semana: Octree para vertex search
  5. Próxima semana: Separar Zustand stores

Documento gerado automaticamente por Hermes — VPS BrainSteel Última atualização: 2026-05-15