Validador de modelos adicionado

X-Lovable-Edit-ID: edt-784929fa-cb16-44d2-98f2-88094064c706
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-05-15 11:50:11 +00:00
5 changed files with 190 additions and 7 deletions
+9
View File
@@ -10,6 +10,7 @@ import { useModelStore } from '@/stores/useModelStore';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getSupportedExtension, convertToGLB } from '@/lib/convertToGLB'; import { getSupportedExtension, convertToGLB } from '@/lib/convertToGLB';
import { convertIFCtoGLB } from '@/lib/convertIFC'; import { convertIFCtoGLB } from '@/lib/convertIFC';
import { validateModelFile } from '@/lib/validateModelFile';
const DEFAULT_URL = 'https://store.tracksteel.com.br/demo.ifc'; const DEFAULT_URL = 'https://store.tracksteel.com.br/demo.ifc';
@@ -118,6 +119,14 @@ export function CloudLoader({ compact = false, variant = 'outline', className }:
const response = await smartFetch(rawUrl); const response = await smartFetch(rawUrl);
const buffer = await response.arrayBuffer(); const buffer = await response.arrayBuffer();
// Valida tamanho + assinatura antes de converter
const sig = validateModelFile(fileName, buffer.byteLength, ext, buffer);
if (!sig.ok) {
toast.error(sig.reason!);
if (sig.detail) console.warn('[validateModelFile]', sig.detail);
return;
}
let blob: Blob, outName = fileName, outSize = buffer.byteLength; let blob: Blob, outName = fileName, outSize = buffer.byteLength;
if (ext === 'glb') { if (ext === 'glb') {
blob = new Blob([buffer], { type: 'model/gltf-binary' }); blob = new Blob([buffer], { type: 'model/gltf-binary' });
+9 -3
View File
@@ -227,10 +227,14 @@ export async function generateInspectionReport(data: ReportData) {
for (let i = 0; i < data.screenshots.length; i++) { for (let i = 0; i < data.screenshots.length; i++) {
checkSpace(100); checkSpace(100);
const src = data.screenshots[i];
try { try {
if (typeof src !== 'string' || !src.startsWith('data:image/')) {
throw new Error('formato de imagem inválido (esperado data URL)');
}
const imgW = contentW; const imgW = contentW;
const imgH = imgW * 0.56; // ~16:9 const imgH = imgW * 0.56; // ~16:9
pdf.addImage(data.screenshots[i], 'PNG', margin, y, imgW, imgH); pdf.addImage(src, 'PNG', margin, y, imgW, imgH);
y += imgH + 3; y += imgH + 3;
pdf.setFontSize(7); pdf.setFontSize(7);
@@ -238,10 +242,12 @@ export async function generateInspectionReport(data: ReportData) {
pdf.setTextColor(148, 163, 184); pdf.setTextColor(148, 163, 184);
pdf.text(`Captura ${i + 1} de ${data.screenshots.length}`, margin, y); pdf.text(`Captura ${i + 1} de ${data.screenshots.length}`, margin, y);
y += 8; y += 8;
} catch { } catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[generateReport] screenshot ${i + 1} falhou:`, msg);
pdf.setFontSize(8); pdf.setFontSize(8);
pdf.setTextColor(200, 50, 50); pdf.setTextColor(200, 50, 50);
pdf.text(`[Erro ao inserir screenshot ${i + 1}]`, margin, y); pdf.text(`[Erro ao inserir screenshot ${i + 1}: ${msg}]`, margin, y);
y += 8; y += 8;
} }
} }
+118
View File
@@ -0,0 +1,118 @@
/**
* Validação defensiva de arquivos 3D antes da conversão.
*
* Objetivos:
* - Bloquear arquivos absurdamente grandes (estoura memória do navegador).
* - Conferir "magic bytes" / assinaturas mínimas para evitar que um PDF
* renomeado para .glb derrube o loader com erro críptico.
* - Não tornar a validação rígida demais: STL/OBJ/IFC são formatos texto
* ou semi-texto antigos e tolerantes a variações de header.
*
* Esta função NUNCA lança — devolve um resultado estruturado para o caller
* decidir como exibir o erro.
*/
export type ModelExt = 'glb' | 'obj' | 'stl' | 'ifc';
export interface ValidationResult {
ok: boolean;
/** Mensagem amigável pronta para `toast.error(...)` quando ok=false. */
reason?: string;
/** Detalhe técnico opcional para console.warn. */
detail?: string;
}
/** Limite de tamanho por arquivo (bytes). 250 MB cobre IFCs grandes sem OOM. */
export const MAX_MODEL_BYTES = 250 * 1024 * 1024;
/** Tamanho mínimo plausível: arquivos < 16 bytes são certamente lixo. */
const MIN_MODEL_BYTES = 16;
/** Quantos bytes ler do início para inspecionar a assinatura. */
const SNIFF_BYTES = 256;
/**
* Valida tamanho + assinatura mágica.
* `buffer` é opcional — quando ausente, faz só a validação de tamanho/extensão.
*/
export function validateModelFile(
fileName: string,
fileSize: number,
ext: ModelExt,
buffer?: ArrayBuffer,
): ValidationResult {
if (fileSize < MIN_MODEL_BYTES) {
return { ok: false, reason: `"${fileName}" está vazio ou corrompido.` };
}
if (fileSize > MAX_MODEL_BYTES) {
const mb = (fileSize / 1024 / 1024).toFixed(0);
const limit = (MAX_MODEL_BYTES / 1024 / 1024).toFixed(0);
return {
ok: false,
reason: `"${fileName}" tem ${mb} MB — limite ${limit} MB para evitar travar o navegador.`,
};
}
if (!buffer) return { ok: true };
const head = new Uint8Array(buffer.slice(0, Math.min(SNIFF_BYTES, buffer.byteLength)));
const asciiHead = new TextDecoder('utf-8', { fatal: false })
.decode(head)
.replace(/^\uFEFF/, '') // strip BOM
.trimStart()
.toLowerCase();
switch (ext) {
case 'glb': {
// GLB binário começa com "glTF" (0x67 0x6C 0x54 0x46)
const magicOk =
head[0] === 0x67 && head[1] === 0x6c && head[2] === 0x54 && head[3] === 0x46;
if (!magicOk) {
return {
ok: false,
reason: `"${fileName}" não parece um GLB válido (assinatura "glTF" ausente).`,
detail: `bytes iniciais: ${[...head.slice(0, 8)].map((b) => b.toString(16)).join(' ')}`,
};
}
return { ok: true };
}
case 'ifc': {
// IFC STEP começa com "ISO-10303-21"
if (!asciiHead.startsWith('iso-10303-21')) {
return {
ok: false,
reason: `"${fileName}" não parece um IFC válido (cabeçalho ISO-10303-21 ausente).`,
};
}
return { ok: true };
}
case 'obj': {
// OBJ é texto puro; aceita qualquer linha começando com diretivas comuns
const validStart = /^(#|v |vn |vt |vp |f |g |o |s |usemtl |mtllib |l )/m;
if (!validStart.test(asciiHead)) {
return {
ok: false,
reason: `"${fileName}" não parece um OBJ válido.`,
};
}
return { ok: true };
}
case 'stl': {
// STL ASCII começa com "solid"; STL binário tem 80 bytes de header arbitrário
// seguido de uint32 com a contagem de triângulos. A heurística mais segura:
// - ASCII: começa com "solid" e não contém zeros nos primeiros 80 bytes
// - Binário: tamanho == 84 + 50 * n_triangles
if (asciiHead.startsWith('solid')) return { ok: true };
if (buffer.byteLength >= 84) {
const dv = new DataView(buffer);
const nTris = dv.getUint32(80, true);
const expected = 84 + 50 * nTris;
if (expected === buffer.byteLength) return { ok: true };
}
return {
ok: false,
reason: `"${fileName}" não parece um STL válido (nem ASCII nem binário coerente).`,
};
}
default:
return { ok: true };
}
}
+29 -2
View File
@@ -6,6 +6,7 @@ import { useModelStore } from '@/stores/useModelStore';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from '@/lib/convertToGLB'; import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from '@/lib/convertToGLB';
import { convertIFCtoGLB } from '@/lib/convertIFC'; import { convertIFCtoGLB } from '@/lib/convertIFC';
import { validateModelFile } from '@/lib/validateModelFile';
import { CloudLoader } from '@/components/CloudLoader'; import { CloudLoader } from '@/components/CloudLoader';
import { SceneModelList } from '@/components/SceneModelList'; import { SceneModelList } from '@/components/SceneModelList';
@@ -36,7 +37,26 @@ const Index = () => {
return; return;
} }
// Pré-validação rápida (tamanho) antes de ler o arquivo inteiro na memória
const sizeCheck = validateModelFile(file.name, file.size, ext);
if (!sizeCheck.ok) {
toast.error(sizeCheck.reason!);
return;
}
if (ext === 'glb') { if (ext === 'glb') {
// Para GLB, valida assinatura "glTF" antes de criar o objectURL
try {
const head = await file.slice(0, 16).arrayBuffer();
const sigCheck = validateModelFile(file.name, file.size, ext, head);
if (!sigCheck.ok) {
toast.error(sigCheck.reason!);
if (sigCheck.detail) console.warn('[validateModelFile]', sigCheck.detail);
return;
}
} catch (err) {
console.error('[upload] header read failed', err);
}
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
addModel({ fileName: file.name, fileSize: file.size, url }); addModel({ fileName: file.name, fileSize: file.size, url });
toast.success(`Modelo "${file.name}" carregado com sucesso!`); toast.success(`Modelo "${file.name}" carregado com sucesso!`);
@@ -47,6 +67,12 @@ const Index = () => {
setConverting(true); setConverting(true);
try { try {
const buffer = await file.arrayBuffer(); const buffer = await file.arrayBuffer();
const sigCheck = validateModelFile(file.name, file.size, ext, buffer);
if (!sigCheck.ok) {
toast.error(sigCheck.reason!);
if (sigCheck.detail) console.warn('[validateModelFile]', sigCheck.detail);
return;
}
let result; let result;
if (ext === 'ifc') { if (ext === 'ifc') {
result = await convertIFCtoGLB(buffer, file.name); result = await convertIFCtoGLB(buffer, file.name);
@@ -57,8 +83,9 @@ const Index = () => {
addModel({ fileName: result.fileName, fileSize: result.fileSize, url }); addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
toast.success(`"${file.name}" convertido para GLB e carregado!`); toast.success(`"${file.name}" convertido para GLB e carregado!`);
} catch (err) { } catch (err) {
console.error(err); console.error('[upload] conversion failed', err);
toast.error(`Falha ao converter "${file.name}"`); const msg = err instanceof Error ? err.message : 'erro desconhecido';
toast.error(`Falha ao converter "${file.name}": ${msg}`);
} finally { } finally {
setConverting(false); setConverting(false);
} }
+25 -2
View File
@@ -16,6 +16,7 @@ import { toast } from "sonner";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from "@/lib/convertToGLB"; import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from "@/lib/convertToGLB";
import { validateModelFile } from "@/lib/validateModelFile";
import { convertIFCtoGLB } from "@/lib/convertIFC"; import { convertIFCtoGLB } from "@/lib/convertIFC";
const Viewer = () => { const Viewer = () => {
@@ -43,7 +44,22 @@ const Viewer = () => {
toast.error('Formato inválido. Selecione .GLB, .OBJ, .STL ou .IFC'); toast.error('Formato inválido. Selecione .GLB, .OBJ, .STL ou .IFC');
return; return;
} }
const sizeCheck = validateModelFile(file.name, file.size, ext);
if (!sizeCheck.ok) {
toast.error(sizeCheck.reason!);
return;
}
if (ext === 'glb') { if (ext === 'glb') {
try {
const head = await file.slice(0, 16).arrayBuffer();
const sig = validateModelFile(file.name, file.size, ext, head);
if (!sig.ok) {
toast.error(sig.reason!);
return;
}
} catch (err) {
console.error('[upload] header read failed', err);
}
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
addModel({ fileName: file.name, fileSize: file.size, url }); addModel({ fileName: file.name, fileSize: file.size, url });
toast.success(`"${file.name}" adicionado à cena`); toast.success(`"${file.name}" adicionado à cena`);
@@ -52,6 +68,12 @@ const Viewer = () => {
setConverting(true); setConverting(true);
try { try {
const buffer = await file.arrayBuffer(); const buffer = await file.arrayBuffer();
const sig = validateModelFile(file.name, file.size, ext, buffer);
if (!sig.ok) {
toast.error(sig.reason!);
if (sig.detail) console.warn('[validateModelFile]', sig.detail);
return;
}
const result = ext === 'ifc' const result = ext === 'ifc'
? await convertIFCtoGLB(buffer, file.name) ? await convertIFCtoGLB(buffer, file.name)
: await convertToGLB(buffer, ext, file.name); : await convertToGLB(buffer, ext, file.name);
@@ -59,8 +81,9 @@ const Viewer = () => {
addModel({ fileName: result.fileName, fileSize: result.fileSize, url }); addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
toast.success(`"${file.name}" convertido e adicionado!`); toast.success(`"${file.name}" convertido e adicionado!`);
} catch (err) { } catch (err) {
console.error(err); console.error('[upload] conversion failed', err);
toast.error(`Falha ao converter "${file.name}"`); const msg = err instanceof Error ? err.message : 'erro desconhecido';
toast.error(`Falha ao converter "${file.name}": ${msg}`);
} finally { } finally {
setConverting(false); setConverting(false);
} }