🚀 Auto-deploy: BrainWind atualizado em 14/07/2026 15:18:53

This commit is contained in:
2026-07-14 15:18:53 +00:00
parent c07016ab82
commit 4e0b26f1c4
14 changed files with 1580 additions and 158 deletions
+41 -9
View File
@@ -14,7 +14,7 @@ import PiperackModule from './pages/PiperackModule';
import {
Home, Settings, Menu, Cylinder, Church, CircleDot,
Square, Layers, BarChart3, Activity, Warehouse,
Settings2, Sun, Moon, Frame,
Settings2, Sun, Moon, Frame, FolderOpen, BookOpen
} from 'lucide-react';
import { useState } from 'react';
import { cn } from '@/lib/utils';
@@ -141,13 +141,14 @@ function AppLayout({ children }: { children: React.ReactNode }) {
</Link>
);
})}
</nav>
<div className="border-t p-2 flex flex-col gap-1 items-center justify-center">
<GlobalWindSettingsModal
trigger={
<button
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md transition-colors text-sm w-full text-left cursor-pointer text-muted-foreground hover:bg-secondary/50 hover:text-secondary-foreground',
isCollapsed && 'justify-center px-0',
'flex items-center gap-3 px-3 py-2.5 rounded-md transition-colors text-sm w-full text-left cursor-pointer text-muted-foreground hover:bg-secondary/50 hover:text-secondary-foreground',
isCollapsed && 'justify-center px-0'
)}
title={isCollapsed ? 'Parâmetros do Vento' : undefined}
>
@@ -156,18 +157,49 @@ function AppLayout({ children }: { children: React.ReactNode }) {
</button>
}
/>
</nav>
<div className="border-t p-2 flex flex-col gap-2 items-center justify-center">
<Link
to="/settings?tab=gerenciador"
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-md transition-colors text-sm w-full cursor-pointer',
location.pathname === '/settings' && new URLSearchParams(location.search).get('tab') === 'gerenciador'
? 'bg-primary text-primary-foreground font-medium shadow-sm'
: 'text-muted-foreground hover:bg-secondary/50 hover:text-secondary-foreground',
isCollapsed && 'justify-center px-0'
)}
title={isCollapsed ? 'Arquivos' : undefined}
>
<FolderOpen className="w-5 h-5 shrink-0" />
{!isCollapsed && <span>Arquivos</span>}
</Link>
<Link
to="/settings"
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-md transition-colors text-sm w-full cursor-pointer text-muted-foreground hover:bg-secondary/50 hover:text-secondary-foreground',
isCollapsed && 'justify-center px-0',
'flex items-center gap-3 px-3 py-2.5 rounded-md transition-colors text-sm w-full cursor-pointer',
location.pathname === '/settings' && (!location.search || location.search.includes('tab=config') || location.search.includes('tab=testes'))
? 'bg-primary text-primary-foreground font-medium shadow-sm'
: 'text-muted-foreground hover:bg-secondary/50 hover:text-secondary-foreground',
isCollapsed && 'justify-center px-0'
)}
title={isCollapsed ? t('nav_settings') : undefined}
>
<Settings className="w-5 h-5 shrink-0" />
{!isCollapsed && <span>{t('nav_settings')}</span>}
{!isCollapsed && <span>Configurações</span>}
</Link>
<Link
to="/settings?tab=glossary"
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-md transition-colors text-sm w-full cursor-pointer',
location.pathname === '/settings' && new URLSearchParams(location.search).get('tab') === 'glossary'
? 'bg-primary text-primary-foreground font-medium shadow-sm'
: 'text-muted-foreground hover:bg-secondary/50 hover:text-secondary-foreground',
isCollapsed && 'justify-center px-0'
)}
title={isCollapsed ? 'Glossário' : undefined}
>
<BookOpen className="w-5 h-5 shrink-0" />
{!isCollapsed && <span>Glossário</span>}
</Link>
</div>
</aside>
+87
View File
@@ -0,0 +1,87 @@
import React, { useState, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Search, BookOpen } from 'lucide-react';
import { nbr6123Glossary } from '@/data/glossary';
const Glossary: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const filteredTerms = useMemo(() => {
const termLower = searchTerm.toLowerCase();
return nbr6123Glossary.filter(item => {
if (item.term.toLowerCase().includes(termLower)) return true;
if (item.definition.toLowerCase().includes(termLower)) return true;
if (item.tags.some(t => t.toLowerCase().includes(termLower))) return true;
if (item.reference.toLowerCase().includes(termLower)) return true;
return false;
});
}, [searchTerm]);
return (
<div className="space-y-6">
<Card className="border-border shadow-sm bg-card/50">
<CardHeader className="pb-4">
<div className="flex items-center gap-2">
<div className="p-2 bg-primary/10 rounded-lg">
<BookOpen className="w-5 h-5 text-primary" />
</div>
<div>
<CardTitle className="text-xl">Glossário NBR 6123</CardTitle>
<CardDescription>
Dicionário técnico com termos aerodinâmicos e variáveis normativas explicadas de forma didática.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar termo, sigla (ex: Cpe, S1) ou conceito..."
className="pl-10 max-w-full"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2">
{filteredTerms.length > 0 ? (
filteredTerms.map((item) => (
<Card key={item.id} className="overflow-hidden hover:border-primary/50 transition-colors">
<CardHeader className="p-4 bg-muted/20 border-b pb-3">
<CardTitle className="text-base text-primary flex items-center justify-between">
{item.term}
</CardTitle>
<CardDescription className="text-xs text-muted-foreground font-mono mt-1">
{item.reference}
</CardDescription>
</CardHeader>
<CardContent className="p-4">
<p className="text-sm leading-relaxed text-foreground/90">
{item.definition}
</p>
<div className="flex flex-wrap gap-1.5 mt-4">
{item.tags.map((tag, idx) => (
<Badge key={idx} variant="secondary" className="text-[10px] font-normal px-2 py-0 h-5">
{tag}
</Badge>
))}
</div>
</CardContent>
</Card>
))
) : (
<div className="col-span-1 md:col-span-2 py-12 text-center text-muted-foreground border border-dashed rounded-lg">
Nenhum termo encontrado para "{searchTerm}".
</div>
)}
</div>
</div>
);
};
export default Glossary;
+121
View File
@@ -0,0 +1,121 @@
import React, { useState } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Save, FileJson, FileBox } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from './ui/dialog';
import { useProjects } from '../lib/hooks/useProjects';
import { useWindStore } from '../store/appStore';
interface SaveModuleDialogProps {
moduleType: 'galpao' | 'cilindro' | 'vault' | 'dome' | 'sign' | 'isolated-roof' | 'bar' | 'bridge' | 'dynamics' | 'piperack';
inputs: Record<string, unknown>;
triggerButton?: React.ReactNode;
}
export const SaveModuleDialog: React.FC<SaveModuleDialogProps> = ({ moduleType, inputs, triggerButton }) => {
const { save } = useProjects();
const windStore = useWindStore();
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [type, setType] = useState<'projeto' | 'template'>('projeto');
// Gera um nome sugerido ao abrir
React.useEffect(() => {
if (open && !name) {
const date = new Date().toLocaleDateString('pt-BR').replace(/\//g, '');
const prefix = type === 'projeto' ? 'PRJ' : 'TPL';
const modName = moduleType.charAt(0).toUpperCase() + moduleType.slice(1);
setName(`${prefix}-${modName}-${date}`);
}
}, [open, type, moduleType, name]);
const handleSave = async () => {
if (!name.trim()) return;
const windData = type === 'projeto' ? {
v0: windStore.v0,
s1: windStore.s1,
s3: windStore.s3,
s3Group: windStore.s3Group,
terrainCategory: windStore.terrainCategory,
largestDimension: windStore.largestDimension,
heightZ: windStore.heightZ,
} : undefined;
await save({
name,
type,
module: moduleType,
inputs,
windData,
createdAt: Date.now(),
updatedAt: Date.now(),
});
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{triggerButton || (
<Button variant="outline" size="sm" className="gap-2">
<Save className="h-4 w-4" />
Salvar
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Salvar Módulo</DialogTitle>
<DialogDescription>
Escolha como deseja salvar este módulo.
Projetos incluem os dados de vento atuais. Templates salvam apenas a geometria da estrutura.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<Button
variant={type === 'projeto' ? 'default' : 'outline'}
className="h-20 flex flex-col gap-2"
onClick={() => setType('projeto')}
>
<FileBox className="h-6 w-6" />
Projeto Completo
</Button>
<Button
variant={type === 'template' ? 'default' : 'outline'}
className="h-20 flex flex-col gap-2"
onClick={() => setType('template')}
>
<FileJson className="h-6 w-6" />
Template (Geometria)
</Button>
</div>
<div className="grid gap-2">
<label htmlFor="name" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Nome do Arquivo</label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Digite um nome para identificar..."
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancelar</Button>
<Button onClick={handleSave} disabled={!name.trim()}>Salvar {type === 'projeto' ? 'Projeto' : 'Template'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+36 -32
View File
@@ -55,48 +55,52 @@ function PiperackModel({
);
}
// Viga Longitudinal da fileira
// Viga Longitudinal da fileira (representando a face reticulada do pórtico)
frames.push(
<mesh key={`beam-${i}`} position={[0, elevation, z]} receiveShadow castShadow>
<boxGeometry args={[width, height, 0.3]} />
<meshStandardMaterial color={steelColor} metalness={0.6} roughness={0.4} transparent opacity={phiTotal} />
{/* Opacidade ajustada para atingir 90% quando a solidez for 1.0 */}
<meshStandardMaterial color={steelColor} metalness={0.6} roughness={0.4} transparent opacity={Math.max(0.1, Math.min(0.9, phiTotal * 0.9))} depthWrite={false} />
</mesh>
);
}
// Vigas transversais conectando as fileiras nas pontas
if (numFrames > 1) {
frames.push(
<mesh key={`cross-1`} position={[-halfW, elevation, 0]} receiveShadow castShadow>
<boxGeometry args={[0.3, height, totalSpacing]} />
<meshStandardMaterial color={steelColor} metalness={0.6} roughness={0.4} transparent opacity={phiTotal} />
</mesh>
);
frames.push(
<mesh key={`cross-2`} position={[halfW, elevation, 0]} receiveShadow castShadow>
<boxGeometry args={[0.3, height, totalSpacing]} />
<meshStandardMaterial color={steelColor} metalness={0.6} roughness={0.4} transparent opacity={phiTotal} />
</mesh>
);
}
// Gera as tubulações correndo longitudinalmente (ao longo de X)
const pipeMeshes = pipes.map((pipe, idx) => {
// Distribui os tubos ao longo do espaçamento transversal (Z)
const zPos = numFrames > 1
? -zOffset + 0.5 + (idx % Math.max(pipes.length, 1)) * (totalSpacing - 1) / Math.max(pipes.length - 1, 1) || 0
: 0;
// elevationOffset varia de 0 (base da estrutura reticulada) a height (topo da estrutura)
// A base da estrutura fica em: rackTop - height, que é elevation - height/2
const yPos = (elevation - height/2) + pipe.elevationOffset + pipe.diameter/2;
const color = pipeColors[idx % pipeColors.length];
// Agrupa tubos pela mesma elevação para evitar colisões e distribuí-los lado a lado (ao longo de Z)
const pipeMeshes: any[] = [];
const groups: Record<number, {pipe: PipeDef, idx: number}[]> = {};
pipes.forEach((pipe, idx) => {
// Arredondamos a elevação para agrupar valores muito próximos
const key = Math.round(pipe.elevationOffset * 100) / 100;
if (!groups[key]) groups[key] = [];
groups[key].push({pipe, idx});
});
return (
<mesh key={`pipe-${pipe.id}`} position={[0, yPos, zPos]} rotation={[0, 0, Math.PI / 2]} receiveShadow castShadow>
<cylinderGeometry args={[pipe.diameter / 2, pipe.diameter / 2, width + 1, 16]} />
<meshStandardMaterial color={color} metalness={0.3} roughness={0.2} />
</mesh>
);
Object.values(groups).forEach(group => {
const gap = 0.2; // 20cm de espaçamento entre tubos na mesma elevação
const totalDiam = group.reduce((sum, item) => sum + item.pipe.diameter, 0);
const totalGroupWidth = totalDiam + gap * (group.length - 1);
// Inicia a distribuição centralizada em Z=0
let currentZ = -totalGroupWidth / 2;
group.forEach((item) => {
const { pipe, idx } = item;
const zPos = currentZ + pipe.diameter / 2;
currentZ += pipe.diameter + gap;
const yPos = (elevation - height/2) + pipe.elevationOffset + pipe.diameter/2;
const color = pipeColors[idx % pipeColors.length];
pipeMeshes.push(
<mesh key={`pipe-${pipe.id}`} position={[0, yPos, zPos]} rotation={[0, 0, Math.PI / 2]} receiveShadow castShadow>
<cylinderGeometry args={[pipe.diameter / 2, pipe.diameter / 2, width + 1, 16]} />
<meshStandardMaterial color={color} metalness={0.3} roughness={0.2} />
</mesh>
);
});
});
return (
+150
View File
@@ -0,0 +1,150 @@
export interface GlossaryTerm {
id: string;
term: string;
definition: string;
reference: string;
tags: string[];
}
export const nbr6123Glossary: GlossaryTerm[] = [
{
id: 'v0',
term: 'Velocidade Básica do Vento (V₀)',
definition: 'Velocidade do vento obtida a partir de dados meteorológicos. É definida como a velocidade média de 3 segundos, excedida em média uma vez em 50 anos, a 10 metros de altura, sobre terreno plano e aberto. Usada como base para todos os cálculos aerodinâmicos na NBR 6123.',
reference: 'NBR 6123:2023 - Seção 5.2',
tags: ['velocidade', 'isopletas', 'parâmetro básico']
},
{
id: 's1',
term: 'Fator Topográfico (S₁)',
definition: 'Coeficiente que considera as variações do relevo do terreno. Leva em conta se o terreno é plano, fracamente acidentado, talude ou morro, aumentando a velocidade do vento em encostas e cumes.',
reference: 'NBR 6123:2023 - Seção 5.3',
tags: ['relevo', 'topografia', 'talude', 'morro']
},
{
id: 's2',
term: 'Fator de Rugosidade (S₂)',
definition: 'Coeficiente que leva em consideração a rugosidade do terreno (obstáculos como árvores e prédios) e as dimensões da edificação. Depende da categoria do terreno (I a V) e aumenta de acordo com a altura (z) da estrutura.',
reference: 'NBR 6123:2023 - Seção 5.4 / Tabela 3',
tags: ['rugosidade', 'altura', 'categoria', 'dimensões']
},
{
id: 's3',
term: 'Fator Estatístico (S₃)',
definition: 'Fator de probabilidade que considera o nível de segurança e a vida útil da edificação. Valores comuns são 1,0 para edificações normais (50 anos), mas pode ser maior (ex: hospitais) ou menor (ex: instalações temporárias).',
reference: 'NBR 6123:2023 - Seção 5.5',
tags: ['segurança', 'probabilidade', 'uso']
},
{
id: 'vk',
term: 'Velocidade Característica (Vₖ)',
definition: 'A velocidade do vento de projeto em uma determinada altura e situação, calculada multiplicando a velocidade básica pelos três fatores: Vₖ = V₀ × S₁ × S₂ × S₃.',
reference: 'NBR 6123:2023 - Seção 5.1',
tags: ['velocidade de projeto', 'fórmula']
},
{
id: 'q',
term: 'Pressão Dinâmica (q)',
definition: 'A energia cinética do vento transformada em pressão. É calculada a partir da velocidade característica: q = 0,613 × Vₖ² (em N/m²). Esta pressão é aplicada nas superfícies junto com os coeficientes aerodinâmicos.',
reference: 'NBR 6123:2023 - Seção 5.6',
tags: ['pressão', 'energia cinética']
},
{
id: 'cpe',
term: 'Coeficiente de Pressão Externa (Cpe)',
definition: 'Valor adimensional que indica como o vento atua nas faces externas de uma edificação (paredes e telhados). Valores positivos indicam pressão (empurrando a face), valores negativos indicam sucção (puxando a face).',
reference: 'NBR 6123:2023 - Seção 6.2 / Tabelas 6 a 12',
tags: ['pressão', 'sucção', 'externo', 'fachada', 'cobertura']
},
{
id: 'cpi',
term: 'Coeficiente de Pressão Interna (Cpi)',
definition: 'Valor que determina a pressão exercida pelo ar no interior da edificação, gerada devido a aberturas e frestas (portas, janelas, ventilação). Depende do índice de permeabilidade de cada face do prédio.',
reference: 'NBR 6123:2023 - Seção 6.3',
tags: ['pressão', 'interno', 'permeabilidade', 'aberturas']
},
{
id: 'barlavento',
term: 'Barlavento',
definition: 'Região ou face da edificação por onde o vento "chega" ou incide primeiro. Normalmente as superfícies a barlavento sofrem pressões positivas (empuxo contra a parede).',
reference: 'NBR 6123:2023 - Conceito Geral',
tags: ['incidência', 'pressão positiva', 'vento']
},
{
id: 'sotavento',
term: 'Sotavento',
definition: 'Região ou face da edificação que fica "escondida" ou protegida do vento incidente (a face traseira). Normalmente sofre pressões negativas (sucção), pois o vento passa e "puxa" o ar daquela região.',
reference: 'NBR 6123:2023 - Conceito Geral',
tags: ['traseira', 'sucção', 'esteira']
},
{
id: 'ca',
term: 'Coeficiente de Arrasto (Ca)',
definition: 'Coeficiente de força global. Diferente do Cpe, que atua pontualmente em painéis, o Ca define a força total de arrasto gerada no volume inteiro da estrutura (ex: chaminés, torres, pórticos).',
reference: 'NBR 6123:2023 - Seção 6.1.2',
tags: ['arrasto', 'força global', 'torres', 'cilindros']
},
{
id: 'esbeltez',
term: 'Índice de Esbeltez (λ)',
definition: 'Relação entre a altura da estrutura e a sua largura base (ou diâmetro). Estruturas muito esbeltas (finas e altas) são mais sensíveis aos efeitos do vento e exigem coeficientes de arrasto específicos e análises dinâmicas.',
reference: 'NBR 6123:2023 - Seção 6.1 / Diversas Tabelas',
tags: ['altura', 'largura', 'aerodinâmica']
},
{
id: 'vortex',
term: 'Desprendimento de Vórtices (Vortex Shedding)',
definition: 'Fenômeno dinâmico onde o vento, ao passar por uma estrutura (geralmente cilíndrica ou prismática), cria vórtices alternados nas laterais, causando oscilações transversais à direção do vento. Pode gerar ressonância estrutural perigosa.',
reference: 'NBR 6123:2023 - Seção 10.2',
tags: ['dinâmica', 'ressonância', 'cilindros', 'chaminés', 'vibração transversal']
},
{
id: 'flutter',
term: 'Esvoaçamento (Flutter)',
definition: 'Instabilidade aeroelástica dinâmica onde a estrutura (ex: tabuleiro de ponte estaiada) começa a girar e vibrar de forma autossustentada e crescente devido à interação complexa entre o vento e o formato da seção.',
reference: 'NBR 6123:2023 - Seção 11',
tags: ['pontes', 'aeroelasticidade', 'torção', 'instabilidade']
},
{
id: 'galloping',
term: 'Galope (Galloping)',
definition: 'Fenômeno dinâmico de instabilidade em flexão pura transversal à direção do vento. Comum em perfis não circulares ou cabos com formação de gelo/água assimétrica.',
reference: 'NBR 6123:2023 - Seção 11.4',
tags: ['dinâmica', 'pontes', 'cabos', 'vibração lateral']
},
{
id: 'phi',
term: 'Índice de Solidez (φ)',
definition: 'Fator que define a "porosidade" de uma estrutura vazada (como uma torre treliçada, letreiro vazado ou pipe-rack). É a área efetiva (cheia) dos perfis dividida pela área de contorno total do painel. Aumenta o coeficiente de arrasto quanto mais "sólida" for a estrutura.',
reference: 'NBR 6123:2023 - Seção 7',
tags: ['treliças', 'torres', 'permeabilidade', 'bloqueio']
},
{
id: 'eta',
term: 'Fator de Proteção (η)',
definition: 'Quando há pórticos ou treliças posicionados um atrás do outro (ex: pipe-racks ou torres retangulares), a face traseira sofre menor incidência do vento por estar "protegida" pela da frente. O fator η pondera essa redução.',
reference: 'NBR 6123:2023 - Seção 7 / Tabela 28',
tags: ['treliças', 'sombreamento', 'pipe-racks', 'arrasto conjunto']
},
{
id: 'rugosidade',
term: 'Categoria de Terreno',
definition: 'A norma define 5 categorias de rugosidade: I (superfícies lisas, mar calmo), II (campos abertos), III (terrenos planos com pequenos obstáculos, fazendas), IV (áreas urbanas, subúrbios densos) e V (centros de grandes cidades).',
reference: 'NBR 6123:2023 - Seção 5.4.1',
tags: ['terreno', 's2', 'urbanização', 'relevo']
},
{
id: 'forca_atrito',
term: 'Força de Atrito (Ff)',
definition: 'Força que o vento exerce tangencialmente às superfícies paralelas ao seu escoamento, devido à fricção superficial (ex: tetos muito compridos ou paredes extensas).',
reference: 'NBR 6123:2023 - Seção 6.1.5',
tags: ['fricção', 'tangencial', 'galpões compridos']
},
{
id: 'pressao_efetiva',
term: 'Pressão Efetiva (Δp)',
definition: 'A diferença real de pressão que atua em uma superfície (como parede ou telhado), combinando a ação externa (Cpe) e interna (Cpi). Fórmula: Δp = q × (Cpe - Cpi). Resulta na carga real em N/m² que a estrutura vai suportar.',
reference: 'NBR 6123:2023 - Seção 6.1.3',
tags: ['pressão resultante', 'cálculo final']
}
];
+3 -1
View File
@@ -8,8 +8,10 @@
export interface SavedProject {
id?: number;
name: string;
module: 'galpao' | 'cilindro' | 'vault' | 'dome' | 'sign' | 'isolated-roof' | 'bar' | 'bridge' | 'dynamics';
type: 'projeto' | 'template';
module: 'galpao' | 'cilindro' | 'vault' | 'dome' | 'sign' | 'isolated-roof' | 'bar' | 'bridge' | 'dynamics' | 'piperack';
inputs: Record<string, unknown>;
windData?: Record<string, unknown>;
createdAt: number;
updatedAt: number;
}
+31 -1
View File
@@ -1,5 +1,8 @@
import React, { useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { useHydrationStore } from '@/store/hydrationStore';
import { SaveModuleDialog } from '@/components/SaveModuleDialog';
import { Slider } from '@/components/ui/slider';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
@@ -12,11 +15,32 @@ import ExportMenu from '../components/ExportMenu';
import { EducationalManual } from '@/components/EducationalManual';
import { exportGenericToPDF, type GenericPDFSection } from '../lib/export-generic-pdf';
const BridgeModule: React.FC = () => {
const { v0, s1, terrainCategory, q } = useWindStore();
const [lp, setLp] = React.useState(120);
const [width, setWidth] = React.useState(14);
const [deckHeight, setDeckHeight] = React.useState(15);
const pendingLoad = useHydrationStore((s) => s.pendingLoad);
const clearPendingLoad = useHydrationStore((s) => s.clearPendingLoad);
React.useEffect(() => {
if (pendingLoad && pendingLoad.module === 'bridge') {
const p = pendingLoad.inputs as any;
if (p.lp !== undefined) setLp(p.lp);
if (p.width !== undefined) setWidth(p.width);
if (p.deckHeight !== undefined) setDeckHeight(p.deckHeight);
if (p.heg !== undefined) setHeg(p.heg);
if (p.mass !== undefined) setMass(p.mass);
if (p.fv !== undefined) setFv(p.fv);
if (p.vk !== undefined) setVk(p.vk);
if (p.alpha !== undefined) setAlpha(p.alpha);
clearPendingLoad();
}
}, [pendingLoad, clearPendingLoad]);
// Constantes normativas de flutter da seção
const [heg, setHeg] = React.useState(2.5);
const [mass, setMass] = React.useState(18000);
const [fv, setFv] = React.useState(0.6);
@@ -246,7 +270,13 @@ const BridgeModule: React.FC = () => {
</Card>
<div className="flex justify-center bg-background rounded-xl border border-border shadow-sm p-4">
<ExportMenu onExportPDF={handleExportPDF} />
<div className="flex flex-col items-end gap-2">
<SaveModuleDialog
moduleType="bridge"
inputs={{ lp, width, deckHeight, heg, mass, fv, vk, alpha }}
/>
<ExportMenu onExportPDF={handleExportPDF} />
</div>
</div>
<SceneCapturePanel />
+28 -1
View File
@@ -15,6 +15,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import ExportMenu from '../components/ExportMenu';
import { EducationalManual } from '@/components/EducationalManual';
import { PressureLegend } from '@/components/PressureLegend';
import { SaveModuleDialog } from '@/components/SaveModuleDialog';
import { useHydrationStore } from '@/store/hydrationStore';
const GalpaoModule: React.FC = () => {
const {
@@ -33,6 +35,21 @@ const GalpaoModule: React.FC = () => {
windAngle,
setWindAngle,
} = useWindStore();
const pendingLoad = useHydrationStore((s) => s.pendingLoad);
const clearPendingLoad = useHydrationStore((s) => s.clearPendingLoad);
React.useEffect(() => {
if (pendingLoad && pendingLoad.module === 'galpao') {
const p = pendingLoad.inputs as any;
if (p.width !== undefined) setWidth(p.width);
if (p.length !== undefined) setLength(p.length);
if (p.height !== undefined) setHeight(p.height);
if (p.roofPitch !== undefined) setRoofPitch(p.roofPitch);
clearPendingLoad();
}
}, [pendingLoad, clearPendingLoad, setWidth, setLength, setHeight, setRoofPitch]);
return (
<div className="flex flex-col lg:flex-row h-full w-full gap-4 p-4 lg:p-6 bg-muted/30">
{/* Coluna Esquerda: Controles */}
@@ -146,7 +163,6 @@ const GalpaoModule: React.FC = () => {
</div>
<div className="flex flex-wrap gap-2 pointer-events-auto justify-start sm:justify-end">
<ExportMenu />
<EducationalManual type="warehouse" params={{ windAngle, cpi }} />
</div>
</div>
@@ -161,6 +177,17 @@ const GalpaoModule: React.FC = () => {
{/* Coluna Direita-Inferior: Cargas Lineares (M9.2) */}
<div className="w-full lg:w-96 shrink-0 flex flex-col gap-4 overflow-y-auto pb-8 lg:pb-0">
<LinearLoadsTable />
<div className="flex justify-center bg-background rounded-xl border border-border shadow-sm p-4">
<div className="flex flex-col items-end gap-2">
<SaveModuleDialog
moduleType="galpao"
inputs={{ width, length, height, roofPitch }}
/>
<ExportMenu />
</div>
</div>
<SceneCapturePanel />
<FtoolExportCard />
</div>
+31 -4
View File
@@ -1,5 +1,8 @@
import React, { useMemo, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useHydrationStore } from '@/store/hydrationStore';
import { SaveModuleDialog } from '@/components/SaveModuleDialog';
import { Slider } from '@/components/ui/slider';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -24,6 +27,23 @@ const PiperackModule: React.FC = () => {
const [phiStruct, setPhiStruct] = useState(0.5);
const [pipes, setPipes] = useState<PipeDef[]>([]);
const pendingLoad = useHydrationStore((s) => s.pendingLoad);
const clearPendingLoad = useHydrationStore((s) => s.clearPendingLoad);
React.useEffect(() => {
if (pendingLoad && pendingLoad.module === 'piperack') {
const p = pendingLoad.inputs as any;
if (p.elevation !== undefined) setElevation(p.elevation);
if (p.width !== undefined) setWidth(p.width);
if (p.height !== undefined) setHeight(p.height);
if (p.spacing !== undefined) setSpacing(p.spacing);
if (p.numFrames !== undefined) setNumFrames(p.numFrames);
if (p.pipes !== undefined) setPipes(p.pipes);
if (p.phiStruct !== undefined) setPhiStruct(p.phiStruct);
clearPendingLoad();
}
}, [pendingLoad, clearPendingLoad]);
const addPipe = () => {
setPipes([...pipes, { id: Math.random().toString(36).substr(2, 9), diameter: 0.5, elevationOffset: height / 2 }]);
};
@@ -91,7 +111,7 @@ const PiperackModule: React.FC = () => {
return (
<div className="flex flex-col h-[calc(100vh-3.5rem)] md:h-screen bg-background">
<header className="shrink-0 flex items-center justify-between px-6 py-4 border-b bg-card">
<header className="shrink-0 flex items-center px-6 py-4 border-b bg-card">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Layers className="w-5 h-5 text-primary" />
@@ -101,9 +121,6 @@ const PiperackModule: React.FC = () => {
<p className="text-sm text-muted-foreground">Vento transversal (Sec. 7 - NBR 6123)</p>
</div>
</div>
<div className="flex items-center gap-3">
<ExportMenu onExportPDF={handleExportPDF} />
</div>
</header>
<div className="flex-1 flex flex-col lg:flex-row p-4 gap-4 overflow-hidden bg-muted/30">
@@ -403,6 +420,16 @@ const PiperackModule: React.FC = () => {
</CardContent>
</Card>
<div className="flex justify-center bg-background rounded-xl border border-border shadow-sm p-4">
<div className="flex flex-col items-end gap-2">
<SaveModuleDialog
moduleType="piperack"
inputs={{ elevation, width, height, spacing, numFrames, pipes, phiStruct }}
/>
<ExportMenu onExportPDF={handleExportPDF} />
</div>
</div>
<SceneCapturePanel />
</div>
</div>
+140 -109
View File
@@ -1,27 +1,31 @@
import React, { useRef, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Sun, Moon, Laptop, Save, Trash2, Upload, CheckCircle2, AlertCircle, FlaskConical } from 'lucide-react';
import { Sun, Moon, Laptop, Save, Trash2, Upload, FlaskConical, Download, FolderOpen, FileBox, FileJson } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTheme } from '@/lib/theme';
import { useProjects } from '@/lib/hooks/useProjects';
import { useWindStore } from '@/store/appStore';
import { useHydrationStore } from '@/store/hydrationStore';
import { useI18n } from '@/store/i18nStore';
import { Badge } from '@/components/ui/badge';
import { useNavigate, useLocation } from 'react-router-dom';
import {
importProjectFromText,
readProjectFile,
type ImportResult,
} from '@/lib/import-project';
import AuditPanel from '@/components/AuditPanel';
import Glossary from '@/components/Glossary';
const SettingsModule: React.FC = () => {
const { theme, setTheme, effectiveTheme } = useTheme();
const { projects, loading, error, remove } = useProjects();
const { projects, loading, error, remove, save } = useProjects();
const { t, locale } = useI18n();
const navigate = useNavigate();
const location = useLocation();
const setPendingLoad = useHydrationStore((s) => s.setPendingLoad);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [importing, setImporting] = useState(false);
const exportSnapshot = () => {
@@ -30,12 +34,36 @@ const SettingsModule: React.FC = () => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', 'ventoapp-state.json');
link.setAttribute('download', 'ventoapp-snapshot.json');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleDownloadJSON = (project: any) => {
const blob = new Blob([JSON.stringify(project, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', `${project.name}.json`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleLoadProject = (project: any) => {
if (project.type === 'projeto' && project.windData) {
// Restore wind data
useWindStore.setState(project.windData);
}
// Set pending load
setPendingLoad(project.module, project.inputs);
// Navigate to module
navigate(`/${project.module === 'isolated-roof' ? 'cobertura-isolada' : project.module === 'sign' ? 'muros' : project.module === 'bar' ? 'barras' : project.module === 'bridge' ? 'pontes' : project.module === 'dynamics' ? 'dinamica' : project.module === 'vault' ? 'abobada' : project.module === 'dome' ? 'cupula' : project.module}`);
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
@@ -44,29 +72,51 @@ const SettingsModule: React.FC = () => {
const file = event.target.files?.[0];
if (!file) return;
setImporting(true);
setImportResult(null);
try {
const text = await readProjectFile(file);
const result = importProjectFromText(text);
setImportResult(result);
const parsed = JSON.parse(text);
if (parsed.module && parsed.inputs) {
// Formato SavedProject (Projeto ou Template)
const newProject = {
...parsed,
name: parsed.name || 'Importado - ' + file.name,
type: parsed.type || 'projeto',
createdAt: Date.now(),
updatedAt: Date.now(),
};
// Ensure type is correct
if (!['projeto', 'template'].includes(newProject.type)) newProject.type = 'projeto';
await save(newProject as any);
} else {
// Old snapshot import fallback
importProjectFromText(text);
}
} catch (e) {
setImportResult({
ok: false,
error: e instanceof Error ? e.message : 'Falha ao ler arquivo',
});
console.error(e);
} finally {
setImporting(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const currentTab = new URLSearchParams(location.search).get('tab') || 'config';
const isSettingsGroup = currentTab === 'config' || currentTab === 'testes';
return (
<div className="p-6 max-w-5xl mx-auto overflow-auto">
<h1 className="text-3xl font-bold mb-6">{t('nav_settings')}</h1>
<h1 className="text-3xl font-bold mb-6 md:hidden">Configurações</h1>
<h1 className="text-3xl font-bold mb-6 hidden md:block">
{currentTab === 'gerenciador' ? 'Arquivos' :
currentTab === 'glossary' ? 'Glossário' :
'Configurações'}
</h1>
<Tabs defaultValue="config" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="config">Configurações</TabsTrigger>
<Tabs value={currentTab} onValueChange={(val) => navigate(`/settings?tab=${val}`)} className="w-full">
<TabsList className={`grid w-full mb-6 ${isSettingsGroup ? 'grid-cols-4 md:grid-cols-2' : 'grid-cols-4 md:hidden'}`}>
<TabsTrigger value="config">Preferências</TabsTrigger>
<TabsTrigger value="gerenciador" className="md:hidden">Arquivos</TabsTrigger>
<TabsTrigger value="glossary" className="text-red-500 uppercase font-bold tracking-wider md:hidden">GLOSSÁRIO</TabsTrigger>
<TabsTrigger value="testes">
<FlaskConical className="w-4 h-4 mr-1.5" />
Testes
@@ -98,99 +148,6 @@ const SettingsModule: React.FC = () => {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('settings_projects')}</CardTitle>
<CardDescription>{t('settings_projects_desc')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between mb-3 gap-2 flex-wrap">
<p className="text-sm text-muted-foreground">
{t('settings_projects_count', { count: projects.length })}
</p>
<div className="grid grid-cols-2 gap-1.5 w-full sm:w-auto sm:flex">
<Button size="sm" variant="outline" className="text-xs px-2 h-8 flex-1 sm:flex-none" onClick={handleImportClick} disabled={importing}>
<Upload className="w-3.5 h-3.5 mr-1.5 shrink-0" /> {importing ? t('settings_importing') : t('export_import')}
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleFileSelected}
className="hidden"
/>
<Button size="sm" variant="outline" className="text-xs px-2 h-8 flex-1 sm:flex-none" onClick={exportSnapshot}>
<Save className="w-3.5 h-3.5 mr-1.5 shrink-0" /> {t('export_snapshot')}
</Button>
</div>
</div>
{importResult && (
<div
className={`rounded-md border p-3 mb-3 text-sm flex items-start gap-2 ${
importResult.ok
? 'bg-emerald-50 border-emerald-200 text-emerald-700 dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-300'
: 'bg-red-50 border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300'
}`}
>
{importResult.ok ? (
<CheckCircle2 className="w-4 h-4 mt-0.5 shrink-0" />
) : (
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="font-medium">
{importResult.ok ? t('settings_import_success') : t('settings_import_error')}
</div>
{importResult.module && (
<div className="text-xs mt-1">
{t('settings_import_module')}: <Badge variant="outline">{importResult.module}</Badge>
{importResult.projectName && (
<span className="ml-2">{t('settings_import_project')}: {importResult.projectName}</span>
)}
</div>
)}
{importResult.appliedFields && importResult.appliedFields.length > 0 && (
<div className="text-xs mt-1">
{t('settings_import_fields', { count: importResult.appliedFields.length })}: {importResult.appliedFields.join(', ')}
</div>
)}
{importResult.warnings && importResult.warnings.length > 0 && (
<div className="text-xs mt-1 opacity-80">
{t('settings_import_warnings')}: {importResult.warnings.join('; ')}
</div>
)}
{importResult.error && (
<div className="text-xs mt-1">{importResult.error}</div>
)}
</div>
</div>
)}
{loading && <p className="text-sm text-muted-foreground">{t('common_loading')}</p>}
{error && <p className="text-sm text-destructive">{error}</p>}
{projects.length === 0 && !loading && (
<p className="text-sm text-muted-foreground">{t('settings_no_projects')}</p>
)}
<div className="space-y-2">
{projects.map((p) => (
<div key={p.id} className="flex items-center justify-between p-2 border rounded-md hover:bg-muted/40">
<div className="flex-1">
<div className="font-medium text-sm">{p.name}</div>
<div className="text-xs text-muted-foreground">
{p.module} · {new Date(p.updatedAt).toLocaleString(locale === 'pt-BR' ? 'pt-BR' : 'en-US')}
</div>
</div>
<Button size="sm" variant="ghost" onClick={() => p.id && remove(p.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('settings_about')}</CardTitle>
@@ -210,10 +167,84 @@ const SettingsModule: React.FC = () => {
</Card>
</TabsContent>
<TabsContent value="gerenciador" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">Arquivos (Gerenciador)</CardTitle>
<CardDescription>Gerencie seus Projetos Completos e Templates de geometria salvos localmente.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
<p className="text-sm text-muted-foreground">
{projects.length} arquivo(s) armazenado(s).
</p>
<div className="grid grid-cols-2 gap-1.5 w-full sm:w-auto sm:flex">
<Button size="sm" variant="outline" className="text-xs px-2 h-8 flex-1 sm:flex-none" onClick={handleImportClick} disabled={importing}>
<Upload className="w-3.5 h-3.5 mr-1.5 shrink-0" /> Importar Arquivo
</Button>
<Button size="sm" variant="outline" className="text-xs px-2 h-8 flex-1 sm:flex-none" onClick={exportSnapshot}>
<Save className="w-3.5 h-3.5 mr-1.5 shrink-0" /> Exportar Wind Snapshot
</Button>
</div>
</div>
{loading && <p className="text-sm text-muted-foreground">{t('common_loading')}</p>}
{error && <p className="text-sm text-destructive">{error}</p>}
{projects.length === 0 && !loading && (
<p className="text-sm text-muted-foreground">Nenhum projeto ou template salvo ainda.</p>
)}
<div className="space-y-2">
{projects.map((p) => (
<div key={p.id} className="flex items-center justify-between p-3 border rounded-md hover:bg-muted/40 transition-colors">
<div className="flex items-center gap-3">
{p.type === 'projeto' ? (
<div className="p-2 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded-md">
<FileBox className="w-5 h-5" />
</div>
) : (
<div className="p-2 bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400 rounded-md">
<FileJson className="w-5 h-5" />
</div>
)}
<div>
<div className="font-medium text-sm flex items-center gap-2">
{p.name}
<Badge variant="outline" className="text-[10px] px-1 py-0 h-4">
{p.module}
</Badge>
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{p.type === 'projeto' ? 'Projeto (Vento + Geometria)' : 'Template (Apenas Geometria)'} · {new Date(p.updatedAt).toLocaleString(locale === 'pt-BR' ? 'pt-BR' : 'en-US')}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Button size="sm" variant="ghost" title="Carregar" className="h-8 w-8 p-0 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:bg-emerald-950/50" onClick={() => handleLoadProject(p)}>
<FolderOpen className="w-4 h-4" />
</Button>
<Button size="sm" variant="ghost" title="Baixar JSON" className="h-8 w-8 p-0" onClick={() => handleDownloadJSON(p)}>
<Download className="w-4 h-4" />
</Button>
<Button size="sm" variant="ghost" title="Excluir" className="h-8 w-8 p-0 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/50" onClick={() => p.id && remove(p.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="glossary" className="space-y-6">
<Glossary />
</TabsContent>
<TabsContent value="testes" className="space-y-6">
<AuditPanel />
</TabsContent>
</Tabs>
<input type="file" ref={fileInputRef} onChange={handleFileSelected} className="hidden" accept=".json" />
</div>
);
};
+16
View File
@@ -0,0 +1,16 @@
import { create } from 'zustand';
export interface HydrationState {
pendingLoad: {
module: string;
inputs: Record<string, unknown>;
} | null;
setPendingLoad: (module: string, inputs: Record<string, unknown>) => void;
clearPendingLoad: () => void;
}
export const useHydrationStore = create<HydrationState>((set) => ({
pendingLoad: null,
setPendingLoad: (module, inputs) => set({ pendingLoad: { module, inputs } }),
clearPendingLoad: () => set({ pendingLoad: null }),
}));