🚀 Auto-deploy: melhoria no snap e medição AR em 22/05/2026 14:53:05
This commit is contained in:
+668
@@ -0,0 +1,668 @@
|
|||||||
|
# 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**:
|
||||||
|
```tsx
|
||||||
|
<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**:
|
||||||
|
```tsx
|
||||||
|
<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.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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**:
|
||||||
|
```tsx
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 SmartMeasure.ts — Algoritmo O(n) em Vertex Search
|
||||||
|
|
||||||
|
**Arquivo**: `src/lib/SmartMeasure.ts`
|
||||||
|
**Função**: `findNearestVertex()`
|
||||||
|
|
||||||
|
**Problema**:
|
||||||
|
Para cada mousemove, itera **todos** os vértices do mesh:
|
||||||
|
```ts
|
||||||
|
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):
|
||||||
|
```ts
|
||||||
|
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**:
|
||||||
|
```bash
|
||||||
|
# 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:
|
||||||
|
```ts
|
||||||
|
// 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:
|
||||||
|
```ts
|
||||||
|
// 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**:
|
||||||
|
```ts
|
||||||
|
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**:
|
||||||
|
```ts
|
||||||
|
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**:
|
||||||
|
```ts
|
||||||
|
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**:
|
||||||
|
```ts
|
||||||
|
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**:
|
||||||
|
```ts
|
||||||
|
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**:
|
||||||
|
```ts
|
||||||
|
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`
|
||||||
|
|
||||||
|
```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:
|
||||||
|
```tsx
|
||||||
|
// 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
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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**:
|
||||||
|
```tsx
|
||||||
|
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**:
|
||||||
|
```ts
|
||||||
|
// 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`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
xrSupported: boolean | null; // null = não checou ainda
|
||||||
|
```
|
||||||
|
|
||||||
|
Quando `xrSupported === false`, não há fallback ou mensagem ao usuário.
|
||||||
|
|
||||||
|
**Sugestão**:
|
||||||
|
```tsx
|
||||||
|
// 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
|
||||||
|
|
||||||
|
```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:
|
||||||
|
```bash
|
||||||
|
# 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
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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**:
|
||||||
|
```ts
|
||||||
|
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
|
||||||
|
|
||||||
|
```ts
|
||||||
|
audioUrl: string | null;
|
||||||
|
```
|
||||||
|
|
||||||
|
Não valida se URL é `blob://` ou `file://` válido. Não limpa strings maliciosas.
|
||||||
|
|
||||||
|
**Sugestão**:
|
||||||
|
```ts
|
||||||
|
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**:
|
||||||
|
```ts
|
||||||
|
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*
|
||||||
@@ -424,3 +424,206 @@ export function resolveSnap(
|
|||||||
if (e) return { point: e.midpoint, type: 'edge' };
|
if (e) return { point: e.midpoint, type: 'edge' };
|
||||||
return { point: hitPoint.clone(), type: 'surface' };
|
return { point: hitPoint.clone(), type: 'surface' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _ab = new THREE.Vector3();
|
||||||
|
const _ap = new THREE.Vector3();
|
||||||
|
const _closest = new THREE.Vector3();
|
||||||
|
|
||||||
|
function pointToSegmentDistSq3D(p: THREE.Vector3, a: THREE.Vector3, b: THREE.Vector3): number {
|
||||||
|
_ab.subVectors(b, a);
|
||||||
|
_ap.subVectors(p, a);
|
||||||
|
const abLenSq = _ab.lengthSq();
|
||||||
|
if (abLenSq < 1e-6) return _ap.lengthSq();
|
||||||
|
|
||||||
|
let t = _ap.dot(_ab) / abLenSq;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
_closest.copy(a).addScaledVector(_ab, t);
|
||||||
|
return p.distanceToSquared(_closest);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findNearestVertex3D(
|
||||||
|
mesh: THREE.Mesh,
|
||||||
|
worldPoint: THREE.Vector3,
|
||||||
|
thresholdMeters: number = 0.035
|
||||||
|
): THREE.Vector3 | null {
|
||||||
|
const geo = mesh.geometry;
|
||||||
|
const posAttr = geo.attributes.position;
|
||||||
|
if (!posAttr) return null;
|
||||||
|
|
||||||
|
const invMatrix = new THREE.Matrix4().copy(mesh.matrixWorld).invert();
|
||||||
|
const localPoint = worldPoint.clone().applyMatrix4(invMatrix);
|
||||||
|
|
||||||
|
let bestDistSq = Infinity;
|
||||||
|
let bestIndex = -1;
|
||||||
|
const thresholdSq = thresholdMeters * thresholdMeters;
|
||||||
|
|
||||||
|
for (let i = 0; i < posAttr.count; i++) {
|
||||||
|
const vx = posAttr.getX(i);
|
||||||
|
const vy = posAttr.getY(i);
|
||||||
|
const vz = posAttr.getZ(i);
|
||||||
|
|
||||||
|
const dx = vx - localPoint.x;
|
||||||
|
const dy = vy - localPoint.y;
|
||||||
|
const dz = vz - localPoint.z;
|
||||||
|
const distSq = dx * dx + dy * dy + dz * dz;
|
||||||
|
|
||||||
|
if (distSq < bestDistSq) {
|
||||||
|
bestDistSq = distSq;
|
||||||
|
bestIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestIndex !== -1 && bestDistSq <= thresholdSq) {
|
||||||
|
const result = new THREE.Vector3(
|
||||||
|
posAttr.getX(bestIndex),
|
||||||
|
posAttr.getY(bestIndex),
|
||||||
|
posAttr.getZ(bestIndex)
|
||||||
|
);
|
||||||
|
result.applyMatrix4(mesh.matrixWorld);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findNearestEdgeSegment3D(
|
||||||
|
mesh: THREE.Mesh,
|
||||||
|
worldPoint: THREE.Vector3,
|
||||||
|
thresholdMeters: number = 0.05
|
||||||
|
): { midpoint: THREE.Vector3; lengthMM: number; a: THREE.Vector3; b: THREE.Vector3 } | null {
|
||||||
|
let edgeLines: THREE.LineSegments | null = null;
|
||||||
|
mesh.children.forEach(c => {
|
||||||
|
if (c.userData.__edgeLine && c instanceof THREE.LineSegments) {
|
||||||
|
edgeLines = c;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const geo = edgeLines?.geometry ?? new THREE.EdgesGeometry(mesh.geometry, 15);
|
||||||
|
const posAttr = geo.attributes.position;
|
||||||
|
if (!posAttr) return null;
|
||||||
|
|
||||||
|
const matrix = edgeLines ? edgeLines.matrixWorld.clone().premultiply(mesh.matrixWorld) : mesh.matrixWorld;
|
||||||
|
const invMatrix = new THREE.Matrix4().copy(matrix).invert();
|
||||||
|
const localPoint = worldPoint.clone().applyMatrix4(invMatrix);
|
||||||
|
|
||||||
|
let bestDistSq = Infinity;
|
||||||
|
let bestIndex = -1;
|
||||||
|
const thresholdSq = thresholdMeters * thresholdMeters;
|
||||||
|
|
||||||
|
const segCount = posAttr.count / 2;
|
||||||
|
const aLocal = new THREE.Vector3();
|
||||||
|
const bLocal = new THREE.Vector3();
|
||||||
|
|
||||||
|
for (let i = 0; i < segCount; i++) {
|
||||||
|
aLocal.fromBufferAttribute(posAttr, i * 2);
|
||||||
|
bLocal.fromBufferAttribute(posAttr, i * 2 + 1);
|
||||||
|
|
||||||
|
const distSq = pointToSegmentDistSq3D(localPoint, aLocal, bLocal);
|
||||||
|
|
||||||
|
if (distSq < bestDistSq) {
|
||||||
|
bestDistSq = distSq;
|
||||||
|
bestIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!edgeLines) geo.dispose();
|
||||||
|
|
||||||
|
if (bestIndex !== -1 && bestDistSq <= thresholdSq) {
|
||||||
|
const a = new THREE.Vector3().fromBufferAttribute(posAttr, bestIndex * 2).applyMatrix4(matrix);
|
||||||
|
const b = new THREE.Vector3().fromBufferAttribute(posAttr, bestIndex * 2 + 1).applyMatrix4(matrix);
|
||||||
|
const midpoint = a.clone().add(b).multiplyScalar(0.5);
|
||||||
|
const lengthMM = a.distanceTo(b) * 1000;
|
||||||
|
return { midpoint, lengthMM, a, b };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectCircularEdgeAtPoint3D(
|
||||||
|
mesh: THREE.Mesh,
|
||||||
|
worldPoint: THREE.Vector3,
|
||||||
|
radiusMeters: number = 0.08
|
||||||
|
): { center: THREE.Vector3; diameterMM: number } | null {
|
||||||
|
let edgeLines: THREE.LineSegments | null = null;
|
||||||
|
mesh.children.forEach(c => {
|
||||||
|
if (c.userData.__edgeLine && c instanceof THREE.LineSegments) edgeLines = c;
|
||||||
|
});
|
||||||
|
const geo = edgeLines?.geometry ?? new THREE.EdgesGeometry(mesh.geometry, 15);
|
||||||
|
const posAttr = geo.attributes.position;
|
||||||
|
if (!posAttr) return null;
|
||||||
|
|
||||||
|
const matrix = edgeLines ? edgeLines.matrixWorld.clone().premultiply(mesh.matrixWorld) : mesh.matrixWorld;
|
||||||
|
const invMatrix = new THREE.Matrix4().copy(matrix).invert();
|
||||||
|
const localPoint = worldPoint.clone().applyMatrix4(invMatrix);
|
||||||
|
|
||||||
|
const verts: THREE.Vector3[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const tmp = new THREE.Vector3();
|
||||||
|
const radiusSq = radiusMeters * radiusMeters;
|
||||||
|
const count = posAttr.count;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
tmp.fromBufferAttribute(posAttr, i);
|
||||||
|
const dx = tmp.x - localPoint.x;
|
||||||
|
const dy = tmp.y - localPoint.y;
|
||||||
|
const dz = tmp.z - localPoint.z;
|
||||||
|
const distSq = dx * dx + dy * dy + dz * dz;
|
||||||
|
|
||||||
|
if (distSq > radiusSq) continue;
|
||||||
|
const key = `${tmp.x.toFixed(5)},${tmp.y.toFixed(5)},${tmp.z.toFixed(5)}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
verts.push(tmp.clone().applyMatrix4(matrix));
|
||||||
|
}
|
||||||
|
if (!edgeLines) geo.dispose();
|
||||||
|
|
||||||
|
if (verts.length < 6) return null;
|
||||||
|
|
||||||
|
const centroid = new THREE.Vector3();
|
||||||
|
verts.forEach(v => centroid.add(v));
|
||||||
|
centroid.divideScalar(verts.length);
|
||||||
|
|
||||||
|
let xx = 0, xy = 0, xz = 0, yy = 0, yz = 0, zz = 0;
|
||||||
|
for (const v of verts) {
|
||||||
|
const dx = v.x - centroid.x, dy = v.y - centroid.y, dz = v.z - centroid.z;
|
||||||
|
xx += dx * dx; xy += dx * dy; xz += dx * dz;
|
||||||
|
yy += dy * dy; yz += dy * dz; zz += dz * dz;
|
||||||
|
}
|
||||||
|
const axisA = new THREE.Vector3(xx, xy, xz).normalize();
|
||||||
|
const axisB = new THREE.Vector3(xy, yy, yz).normalize();
|
||||||
|
const normal = new THREE.Vector3().crossVectors(axisA, axisB);
|
||||||
|
if (normal.lengthSq() < 1e-8) return null;
|
||||||
|
normal.normalize();
|
||||||
|
|
||||||
|
const basisU = new THREE.Vector3();
|
||||||
|
if (Math.abs(normal.x) < 0.9) basisU.crossVectors(normal, new THREE.Vector3(1, 0, 0)).normalize();
|
||||||
|
else basisU.crossVectors(normal, new THREE.Vector3(0, 1, 0)).normalize();
|
||||||
|
const basisV = new THREE.Vector3().crossVectors(normal, basisU).normalize();
|
||||||
|
|
||||||
|
const points2D = verts.map(v => {
|
||||||
|
const rel = v.clone().sub(centroid);
|
||||||
|
return { u: rel.dot(basisU), v: rel.dot(basisV) };
|
||||||
|
});
|
||||||
|
const fit = circleFitKasa(points2D);
|
||||||
|
if (!fit) return null;
|
||||||
|
|
||||||
|
const diameterMM = fit.radius * 2 * 1000;
|
||||||
|
if (diameterMM < 2 || diameterMM > 500) return null;
|
||||||
|
|
||||||
|
const center3D = centroid.clone()
|
||||||
|
.add(basisU.clone().multiplyScalar(fit.cx))
|
||||||
|
.add(basisV.clone().multiplyScalar(fit.cy));
|
||||||
|
|
||||||
|
return { center: center3D, diameterMM };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSnap3D(
|
||||||
|
mesh: THREE.Mesh,
|
||||||
|
hitPoint: THREE.Vector3,
|
||||||
|
vertexThresholdMeters: number = 0.035,
|
||||||
|
edgeThresholdMeters: number = 0.05
|
||||||
|
): { point: THREE.Vector3; type: 'vertex' | 'edge' | 'surface' } {
|
||||||
|
const v = findNearestVertex3D(mesh, hitPoint, vertexThresholdMeters);
|
||||||
|
if (v) return { point: v, type: 'vertex' };
|
||||||
|
const e = findNearestEdgeSegment3D(mesh, hitPoint, edgeThresholdMeters);
|
||||||
|
if (e) return { point: e.midpoint, type: 'edge' };
|
||||||
|
return { point: hitPoint.clone(), type: 'surface' };
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { useRef, useState } from 'react';
|
|||||||
import { useFrame, useThree } from '@react-three/fiber';
|
import { useFrame, useThree } from '@react-three/fiber';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
import { resolveSnap, detectCircularEdgeAtPoint, findNearestEdgeSegment } from './SmartMeasure';
|
import {
|
||||||
|
resolveSnap,
|
||||||
|
detectCircularEdgeAtPoint,
|
||||||
|
findNearestEdgeSegment,
|
||||||
|
resolveSnap3D,
|
||||||
|
detectCircularEdgeAtPoint3D,
|
||||||
|
findNearestEdgeSegment3D
|
||||||
|
} from './SmartMeasure';
|
||||||
|
|
||||||
const TRIG_ON = 0.7;
|
const TRIG_ON = 0.7;
|
||||||
const TRIG_OFF = 0.3;
|
const TRIG_OFF = 0.3;
|
||||||
@@ -121,48 +128,37 @@ export function XRControllerMeasure() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (hit && hit.object instanceof THREE.Mesh) {
|
if (hit && hit.object instanceof THREE.Mesh) {
|
||||||
const size = gl.getSize(new THREE.Vector2());
|
// Snap to existing registered hole centers first (within ~4cm)
|
||||||
const canvasSize = { width: size.x || 1024, height: size.y || 1024 };
|
|
||||||
const fakeCam = new THREE.PerspectiveCamera(60, 1, 0.01, 100);
|
|
||||||
fakeCam.position.copy(tmpOrigin.current);
|
|
||||||
fakeCam.quaternion.copy(tmpQuat.current);
|
|
||||||
fakeCam.updateMatrixWorld(true);
|
|
||||||
|
|
||||||
// Snap to existing registered hole centers first (within ~30px screen)
|
|
||||||
const existing = useModelStore.getState().measurements;
|
const existing = useModelStore.getState().measurements;
|
||||||
let bestHoleCenter: THREE.Vector3 | null = null;
|
let bestHoleCenter: THREE.Vector3 | null = null;
|
||||||
let bestHolePx = Infinity;
|
let bestHoleDist = Infinity;
|
||||||
const hitProj = hit.point.clone().project(fakeCam);
|
|
||||||
const hx = (hitProj.x * 0.5 + 0.5) * canvasSize.width;
|
|
||||||
const hy = (-hitProj.y * 0.5 + 0.5) * canvasSize.height;
|
|
||||||
for (const m of existing) {
|
for (const m of existing) {
|
||||||
if (m.kind !== 'hole') continue;
|
if (m.kind !== 'hole') continue;
|
||||||
const c = new THREE.Vector3(m.pointA.x, m.pointA.y, m.pointA.z);
|
const c = new THREE.Vector3(m.pointA.x, m.pointA.y, m.pointA.z);
|
||||||
const p = c.clone().project(fakeCam);
|
const d = hit.point.distanceTo(c);
|
||||||
const sx = (p.x * 0.5 + 0.5) * canvasSize.width;
|
if (d < 0.04 && d < bestHoleDist) {
|
||||||
const sy = (-p.y * 0.5 + 0.5) * canvasSize.height;
|
bestHoleDist = d;
|
||||||
const d = Math.hypot(sx - hx, sy - hy);
|
bestHoleCenter = c;
|
||||||
if (d < 30 && d < bestHolePx) { bestHolePx = d; bestHoleCenter = c; }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapEnabled && bestHoleCenter) {
|
if (snapEnabled && bestHoleCenter) {
|
||||||
snappedPoint = bestHoleCenter;
|
snappedPoint = bestHoleCenter;
|
||||||
snapKind = 'hole';
|
snapKind = 'hole';
|
||||||
} else if (snapEnabled) {
|
} else if (snapEnabled) {
|
||||||
// Try detecting a circular edge (hole) at hit — radius mais generoso
|
// Try detecting a circular edge (hole) at hit (8cm radius)
|
||||||
const circle = detectCircularEdgeAtPoint(hit.object, hit.point, fakeCam, canvasSize, 90);
|
const circle = detectCircularEdgeAtPoint3D(hit.object, hit.point, 0.08);
|
||||||
if (circle) {
|
if (circle) {
|
||||||
snappedPoint = circle.center;
|
snappedPoint = circle.center;
|
||||||
snapKind = 'hole';
|
snapKind = 'hole';
|
||||||
hoverDetected = { kind: 'hole', value: circle.diameterMM, position: circle.center };
|
hoverDetected = { kind: 'hole', value: circle.diameterMM, position: circle.center };
|
||||||
} else {
|
} else {
|
||||||
// Snap mais "magnético": raios maiores de busca em pixels
|
// Snap 3D físico puro (3.5cm para vértices, 5cm para arestas)
|
||||||
// vértice: 32px (antes 14), aresta: 48px (antes 18)
|
const snap = resolveSnap3D(hit.object, hit.point, 0.035, 0.05);
|
||||||
const snap = resolveSnap(hit.object, hit.point, fakeCam, canvasSize, 32, 48);
|
|
||||||
snappedPoint = snap.point;
|
snappedPoint = snap.point;
|
||||||
snapKind = snap.type;
|
snapKind = snap.type;
|
||||||
if (snap.type === 'edge') {
|
if (snap.type === 'edge') {
|
||||||
const seg = findNearestEdgeSegment(hit.object, hit.point, fakeCam, canvasSize, 48);
|
const seg = findNearestEdgeSegment3D(hit.object, hit.point, 0.05);
|
||||||
if (seg) {
|
if (seg) {
|
||||||
const lenMM = seg.a.distanceTo(seg.b) * 1000;
|
const lenMM = seg.a.distanceTo(seg.b) * 1000;
|
||||||
hoverDetected = { kind: 'edge', value: lenMM, position: seg.midpoint, endpoints: { a: seg.a, b: seg.b } };
|
hoverDetected = { kind: 'edge', value: lenMM, position: seg.midpoint, endpoints: { a: seg.a, b: seg.b } };
|
||||||
|
|||||||
@@ -561,11 +561,9 @@ const XRSession = () => {
|
|||||||
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance', preserveDrawingBuffer: true }}
|
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance', preserveDrawingBuffer: true }}
|
||||||
camera={{ position: [1.5, 1.2, 1.5], fov: 50, near: 0.01, far: 100 }}
|
camera={{ position: [1.5, 1.2, 1.5], fov: 50, near: 0.01, far: 100 }}
|
||||||
className="!bg-transparent"
|
className="!bg-transparent"
|
||||||
onCreated={({ gl, invalidate }) => {
|
onCreated={({ gl }) => {
|
||||||
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||||||
gl.setClearColor(0x000000, 0);
|
gl.setClearColor(0x000000, 0);
|
||||||
const loop = () => { invalidate(); requestAnimationFrame(loop); };
|
|
||||||
loop();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<XR store={store}>
|
<XR store={store}>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# STEELXR: SCRIPT DE ATUALIZAÇÃO E DEPLOY AUTOMÁTICO
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
echo -e "\n${CYAN}🚀 Iniciando Ciclo Automático SteelXR...${NC}"
|
||||||
|
|
||||||
|
# 1. Sincronização com Repositório Git (se existir)
|
||||||
|
if [ -d .git ]; then
|
||||||
|
echo -e "${YELLOW}📝 Sincronizando código com Git...${NC}"
|
||||||
|
git add .
|
||||||
|
if git diff-index --quiet HEAD --; then
|
||||||
|
echo -e "${GREEN}✨ Código local já está sincronizado.${NC}"
|
||||||
|
else
|
||||||
|
TIMESTAMP=$(date +"%d/%m/%Y %H:%M:%S")
|
||||||
|
echo -e "${CYAN}📤 Gravando alterações (Auto-update $TIMESTAMP)...${NC}"
|
||||||
|
git commit -m "🚀 Auto-deploy: melhoria no snap e medição AR em $TIMESTAMP"
|
||||||
|
if git remote | grep -q 'origin'; then
|
||||||
|
echo -e "${CYAN}📤 Enviando para o repositório remoto (Gitea)...${NC}"
|
||||||
|
git push origin main
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}ℹ️ Diretório não é um repositório Git. Pulando sincronização Git.${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}🏁 Ciclo concluído com sucesso. O webhook do Coolify iniciará o deploy na VPS.${NC}\n"
|
||||||
Reference in New Issue
Block a user