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:
@@ -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' });
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user