Files
BrainWind/app/src/lib/export-generic-pdf.tsx
T

342 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Document, Page, Text, View, StyleSheet, Image as PdfImage, pdf, Font } from '@react-pdf/renderer';
import { useWindStore } from '../store/appStore';
import { useCaptureStore } from '../store/captureStore';
// Registrar fonte Roboto com suporte Unicode completo (subscripts, grego, etc.)
Font.register({
family: 'Roboto',
fonts: [
{ src: 'https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.7/fonts/Roboto/Roboto-Regular.ttf' },
{ src: 'https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.7/fonts/Roboto/Roboto-Medium.ttf', fontWeight: 'bold' },
{ src: 'https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.7/fonts/Roboto/Roboto-Italic.ttf', fontStyle: 'italic' },
],
});
const styles = StyleSheet.create({
page: { flexDirection: 'column', padding: 40, fontSize: 9, fontFamily: 'Roboto', color: '#334155' },
header: { marginBottom: 15, borderBottom: '1.5pt solid #6b21a8', paddingBottom: 8, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
title: { fontSize: 16, fontWeight: 'bold', color: '#6b21a8', textAlign: 'right' },
subtitle: { fontSize: 9, color: '#64748b', marginTop: 3, textAlign: 'right' },
section: { marginTop: 12, marginBottom: 8 },
sectionTitle: { fontSize: 11, fontWeight: 'bold', marginBottom: 6, color: '#4c1d95', borderBottom: '0.5pt solid #e2e8f0', paddingBottom: 2 },
row: { flexDirection: 'row', marginBottom: 3 },
label: { width: 180, fontWeight: 'bold', color: '#475569' },
value: { flex: 1, color: '#1e293b' },
text: { fontSize: 9, marginBottom: 3, lineHeight: 1.3, color: '#334155' },
table: { display: 'flex', flexDirection: 'column', marginTop: 8, borderTop: '0.5pt solid #cbd5e1', borderLeft: '0.5pt solid #cbd5e1' },
tableRow: { flexDirection: 'row' },
tableHeader: { backgroundColor: '#f8fafc', fontWeight: 'bold' },
tableCell: { flex: 1, padding: 4, borderRight: '0.5pt solid #cbd5e1', borderBottom: '0.5pt solid #cbd5e1', textAlign: 'center', fontSize: 8 },
tableCellFirst: { flex: 1, padding: 4, borderRight: '0.5pt solid #cbd5e1', borderBottom: '0.5pt solid #cbd5e1', textAlign: 'left', fontSize: 8 },
footer: { position: 'absolute', bottom: 30, left: 40, right: 40, borderTop: '0.5pt solid #cbd5e1', paddingTop: 6 },
sceneImage: { width: 480, height: 250, objectFit: 'contain', marginVertical: 6, border: '0.5pt solid #e2e8f0', borderRadius: 4 },
sceneCaption: { fontSize: 7, color: '#64748b', fontStyle: 'italic', textAlign: 'center', marginBottom: 6 },
mathCard: { marginTop: 8, padding: 8, backgroundColor: '#f8fafc', borderLeft: '2.5pt solid #6b21a8', borderRadius: 2 },
recCard: { marginTop: 8, padding: 8, backgroundColor: '#faf5ff', borderLeft: '2.5pt solid #a855f7', borderRadius: 2 },
signatureBlock: { marginTop: 40, flexDirection: 'row', justifyContent: 'space-around', paddingTop: 30 },
signatureLine: { width: 200, borderTop: '0.75pt solid #334155', paddingTop: 4, alignItems: 'center' },
signatureText: { fontSize: 9, color: '#334155' },
});
export function getEffortColor(val: string | number): string {
const num = typeof val === 'number' ? val : parseFloat(val.toString().replace(',', '.').replace(/[^\d.-]/g, ''));
if (isNaN(num) || num === 0) return '#1e293b';
return num < 0 ? '#ea580c' : '#2563eb'; // orange for negative (suction), blue for positive (compression)
}
export function appendKgf(val: string | number): string {
if (typeof val === 'number') return String(val);
return val.replace(/(-?\d+(?:[.,]\d+)?)\s*(kN(?:\/m²|\/m2|\/m|·m|\.m|m)?)/g, (match, numStr, unit) => {
const num = parseFloat(numStr.replace(',', '.'));
if (isNaN(num)) return match;
const kgf = num * 101.9716;
let kgfUnit = 'kgf';
if (unit.includes('/m²') || unit.includes('/m2')) kgfUnit = 'kgf/m²';
else if (unit.includes('/m')) kgfUnit = 'kgf/m';
else if (unit.includes('·m') || unit.includes('.m') || unit === 'kNm') kgfUnit = 'kgf·m';
const kgfStr = kgf.toFixed(1);
const kgfFormatted = numStr.includes(',') ? kgfStr.replace('.', ',') : kgfStr;
return `${match} (${kgfFormatted} ${kgfUnit})`;
});
}
export interface GenericPDFSection {
title: string;
type: 'table' | 'text' | 'grid';
content?: string;
tableHeaders?: string[];
tableRows?: (string | number)[][];
gridItems?: { label: string; value: string | number }[];
}
export interface GenericPDFProps {
moduleName: string;
sections: GenericPDFSection[];
wind: ReturnType<typeof useWindStore.getState>;
sceneImage?: string | null;
}
function generateRecommendations(moduleName: string, sections: GenericPDFSection[], wind: any): string[] {
const recs: string[] = [];
let hasSuction = false;
let forceValue = 0;
for (const sec of sections) {
if (sec.gridItems) {
for (const item of sec.gridItems) {
const labelLower = item.label.toLowerCase();
if (labelLower.includes('vertical') || labelLower.includes('fy') || labelLower.includes('força vertical')) {
const valStr = String(item.value);
const valNum = parseFloat(valStr.replace(/[^\d.-]/g, ''));
if (!isNaN(valNum)) {
if (valNum > 0 || labelLower.includes('arrancamento') || labelLower.includes('sucção')) {
hasSuction = true;
forceValue = Math.max(forceValue, Math.abs(valNum));
}
}
}
}
}
}
const nameLower = moduleName.toLowerCase();
if (nameLower.includes('abrigo') || nameLower.includes('pórtico') || nameLower.includes('marquise')) {
recs.push("Em abrigos e pórticos em balanço, o arrancamento vertical (Fz) na cobertura é intensificado quando a parede de fundo é fechada devido ao aprisionamento de pressão (+Cpi).");
recs.push("Verifique criteriosamente as ligações pilar-viga e a ancoragem das telhas na região da testeira, onde as tensões de arrancamento atingem picos críticos.");
if (hasSuction && forceValue > 15) {
recs.push(`A força de arrancamento calculada (${forceValue.toFixed(1)} kN) requer verificação da fundação dos pilares contra arrancamento e tombamento.`);
}
} else if (nameLower.includes('cobertura') || nameLower.includes('isolada')) {
recs.push("Para coberturas isoladas, o vento gera sucções severas de arrancamento nas bordas. Verifique criteriosamente o dimensionamento e ancoragem das terças.");
if (hasSuction && forceValue > 20) {
recs.push(`A força de arrancamento vertical calculada (${forceValue.toFixed(1)} kN) exige detalhamento especial de fundação resistente a tração.`);
}
} else if (nameLower.includes('torre') || nameLower.includes('reticulada')) {
recs.push("Torres reticuladas sofrem grandes forças de arrasto. Certifique-se de que a esbeltez local atende aos limites regulamentares e verifique a fadiga nas conexões.");
} else if (nameLower.includes('muro') || nameLower.includes('placa') || nameLower.includes('sign')) {
recs.push("Em painéis e muros, o momento de tombamento na fundação é crítico. Recomenda-se adotar coeficiente de segurança mínimo de 1,5 contra tombamento.");
} else if (nameLower.includes('ponte') || nameLower.includes('tabuleiro')) {
recs.push("Pontes de grandes vãos são susceptíveis a oscilações aeroelásticas (Flutter/Galloping). Caso a velocidade crítica de flutter seja próxima à de projeto, execute ensaios aerodinâmicos complementares.");
} else if (nameLower.includes('dinâ') || nameLower.includes('dinamica') || nameLower.includes('vórtice')) {
recs.push("Estruturas esbeltas com frequência natural inferior a 1,0 Hz devem obrigatoriamente ser analisadas sob efeito de oscilação dinâmica.");
} else if (nameLower.includes('cilindro') || nameLower.includes('chaminé')) {
recs.push("Cilindros e reservatórios estão expostos a picos locais de pressão e flambagem da chapa. Anéis de rigidez superiores são altamente sugeridos.");
} else if (nameLower.includes('abóbada') || nameLower.includes('cúpula') || nameLower.includes('vault') || nameLower.includes('dome')) {
recs.push("Estruturas curvas apresentam zonas de transição com forte gradiente de pressões. Cuidado redobrado no cálculo das pressões tangenciais locais.");
}
if (wind.vk > 45) {
recs.push(`A velocidade característica encontrada (${wind.vk.toFixed(1)} m/s) é elevada. É sugerido rígido controle de deslocamentos laterais na estrutura.`);
}
recs.push("O memorial serve como suporte ao projetista estrutural, cabendo ao Engenheiro Responsável Técnico (RT) a aprovação final dos esforços de dimensionamento.");
return recs;
}
const GenericReportDocument = ({ moduleName, sections, wind, sceneImage }: GenericPDFProps) => {
const recommendations = generateRecommendations(moduleName, sections, wind);
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Cabeçalho com Logotipo customizado ou placeholder */}
<View style={styles.header}>
{wind.companyLogo ? (
<PdfImage src={wind.companyLogo} style={{ width: 110, height: 40, objectFit: 'contain' }} />
) : (
<View style={{ width: 110, height: 40, border: '0.75pt dashed #cbd5e1', borderRadius: 2, justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ fontSize: 7, color: '#94a3b8' }}>Logotipo Empresa</Text>
</View>
)}
<View style={{ flex: 1, marginLeft: 15 }}>
<Text style={styles.title}>{wind.projectName || 'BrainWind v1.0 — Memória de Cálculo'}</Text>
<Text style={styles.subtitle}>Módulo: {moduleName} ABNT NBR 6123:2023</Text>
</View>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 12, backgroundColor: '#f8fafc', padding: 6, borderRadius: 2 }}>
<View>
<Text style={{ fontSize: 8, color: '#64748b' }}>Cliente</Text>
<Text style={{ fontSize: 9, fontWeight: 'bold' }}>{wind.clientName || '-'}</Text>
</View>
<View>
<Text style={{ fontSize: 8, color: '#64748b' }}>Autor</Text>
<Text style={{ fontSize: 9, fontWeight: 'bold' }}>{wind.authorName || '-'}</Text>
</View>
<View>
<Text style={{ fontSize: 8, color: '#64748b' }}>Revisão</Text>
<Text style={{ fontSize: 9, fontWeight: 'bold' }}>{wind.projectRev || '-'}</Text>
</View>
<View>
<Text style={{ fontSize: 8, color: '#64748b' }}>Data</Text>
<Text style={{ fontSize: 9, fontWeight: 'bold' }}>{new Date().toLocaleDateString('pt-BR')}</Text>
</View>
</View>
{/* 1. Parâmetros Globais do Vento */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>1. Parâmetros Globais do Vento e Pressão Dinâmica</Text>
<View style={styles.row}>
<Text style={styles.label}>Localidade de Projeto:</Text>
<Text style={styles.value}>{wind.locality || 'V0 generalizado'}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Velocidade Básica (V0):</Text>
<Text style={styles.value}>{wind.v0} m/s (conforme Figura 1 e Anexo C da NBR 6123)</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Fator Topográfico (S1):</Text>
<Text style={styles.value}>{wind.s1} (conforme Seção 5.2)</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Fator de Rugosidade (S2):</Text>
<Text style={styles.value}>
{wind.s2.toFixed(3)} (Categoria {wind.terrainCategory}, Classe {wind.structureClass}, conforme Tabela 2)
</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Fator Estatístico (S3):</Text>
<Text style={styles.value}>{wind.s3.toFixed(2)} (Grupo {wind.s3Group}, conforme Tabela 4)</Text>
</View>
<View style={styles.mathCard}>
<Text style={{ fontSize: 10, fontWeight: 'bold', marginBottom: 3, color: '#1e1b4b' }}>
Formulações de Velocidade e Pressão (Sec. 4.2 e 4.3):
</Text>
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#1e1b4b', marginBottom: 2 }}>
Fórmula Base: Vk = V0 × S1 × S2 × S3
</Text>
<Text style={{ fontSize: 8, color: '#475569', marginBottom: 6 }}>
Vk = {wind.v0} × {wind.s1} × {wind.s2.toFixed(3)} × {wind.s3.toFixed(2)} = {wind.vk.toFixed(2)} m/s
</Text>
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#1e1b4b', marginBottom: 2 }}>
Pressão Dinâmica: q = 0,613 × (Vk)²
</Text>
<Text style={{ fontSize: 8, color: '#475569' }}>
q = 0,613 × ({wind.vk.toFixed(2)})² = {(0.613 * Math.pow(wind.vk, 2) / 1000).toFixed(4)} kN/m²
</Text>
</View>
</View>
{sceneImage && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>2. Modelo 3D (Captura de Cena)</Text>
<PdfImage src={sceneImage} style={styles.sceneImage} />
<Text style={styles.sceneCaption}>
Vista isométrica em tempo real capturada pelo usuário na interface.
</Text>
</View>
)}
{sections.map((sec, idx) => (
<View style={styles.section} key={idx} wrap={false}>
<Text style={styles.sectionTitle}>
{sceneImage ? idx + 3 : idx + 2}. {sec.title}
</Text>
{sec.type === 'text' && sec.content && (
<Text style={styles.text}>{appendKgf(sec.content)}</Text>
)}
{sec.type === 'grid' && sec.gridItems && (
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
{sec.gridItems.map((item, i) => (
<View style={{ width: '50%', marginBottom: 4 }} key={i}>
<Text style={{ fontWeight: 'bold', color: '#475569' }}>
{item.label.replace('⊥', 'perpendicular à').replace('∥', 'paralelo à')}:
</Text>
<Text style={{ color: '#1e293b' }}>{appendKgf(item.value)}</Text>
</View>
))}
</View>
)}
{sec.type === 'table' && sec.tableHeaders && sec.tableRows && (
<View style={styles.table}>
<View style={[styles.tableRow, styles.tableHeader]}>
{sec.tableHeaders.map((th, i) => (
<Text key={i} style={i === 0 ? styles.tableCellFirst : styles.tableCell}>
{th}
</Text>
))}
</View>
{sec.tableRows.map((tr, rIdx) => (
<View style={styles.tableRow} key={rIdx}>
{tr.map((tc, cIdx) => (
<Text key={cIdx} style={[cIdx === 0 ? styles.tableCellFirst : styles.tableCell, cIdx > 0 ? { color: getEffortColor(tc) } : {}]}>
{appendKgf(tc)}
</Text>
))}
</View>
))}
</View>
)}
</View>
))}
<View style={styles.section} wrap={false}>
<Text style={styles.sectionTitle}>
{sceneImage ? sections.length + 3 : sections.length + 2}. Parecer Técnico e Observações
</Text>
<View style={styles.recCard}>
{recommendations.map((rec, rIdx) => (
<Text style={{ fontSize: 9, marginBottom: 4, lineHeight: 1.3, color: '#3b0764' }} key={rIdx}>
{rec}
</Text>
))}
</View>
<View style={styles.signatureBlock}>
<View style={styles.signatureLine}>
<Text style={styles.signatureText}>{wind.authorName || 'Engenheiro Responsável'}</Text>
<Text style={{ fontSize: 7, color: '#64748b', marginTop: 2 }}>CREA / CAU:</Text>
</View>
</View>
<View style={{ marginTop: 20, paddingTop: 10, borderTop: '0.5pt solid #e2e8f0' }}>
<Text style={{ fontSize: 6, color: '#94a3b8', textAlign: 'justify', lineHeight: 1.3 }}>
Isenção de Responsabilidade do Software: Os resultados e relatórios gerados pelo BrainWind são de responsabilidade exclusiva e integral do usuário. O aplicativo atua como ferramenta de auxílio e não substitui o julgamento técnico, a análise detalhada das normas e o dimensionamento final por profissional habilitado.
</Text>
</View>
</View>
<View style={styles.footer} fixed>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 6, color: '#94a3b8' }}>
Legendas: V0: Vel. básica | S1, S2, S3: Fatores de vento | Vk: Vel. de projeto | q: Pressão dinâmica | Cpe: Pressão externa | Cpi: Pressão interna
</Text>
</View>
<Text style={{ fontSize: 7, color: '#64748b', textAlign: 'right', flex: 0.2, paddingRight: 10 }} render={({ pageNumber, totalPages }) => (
`Pág. ${pageNumber} de ${totalPages}`
)} />
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', flex: 0.3 }}>
<PdfImage src="/logo_brainwind.png" style={{ width: 25, height: 'auto', objectFit: 'contain' }} />
</View>
</View>
</View>
</Page>
</Document>
);
};
export async function exportGenericToPDF(moduleName: string, sections: GenericPDFSection[]) {
const wind = useWindStore.getState();
const sceneImage = useCaptureStore.getState().capturedImage;
const blob = await pdf(
<GenericReportDocument moduleName={moduleName} sections={sections} wind={wind} sceneImage={sceneImage} />
).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', `memoria_calculo_${moduleName.toLowerCase().replace(/\s+/g, '_')}.pdf`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}