Compare commits

..

25 Commits

Author SHA1 Message Date
Marcos b6c96ada9c 🚀 Auto-deploy: BrainWind atualizado em 01/09/2026 17:01:56 2026-09-01 17:01:56 +00:00
Marcos 3219011b33 🚀 Auto-deploy: BrainWind atualizado em 01/09/2026 16:55:27 2026-09-01 16:55:27 +00:00
Marcos f66ae1a3aa 🚀 Auto-deploy: BrainWind atualizado em 01/09/2026 15:08:48 2026-09-01 15:08:48 +00:00
Marcos 91cb5a78c5 🚀 Auto-deploy: BrainWind atualizado em 01/09/2026 15:06:36 2026-09-01 15:06:36 +00:00
Marcos 7b179aaeea 🚀 Auto-deploy: BrainWind atualizado em 01/09/2026 14:55:28 2026-09-01 14:55:28 +00:00
Marcos 02811b09a4 🚀 Auto-deploy: BrainWind atualizado em 01/09/2026 14:27:19 2026-09-01 14:27:19 +00:00
Marcos fd71215958 🚀 Auto-deploy: BrainWind atualizado em 26/08/2026 11:50:27 2026-08-26 11:50:27 +00:00
Marcos f787e8c002 🚀 Auto-deploy: BrainWind atualizado em 26/08/2026 11:43:19 2026-08-26 11:43:19 +00:00
Marcos b842d6009f 🚀 Auto-deploy: BrainWind atualizado em 26/08/2026 10:57:31 2026-08-26 10:57:31 +00:00
Marcos ddcb56d78b 🚀 Auto-deploy: BrainWind atualizado em 26/08/2026 10:50:25 2026-08-26 10:50:25 +00:00
Marcos f1cbfc1604 🚀 Auto-deploy: BrainWind atualizado em 26/08/2026 10:41:48 2026-08-26 10:41:48 +00:00
Marcos 6b6ff35bbd 🚀 Auto-deploy: BrainWind atualizado em 25/08/2026 18:32:50 2026-08-25 18:32:50 +00:00
Marcos 495dace14c 🚀 Auto-deploy: BrainWind atualizado em 24/08/2026 14:03:31 2026-08-24 14:03:31 +00:00
Marcos 2ee2632556 🚀 Auto-deploy: BrainWind atualizado em 24/08/2026 13:28:27 2026-08-24 13:28:27 +00:00
Marcos 5f9580436c 🚀 Auto-deploy: BrainWind atualizado em 24/08/2026 13:22:29 2026-08-24 13:22:29 +00:00
Marcos 0a4dc9b57a 🚀 Auto-deploy: BrainWind atualizado em 24/08/2026 11:28:13 2026-08-24 11:28:13 +00:00
Marcos ff01f68941 🚀 Auto-deploy: BrainWind atualizado em 30/07/2026 11:01:30 2026-07-30 11:01:30 +00:00
Marcos 8d5f70e94a 🚀 Auto-deploy: BrainWind atualizado em 30/07/2026 10:38:28 2026-07-30 10:38:28 +00:00
Marcos dc90b289c5 🚀 Auto-deploy: BrainWind atualizado em 30/07/2026 10:29:57 2026-07-30 10:29:57 +00:00
Marcos a30125c7ce 🚀 Auto-deploy: BrainWind atualizado em 29/07/2026 12:00:49 2026-07-29 12:00:49 +00:00
Marcos fde885ca60 🚀 Auto-deploy: BrainWind atualizado em 29/07/2026 10:44:07 2026-07-29 10:44:07 +00:00
Marcos d4d4905798 🚀 Auto-deploy: BrainWind atualizado em 29/07/2026 10:31:09 2026-07-29 10:31:09 +00:00
Marcos b1cadac6ab 🚀 Auto-deploy: BrainWind atualizado em 29/07/2026 10:27:37 2026-07-29 10:27:37 +00:00
Marcos 6141b9a41c correcao de texto - girando 2026-07-28 20:44:36 +00:00
Marcos dff84c78ba ajuste dos textos 3d galpao 2026-07-28 20:37:25 +00:00
55 changed files with 8412 additions and 637 deletions
+74
View File
@@ -0,0 +1,74 @@
# 🌪️ BrainWind: Dossiê Institucional & Técnico
> **A plataforma definitiva de engenharia para cálculo aerodinâmico e ações do vento em conformidade integral com a ABNT NBR 6123:2023.**
---
## 1. O que é o BrainWind?
O **BrainWind** é um software de engenharia estrutural de última geração, desenvolvido para calcular, simular e validar com máxima precisão e interatividade visual as **forças e pressões do vento** incidentes sobre as mais diversas tipologias estruturais.
Diferente de planilhas eletrônicas frágeis ou módulos estáticos e burocráticos presentes em softwares tradicionais, o BrainWind transforma formulações matemáticas densas da **NBR 6123:2023** em uma experiência visual em tempo real (3D e 2D), didática e instantaneamente acionável para o projetista estrutural.
---
## 2. Diferenciais Competitivos no Mercado
| Aspecto | Softwares Tradicionais / Planilhas | **BrainWind** |
| :--- | :--- | :--- |
| **Norma Técnica** | Fórmulas legadas ou parciais (NBR 6123:1988) | **100% de Cobertura da NBR 6123:2023** (todas as 36 tabelas e anexos oficiais) |
| **Visualização** | Gráficos 2D estáticos ou tabelas em preto e branco | **Modelos 3D Interativos (Three.js)** + Linhas de Fluxo de Vento (*Airflow*) + Heatmaps 2D em tempo real |
| **Comparação de Cenários** | Necessidade de rodar cálculos separados | **Modo A/B Simultâneo** (vento a 0° vs 90° lado a lado na mesma tela) |
| **Validação e Confiabilidade** | "Caixa-preta" sem comprovação de gabarito | **TesteBib**: Validação em tempo real contra casos clássicos da literatura (Blessmann, Pfeil, Rebello) |
| **Integração com Análise** | Digitação manual de cargas nó a nó | **Exportação nativa para Ftool (.txt)**, PDFs com memorial didático e CSVs |
| **Auditoria por Inteligência Artificial** | Inexistente | **Módulo de Auditoria Automatizada por LLM** que audita os cálculos e consistência |
| **Acessibilidade** | Exige instalação pesada de desktop | **PWA Moderno (Web/Mobile)**, ultra-rápido, funciona offline com persistência IndexedDB |
---
## 3. Principais Highlights que Surpreendem
### 📐 1. Abrangência Estrutural sem Paralelo (14 Módulos)
O BrainWind cobre desde a edificação mais comum até estruturas industriais e especiais complexas:
- **Galpões e Edifícios Paralelepipédicos:** Zonas de pressão externa (A, B, C, D nas paredes; E, F, G, H, I, J no telhado), coeficientes $C_{pe}$ e pressões internas $C_{pi}$.
- **Silos, Tanques e Chaminés Cilíndricas:** Número de Reynolds ($Re$), regimes sub/supercrítico e perfil completo da Tabela 13.
- **Abóbadas Cilíndricas & Cúpulas Semiesféricas:** Sustentação aerodinâmica, empuxo ascendente e regimes laminares/turbulentos.
- **Muros, Placas e Platibandas:** Forças de arrasto em placas com ou sem folga em relação ao solo (*ground clearance*).
- **Coberturas Isoladas (Hangares e Marquises):** A uma água, duas águas simétricas e formato em "V" (borboleta).
- **Barras Prismáticas & Cantoneiras:** Coeficientes de força $C_x$ e $C_y$ para qualquer ângulo $\alpha$ e fator de redução por comprimento finito ($K$).
- **Pontes Rodoviárias/Ferroviárias:** Classificação estática/dinâmica e forças de sustentação e arrasto no tabuleiro.
- **Torres Treliçadas de Telecomunicação:** Seções quadradas e triangulares com índice de plenitude $\varphi$.
- **Pipe-Racks Industriais:** Quadros estruturais múltiplos com bandejas e tubulações, calculando sombreamento ($\eta$) e arrasto global.
- **Abrigos Leves & Tendas Tensionadas:** Checagem de arrancamento do vento (*uplift*) vs massa superficial gravitacional.
- **Efeitos Dinâmicos e Conforto Humano:** Desprendimento de vórtices (Von Kármán, Strouhal, Scruton) e critérios de aceleração máxima conforme a ISO 10137.
---
### 🔬 2. Validação Bibliográfica Transparente (TesteBib)
O BrainWind não esconde cálculos. Na aba **TesteBib**, o projetista tem acesso a dezenas de problemas resolvidos passo a passo extraídos dos principais manuais brasileiros de engenharia:
- Prof. **Joaquim Blessmann** (*O Vento na Engenharia Estrutural*, *Ação do Vento em Torres*, *Ação do Vento em Coberturas Curvas*).
- Prof. **Walter Pfeil & Michèle Pfeil** (*Estruturas de Aço*).
- Prof. **Yopanan Rebello** (*A Concepção Estrutural e a Arquitetura*).
Cada cenário compara os resultados em tempo real do motor de cálculo (*Kernel Local*) com o gabarito oficial da literatura, comprovando tolerância zero a desvios.
---
### 🎨 3. UI/UX de Alta Engenharia com "Modo A/B"
- **Visualizador 3D com Linhas de Corrente:** Permite alternar entre o modo *Sólido* (com mapeamento de cor para sucção em vermelho e sobrepressão em azul) e o modo *Fluxo* (partículas animadas que mostram o desvio do vento).
- **Heatmap 2D Proporcional:** Planta baixa detalhada das zonas de pressão com indicação de vento e cotas dinâmicas.
- **Modo A/B Integrado:** Visualize no mesmo painel a planta do galpão sofrendo vento a 0° e vento a 90°, permitindo identificar instantaneamente o pior caso para o dimensionamento dos pilares e terças.
---
### ⚡ 4. Produtividade Máxima no Fluxo de Projeto
- **Geração de Cargas Lineares:** Transforma pressões superficiais ($\text{kN/m}^2$) em cargas lineares distribuídas ($\text{kN/m}$) para os pórticos principais e terças de cobertura.
- **Combinações de Ações (NBR 8681 / NBR 6118):** Combinação automática em ELU e ELS ($1.4\,PP + 1.4\,Vento$).
- **Exportação Ftool (.txt):** Exporta diretamente a geometria do pórtico e as cargas de vento prontas para serem abertas e calculadas no Ftool.
- **Certificado de Fidelidade Normativa:** Emissão de certificado digital de conformidade com a NBR 6123:2023.
---
## 4. Conclusão
O **BrainWind** posiciona-se não apenas como uma calculadora, mas como uma **suíte completa de inteligência aerodinâmica para a engenharia estrutural**. Ele une o rigor matemático absoluto da ABNT ao que há de mais avançado em tecnologia web e computação gráfica, garantindo projetos mais seguros, econômicos, auditáveis e profissionais.
+5
View File
@@ -0,0 +1,5 @@
[phases.setup]
nixPkgs = ["nodejs_23"]
[start]
cmd = "npx --yes serve@latest -s dist"
+5144 -6
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"start": "npx --yes serve@latest -s dist",
"lint": "oxlint",
"preview": "vite preview",
"test": "npm run test:engenharia && npm run test:sistema",
@@ -35,6 +36,7 @@
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"three": "^0.185.1",
"zundo": "^2.3.0",
"zustand": "^5.0.14"
},
"devDependencies": {
@@ -49,6 +51,7 @@
"tailwindcss": "^4.3.2",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.10"
}
}
+39 -93
View File
@@ -1,19 +1,20 @@
import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom';
import GalpaoModule from './pages/GalpaoModule';
import CylinderModule from './pages/CylinderModule';
import VaultModule from './pages/VaultModule';
import DomeModule from './pages/DomeModule';
import SignModule from './pages/SignModule';
import IsolatedRoofModule from './pages/IsolatedRoofModule';
import BarSelectorModule from './pages/BarSelectorModule';
import BridgeModule from './pages/BridgeModule';
import DynamicsModule from './pages/DynamicsModule';
import CalcCaModule from './pages/CalcCaModule';
import SettingsModule from './pages/SettingsModule';
import TowerModule from './pages/TowerModule';
import PiperackModule from './pages/PiperackModule';
import ShelterModule from './pages/ShelterModule';
import CertificatePage from './pages/CertificatePage';
// Lazy load modules to improve performance and code splitting
const GalpaoModule = lazy(() => import('./pages/GalpaoModule'));
const CylinderModule = lazy(() => import('./pages/CylinderModule'));
const VaultModule = lazy(() => import('./pages/VaultModule'));
const DomeModule = lazy(() => import('./pages/DomeModule'));
const SignModule = lazy(() => import('./pages/SignModule'));
const IsolatedRoofModule = lazy(() => import('./pages/IsolatedRoofModule'));
const BarSelectorModule = lazy(() => import('./pages/BarSelectorModule'));
const BridgeModule = lazy(() => import('./pages/BridgeModule'));
const DynamicsModule = lazy(() => import('./pages/DynamicsModule'));
const CalcCaModule = lazy(() => import('./pages/CalcCaModule'));
const SettingsModule = lazy(() => import('./pages/SettingsModule'));
const TowerModule = lazy(() => import('./pages/TowerModule'));
const PiperackModule = lazy(() => import('./pages/PiperackModule'));
const ShelterModule = lazy(() => import('./pages/ShelterModule'));
const CertificatePage = lazy(() => import('./pages/CertificatePage'));
import {
Home, Settings, Menu, Cylinder, Church, CircleDot,
Square, Layers, BarChart3, Activity, Warehouse,
@@ -26,7 +27,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useState } from 'react';
import { useState, lazy, Suspense } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { ThemeProvider, useTheme } from '@/lib/theme';
@@ -74,6 +75,8 @@ const TowerIcon = (props: React.SVGProps<SVGSVGElement>) => (
</svg>
);
import HomeMock from './pages/HomeMock';
function AppLayout({ children }: { children: React.ReactNode }) {
const location = useLocation();
const [isCollapsed, setIsCollapsed] = useState(false);
@@ -347,66 +350,7 @@ function AppLayout({ children }: { children: React.ReactNode }) {
);
}
function HomeMock() {
const { t } = useI18n();
const modules = [
{ to: '/galpao', icon: Warehouse, label: 'Galpão Retangular', desc: 'Paredes A/B/C/D, telhados E-J, excentricidade, atrito, alta turbulência.' },
{ to: '/cilindro', icon: Cylinder, label: 'Cilindro Vertical', desc: 'Silos, reservatórios, chaminés. Cpe por ângulo (Tab. 13), Reynolds.' },
{ to: '/abobada', icon: Church, label: 'Abóbada Cilíndrica', desc: 'Coberturas curvas em arco. Tab. 15-20, 6 zonas.' },
{ to: '/cupula', icon: CircleDot, label: 'Cúpula', desc: 'Sobre terreno (Tab. 21) ou parede cilíndrica (Tab. 22).' },
{ to: '/muros', icon: Square, label: 'Muros e Placas', desc: 'Cf para vento perpendicular e oblíquo (Tab. 23).' },
{ to: '/cobertura-isolada', icon: Layers, label: 'Coberturas Isoladas', desc: 'Uma ou duas águas, abas perpendiculares (Tab. 24-25).' },
{ to: '/barras', icon: BarChart3, label: 'Barras Prismáticas', desc: 'Faces planas (Tab. 26) ou circulares (Tab. 27).' },
{ to: '/pontes', icon: BridgeIcon, label: 'Pontes', desc: 'Pse, Cx/Cz do tabuleiro, flutter, galope (sec. 11).' },
{ to: '/torre', icon: TowerIcon, label: 'Torres', desc: 'Torres treliçadas e mastros (sec. 8.4).' },
{ to: '/piperack', icon: Frame, label: 'Pipe-Racks Industriais', desc: 'Estruturas reticuladas múltiplas, tubulações, fator η (Tab. 28).' },
{ to: '/abrigo', icon: Tent, label: 'Abrigos e Pórticos Fechados', desc: 'Marquises, abrigos e pórticos. 4 condições topológicas, frestas de alívio, corte A-A (Tab. 23-25, sec. 6.3).' },
{ to: '/calc-ca', icon: Calculator, label: 'Calculadora de C_a', desc: 'Coeficiente de arrasto (Fig. 4 e 5).' },
];
return (
<div className="p-8 max-w-6xl mx-auto space-y-8">
<header className="text-center space-y-4 flex flex-col items-center mt-4 mb-6">
<div className="relative inline-block">
<img src="/logo_brainwind.png" alt="BrainWind" className="h-20 md:h-28 w-auto object-contain dark:drop-shadow-[0_0_15px_rgba(255,255,255,0.4)]" />
<span className="absolute -bottom-1 -right-2 md:-right-6 bg-primary text-primary-foreground text-[10px] md:text-xs font-bold px-2 py-0.5 rounded-full shadow-sm border border-primary-foreground/20">v1.0</span>
</div>
<p className="text-muted-foreground text-lg">
{t('app_subtitle')}
</p>
</header>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{modules.map((m) => (
<Link key={m.to} to={m.to} className="block p-6 rounded-xl border bg-card hover:shadow-lg hover:border-primary transition-all">
<m.icon className="w-8 h-8 text-primary mb-3" />
<h2 className="font-semibold text-lg mb-1">{m.label}</h2>
<p className="text-sm text-muted-foreground">{m.desc}</p>
</Link>
))}
</div>
<div className="rounded-lg border bg-muted/40 p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground mb-2">{t('home_full_coverage')}</p>
<ul className="grid grid-cols-2 md:grid-cols-4 gap-1 text-xs">
<li> Sec. 5 V, S, S, S</li>
<li> Sec. 6.1 Paralelepipédicas</li>
<li> Sec. 6.2 Cilindros, abóbadas, cúpulas</li>
<li> Sec. 6.3 Pressão interna (Cpi)</li>
<li> Sec. 6.4 Vizinhança</li>
<li> Sec. 7 Muros, coberturas isoladas</li>
<li> Sec. 8 Barras e reticulados</li>
<li> Sec. 9 Efeitos dinâmicos</li>
<li> Sec. 10 Vórtices</li>
<li> Sec. 11 Pontes</li>
<li> Anexo A S(qualquer t)</li>
<li> Anexo B S(Pₘ, vida útil)</li>
<li> Anexo C 49 estações meteorológicas</li>
</ul>
</div>
</div>
);
}
function App() {
return (
@@ -414,24 +358,26 @@ function App() {
<TooltipProvider>
<BrowserRouter>
<AppLayout>
<Routes>
<Route path="/" element={<HomeMock />} />
<Route path="/galpao" element={<GalpaoModule />} />
<Route path="/cilindro" element={<CylinderModule />} />
<Route path="/abobada" element={<VaultModule />} />
<Route path="/cupula" element={<DomeModule />} />
<Route path="/muros" element={<SignModule />} />
<Route path="/cobertura-isolada" element={<IsolatedRoofModule />} />
<Route path="/barras" element={<BarSelectorModule />} />
<Route path="/pontes" element={<BridgeModule />} />
<Route path="/torre" element={<TowerModule />} />
<Route path="/piperack" element={<PiperackModule />} />
<Route path="/abrigo" element={<ShelterModule />} />
<Route path="/dinamica" element={<DynamicsModule />} />
<Route path="/calc-ca" element={<CalcCaModule />} />
<Route path="/settings" element={<SettingsModule />} />
<Route path="/certificado" element={<CertificatePage />} />
</Routes>
<Suspense fallback={<div className="flex h-full w-full items-center justify-center text-muted-foreground">Carregando módulo...</div>}>
<Routes>
<Route path="/" element={<HomeMock />} />
<Route path="/galpao" element={<GalpaoModule />} />
<Route path="/cilindro" element={<CylinderModule />} />
<Route path="/abobada" element={<VaultModule />} />
<Route path="/cupula" element={<DomeModule />} />
<Route path="/muros" element={<SignModule />} />
<Route path="/cobertura-isolada" element={<IsolatedRoofModule />} />
<Route path="/barras" element={<BarSelectorModule />} />
<Route path="/pontes" element={<BridgeModule />} />
<Route path="/torre" element={<TowerModule />} />
<Route path="/piperack" element={<PiperackModule />} />
<Route path="/abrigo" element={<ShelterModule />} />
<Route path="/dinamica" element={<DynamicsModule />} />
<Route path="/calc-ca" element={<CalcCaModule />} />
<Route path="/settings" element={<SettingsModule />} />
<Route path="/certificado" element={<CertificatePage />} />
</Routes>
</Suspense>
</AppLayout>
</BrowserRouter>
</TooltipProvider>
+1 -1
View File
@@ -85,7 +85,7 @@ export function DragCoefficientChart({ l1, l2, h, caLow, caHigh }: DragCoefficie
{/* Fake Curves */}
{curves.map((d, idx) => (
<path key={idx} d={d} fill="none" stroke="currentColor" strokeOpacity={0.2} strokeWidth={1.5} className="text-red-500" />
<path key={idx} d={d} fill="none" strokeWidth={1.5} className="stroke-amber-500/40 dark:stroke-amber-400/60" />
))}
{/* Axes */}
+1 -1
View File
@@ -400,7 +400,7 @@ function TowerDiagram({ baseWidth, height, panels, phi, forceKN }: TowerProps) {
}
interface IsolatedRoofProps {
type: 'shed' | 'gable';
type: 'shed' | 'gable' | 'butterfly';
theta: number;
height: number;
width: number;
@@ -42,6 +42,8 @@ export function GlobalWindSettingsModal({ trigger }: GlobalWindSettingsModalProp
setTerrainCategory,
locality,
setLocality,
fviz,
setFviz,
} = useWindStore();
const [stationQuery, setStationQuery] = useState('');
@@ -77,6 +79,7 @@ export function GlobalWindSettingsModal({ trigger }: GlobalWindSettingsModalProp
<TabsList className="flex w-full mb-4 bg-muted p-1 text-xs min-w-0">
<TabsTrigger value="norma" className="text-xs py-1 px-1.5 flex-1">NBR 6123</TabsTrigger>
<TabsTrigger value="local" className="text-xs py-1 px-1.5 flex-1">Localidade</TabsTrigger>
<TabsTrigger value="vizinhanca" className="text-xs py-1 px-1.5 flex-1">Vizinhança 3D</TabsTrigger>
</TabsList>
<TabsContent value="norma" className="space-y-4 min-w-0">
@@ -215,6 +218,21 @@ export function GlobalWindSettingsModal({ trigger }: GlobalWindSettingsModalProp
</p>
</div>
</TabsContent>
<TabsContent value="vizinhanca" className="space-y-4 min-w-0">
<div className="space-y-2">
<div className="flex justify-between items-center">
<label className="text-xs font-medium text-foreground">Fator de Vizinhança (Fviz)</label>
<span className="text-xs text-muted-foreground font-mono">{fviz.toFixed(2)}</span>
</div>
<Slider min={0.7} max={1.3} step={0.01} value={[fviz]} onValueChange={(vals) => setFviz(vals[0])} className="py-1 cursor-pointer" />
<p className="text-[10px] text-muted-foreground">
Modifica diretamente a pressão dinâmica (q).<br/>
<b>&lt; 1.0</b>: Efeito de sombreamento (edifícios ao redor protegem a estrutura).<br/>
<b>&gt; 1.0</b>: Efeito funil ou fenda (edifícios ao redor aceleram o vento).
</p>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
+202
View File
@@ -0,0 +1,202 @@
import React from 'react';
import { useGalpaoStore } from '../store/galpaoStore';
import { useWindStore } from '../store/appStore';
import type { WallCoefficients, RoofCoefficients } from '../lib/coefficients';
interface Heatmap2DProps {
overrideAngle?: 0 | 90;
overrideWallCpe?: WallCoefficients;
overrideRoofCpe?: RoofCoefficients;
}
/**
* Mapeia o valor da pressão (Cpe - Cpi) para uma cor.
* Sucção (negativo) = Tons de vermelho/laranja.
* Pressão (positivo) = Tons de azul.
*/
const getPressureColor = (cpe: number, cpi: number): string => {
const p = cpe - cpi;
if (p < 0) {
const intensity = Math.min(Math.abs(p) / 1.5, 1); // Normaliza sucção até -1.5
return `rgba(239, 68, 68, ${0.2 + intensity * 0.7})`; // Vermelho
} else if (p > 0) {
const intensity = Math.min(p / 1.0, 1); // Normaliza pressão até +1.0
return `rgba(59, 130, 246, ${0.2 + intensity * 0.7})`; // Azul
}
return 'rgba(200, 200, 200, 0.3)'; // Neutro
};
const Heatmap2D: React.FC<Heatmap2DProps> = ({ overrideAngle, overrideWallCpe, overrideRoofCpe }) => {
const storeGalpao = useGalpaoStore();
const storeWind = useWindStore();
const width = storeGalpao.width;
const length = storeGalpao.length;
const wallCpe = overrideWallCpe || storeGalpao.wallCpe;
const roofCpe = overrideRoofCpe || storeGalpao.roofCpe;
const windAngle = overrideAngle !== undefined ? overrideAngle : storeWind.windAngle;
const cpi = storeWind.cpi;
// Dimensões base do SVG
const svgW = 600;
const svgH = 600;
const margin = 100;
// Fator de escala para caber a planta (width x length) na tela
const maxDim = Math.max(width, length);
const scale = (svgW - margin * 2) / maxDim;
// Tamanhos desenhados
const drawW = width * scale;
const drawL = length * scale;
// Centro do desenho
const cx = svgW / 2;
const cy = svgH / 2;
const x0 = cx - drawW / 2;
const y0 = cy - drawL / 2;
// Determinar larguras das zonas do telhado (baseado na NBR 6123, onde as bordas têm distâncias específicas)
// Para simplificar graficamente o Heatmap, usaremos proporções visuais para E, F, G, H
const zoneSizeX = Math.min(drawW * 0.2, 40); // Representação genérica da zona E/F
const zoneSizeY = Math.min(drawL * 0.2, 40);
// A NBR 6123 rotaciona as zonas dependendo de windAngle (0° vs 90°)
// Vento 0° = perpendicular ao comprimento (Z). A=Frente, B=Fundo, C=Lado, D=Lado
// Vento 90° = perpendicular à largura (X). A=Lado, B=Lado, C=Frente, D=Fundo
return (
<div className="w-full h-full flex flex-col items-center justify-center p-4">
<div className="bg-background rounded-xl p-4 shadow-sm border border-border w-full max-w-2xl flex flex-col max-h-full">
<h3 className="text-center font-semibold mb-2 shrink-0">Planta Baixa - Telhado e Paredes</h3>
<p className="text-xs text-center text-muted-foreground mb-4 shrink-0">Vento incidindo a {windAngle}°</p>
<div className="flex-1 min-h-0 relative flex items-center justify-center w-full">
<svg width="100%" height="100%" viewBox={`0 0 ${svgW} ${svgH}`} className="max-w-[400px] max-h-[40vh] mx-auto overflow-visible" style={{ maxHeight: 'min(400px, 40vh)' }}>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" className="text-foreground" />
</marker>
</defs>
{/* Vento (Setas Dinâmicas) */}
{windAngle === 90 && (
<g className="text-blue-500" stroke="currentColor" strokeWidth="3" markerEnd="url(#arrow)">
<line x1={x0 - 100} y1={cy} x2={x0 - 60} y2={cy} />
<line x1={x0 - 100} y1={cy - 40} x2={x0 - 60} y2={cy - 40} />
<line x1={x0 - 100} y1={cy + 40} x2={x0 - 60} y2={cy + 40} />
<text x={x0 - 110} y={cy} textAnchor="end" dominantBaseline="middle" className="text-2xl font-bold fill-blue-500" stroke="none">Vento (90°)</text>
</g>
)}
{windAngle === 0 && (
<g className="text-blue-500" stroke="currentColor" strokeWidth="3" markerEnd="url(#arrow)">
<line x1={cx} y1={y0 - 100} x2={cx} y2={y0 - 60} />
<line x1={cx - 40} y1={y0 - 100} x2={cx - 40} y2={y0 - 60} />
<line x1={cx + 40} y1={y0 - 100} x2={cx + 40} y2={y0 - 60} />
<text x={cx} y={y0 - 115} textAnchor="middle" className="text-2xl font-bold fill-blue-500" stroke="none">Vento (0°)</text>
</g>
)}
{/* Área Total do Telhado (Fundo) */}
<rect x={x0} y={y0} width={drawW} height={drawL} fill="white" stroke="#ccc" strokeWidth="2" />
{/* Zonas do Telhado (Simplificadas para Vento a 0 ou 90) */}
{windAngle === 90 ? (
// Vento 90° -> Zonas E, G na esquerda, F, H na direita etc. (Esquema simplificado de visualização)
<>
{/* Metade Barlavento (Esquerda) */}
<rect x={x0} y={y0} width={drawW/2} height={drawL} fill={getPressureColor(roofCpe.E || 0, cpi)} stroke="#666" />
{/* Metade Sotavento (Direita) */}
<rect x={x0 + drawW/2} y={y0} width={drawW/2} height={drawL} fill={getPressureColor(roofCpe.F || 0, cpi)} stroke="#666" />
{/* Zonas Críticas */}
<rect x={x0} y={y0} width={zoneSizeX} height={zoneSizeY} fill={getPressureColor(roofCpe.G || 0, cpi)} stroke="#333" />
<rect x={x0} y={y0 + drawL - zoneSizeY} width={zoneSizeX} height={zoneSizeY} fill={getPressureColor(roofCpe.G || 0, cpi)} stroke="#333" />
</>
) : (
// Vento 0°
<>
{/* Metade Barlavento (Topo) */}
<rect x={x0} y={y0} width={drawW} height={drawL/2} fill={getPressureColor(roofCpe.E || 0, cpi)} stroke="#666" />
{/* Metade Sotavento (Baixo) */}
<rect x={x0} y={y0 + drawL/2} width={drawW} height={drawL/2} fill={getPressureColor(roofCpe.F || 0, cpi)} stroke="#666" />
{/* Zonas Críticas */}
<rect x={x0} y={y0} width={zoneSizeX} height={zoneSizeY} fill={getPressureColor(roofCpe.G || 0, cpi)} stroke="#333" />
<rect x={x0 + drawW - zoneSizeX} y={y0} width={zoneSizeX} height={zoneSizeY} fill={getPressureColor(roofCpe.G || 0, cpi)} stroke="#333" />
</>
)}
{/* Labels do Telhado (Simplificados) */}
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle" className="text-sm font-bold fill-foreground" style={{ mixBlendMode: 'difference' }}>
TELHADO
</text>
{/* PAREDES (Desenhadas como bordas expandidas) */}
{/* Parede A (Frente em 0°, Lado Esq em 90°) */}
{windAngle === 0 ? (
<rect x={x0} y={y0 - 15} width={drawW} height={15} fill={getPressureColor(wallCpe.A, cpi)} stroke="#111" />
) : (
<rect x={x0 - 15} y={y0} width={15} height={drawL} fill={getPressureColor(wallCpe.A, cpi)} stroke="#111" />
)}
{/* Parede B (Fundo em 0°, Lado Dir em 90°) */}
{windAngle === 0 ? (
<rect x={x0} y={y0 + drawL} width={drawW} height={15} fill={getPressureColor(wallCpe.B, cpi)} stroke="#111" />
) : (
<rect x={x0 + drawW} y={y0} width={15} height={drawL} fill={getPressureColor(wallCpe.B, cpi)} stroke="#111" />
)}
{/* Parede C (Lateral Dir em 0°, Frente em 90°) */}
{windAngle === 0 ? (
<rect x={x0 + drawW} y={y0} width={15} height={drawL} fill={getPressureColor(wallCpe.C, cpi)} stroke="#111" />
) : (
<rect x={x0} y={y0 + drawL} width={drawW} height={15} fill={getPressureColor(wallCpe.C, cpi)} stroke="#111" />
)}
{/* Parede D (Lateral Esq em 0°, Fundo em 90°) */}
{windAngle === 0 ? (
<rect x={x0 - 15} y={y0} width={15} height={drawL} fill={getPressureColor(wallCpe.D, cpi)} stroke="#111" />
) : (
<rect x={x0} y={y0 - 15} width={drawW} height={15} fill={getPressureColor(wallCpe.D, cpi)} stroke="#111" />
)}
{/* Indicadores de Paredes */}
{windAngle === 0 ? (
<>
<text x={cx} y={y0 - 25} textAnchor="middle" className="text-2xl font-bold fill-foreground">A</text>
<text x={cx} y={y0 + drawL + 25} textAnchor="middle" dominantBaseline="hanging" className="text-2xl font-bold fill-foreground">B</text>
<text x={x0 - 25} y={cy} textAnchor="end" dominantBaseline="middle" className="text-2xl font-bold fill-foreground">D</text>
<text x={x0 + drawW + 25} y={cy} textAnchor="start" dominantBaseline="middle" className="text-2xl font-bold fill-foreground">C</text>
</>
) : (
<>
<text x={x0 - 25} y={cy} textAnchor="end" dominantBaseline="middle" className="text-2xl font-bold fill-foreground">A</text>
<text x={x0 + drawW + 25} y={cy} textAnchor="start" dominantBaseline="middle" className="text-2xl font-bold fill-foreground">B</text>
<text x={cx} y={y0 + drawL + 25} textAnchor="middle" dominantBaseline="hanging" className="text-2xl font-bold fill-foreground">C</text>
<text x={cx} y={y0 - 25} textAnchor="middle" className="text-2xl font-bold fill-foreground">D</text>
</>
)}
</svg>
</div>
<div className="mt-4 shrink-0 flex flex-wrap justify-center gap-4 text-xs sm:text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: getPressureColor(-1.0, cpi) }}></div>
<span>Sucção Máxima</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border border-border" style={{ backgroundColor: getPressureColor(0, cpi) }}></div>
<span>Neutro (0)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: getPressureColor(1.0, cpi) }}></div>
<span>Sobrepressão Máxima</span>
</div>
</div>
</div>
</div>
);
};
export default Heatmap2D;
+61 -1
View File
@@ -9,6 +9,8 @@ import {
getPillarBaseMoment,
getDragForce,
} from '../lib/line-loads';
import { calcELU, calcELS } from '../lib/combinations';
import type { LoadInputs } from '../lib/combinations';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from './ui/card';
import { Input } from './ui/input';
import { Badge } from './ui/badge';
@@ -25,6 +27,10 @@ const LinearLoadsTable: React.FC = () => {
const [purlinSpacing, setPurlinSpacing] = useState<number>(1.5);
// Gravitational Loads (kN/m²)
const [pp, setPp] = useState<number>(0.15); // Peso Próprio típico
const [sc, setSc] = useState<number>(0.25); // Sobrecarga típica de cobertura
const columnLoads = useMemo(
() => getColumnLinearLoads(cpi, q, wallCpe, frameSpacing, windAngle),
[cpi, q, wallCpe, frameSpacing, windAngle],
@@ -92,10 +98,11 @@ const LinearLoadsTable: React.FC = () => {
<Separator />
<Tabs defaultValue="pilares" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="pilares">{t('linear_loads_tab_pillars')}</TabsTrigger>
<TabsTrigger value="tercas">{t('linear_loads_tab_purlins')}</TabsTrigger>
<TabsTrigger value="reacoes">{t('linear_loads_tab_reactions')}</TabsTrigger>
<TabsTrigger value="combinacoes" className="text-xs">Combinações</TabsTrigger>
</TabsList>
<TabsContent value="pilares" className="space-y-3">
@@ -202,6 +209,59 @@ const LinearLoadsTable: React.FC = () => {
{t('linear_loads_warning_simplified')}
</p>
</TabsContent>
<TabsContent value="combinacoes" className="space-y-4">
<div className="grid grid-cols-2 gap-4 bg-muted/20 p-3 rounded-md border">
<div className="space-y-1">
<label className="text-xs font-medium text-foreground">Peso Próprio (kN/m²)</label>
<Input type="number" step={0.05} value={pp} onChange={e => setPp(Number(e.target.value))} className="h-8" />
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-foreground">Sobrecarga (kN/m²)</label>
<Input type="number" step={0.05} value={sc} onChange={e => setSc(Number(e.target.value))} className="h-8" />
</div>
</div>
<div className="rounded-md border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/40">
<tr>
<th className="px-3 py-2 text-left font-medium">Elemento (Têrça)</th>
<th className="px-3 py-2 text-right font-medium">Vento (kN/m)</th>
<th className="px-3 py-2 text-right font-medium">ELU (kN/m)</th>
<th className="px-3 py-2 text-right font-medium text-muted-foreground">ELS (kN/m)</th>
</tr>
</thead>
<tbody>
{[
{ zona: 'E', w: roofLoads.E },
{ zona: 'F', w: roofLoads.F },
{ zona: 'G', w: roofLoads.G },
{ zona: 'H', w: roofLoads.H },
{ zona: 'I', w: roofLoads.I },
{ zona: 'J', w: roofLoads.J },
].map((row) => {
const inputs: LoadInputs = {
pp: pp * purlinSpacing, // linearizando a carga de área
sc: sc * purlinSpacing,
w: row.w
};
return (
<tr key={row.zona} className="border-t">
<td className="px-3 py-2 font-mono font-semibold">Zona {row.zona}</td>
<td className="px-3 py-2 text-right font-mono">{fmtSigned(row.w)}</td>
<td className="px-3 py-2 text-right font-mono font-bold text-primary">{fmtSigned(calcELU(inputs))}</td>
<td className="px-3 py-2 text-right font-mono text-muted-foreground">{fmtSigned(calcELS(inputs))}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">
* Combinações simplificadas para NBR 8681. Assumido vento principal ou sobrecarga principal (pega o pior caso ELU Normal).
</p>
</TabsContent>
</Tabs>
<Separator />
+13 -39
View File
@@ -401,25 +401,12 @@ export function WarehouseModel() {
<meshStandardMaterial color="#0f172a" roughness={0.5} transparent={(viewMode as string) === 'airflow'} opacity={(viewMode as string) === 'airflow' ? 0.35 : 1} />
</mesh>
{/* Rótulo e Título da Água */}
<Text
position={[0, 0.95, 0]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={titleFontSize}
color="#000000"
outlineWidth={0.04}
outlineColor="#ffffff"
fontWeight={700}
anchorX="center"
anchorY="middle"
>
{isParallel ? 'Telhado (Zonas E/F/G/H)' : 'Água Barlavento (Zonas E, F, J)'}
</Text>
{/* Marcações Individuais das Zonas */}
<Text
position={[0, 0.35, -3 * length / 8]}
rotation={[-Math.PI / 2, 0, 0]}
rotation={[-Math.PI / 2, 0, Math.PI]}
fontSize={zoneFontSize}
color="#000000"
outlineWidth={0.035}
@@ -432,7 +419,7 @@ export function WarehouseModel() {
</Text>
<Text
position={[0, 0.35, -length / 8]}
rotation={[-Math.PI / 2, 0, 0]}
rotation={[-Math.PI / 2, 0, Math.PI]}
fontSize={zoneFontSize}
color="#000000"
outlineWidth={0.035}
@@ -445,7 +432,7 @@ export function WarehouseModel() {
</Text>
<Text
position={[0, 0.35, length / 8]}
rotation={[-Math.PI / 2, 0, 0]}
rotation={[-Math.PI / 2, 0, Math.PI]}
fontSize={zoneFontSize}
color="#000000"
outlineWidth={0.035}
@@ -458,7 +445,7 @@ export function WarehouseModel() {
</Text>
<Text
position={[0, 0.35, 3 * length / 8]}
rotation={[-Math.PI / 2, 0, 0]}
rotation={[-Math.PI / 2, 0, Math.PI]}
fontSize={zoneFontSize}
color="#000000"
outlineWidth={0.035}
@@ -471,7 +458,7 @@ export function WarehouseModel() {
</Text>
<Text
position={[-widthSlope * 0.38, 0.35, 0]}
rotation={[-Math.PI / 2, 0, 0]}
rotation={[-Math.PI / 2, 0, -Math.PI / 2]}
fontSize={zoneFontSize}
color="#000000"
outlineWidth={0.035}
@@ -532,25 +519,12 @@ export function WarehouseModel() {
<meshStandardMaterial color="#0f172a" roughness={0.5} transparent={(viewMode as string) === 'airflow'} opacity={(viewMode as string) === 'airflow' ? 0.35 : 1} />
</mesh>
{/* Rótulo e Título da Água */}
<Text
position={[0, 0.95, 0]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={titleFontSize}
color="#000000"
outlineWidth={0.04}
outlineColor="#ffffff"
fontWeight={700}
anchorX="center"
anchorY="middle"
>
{isParallel ? 'Telhado (Zonas E/F/G/H)' : 'Água Sotavento (Zonas G, H, I)'}
</Text>
{/* Marcações Individuais das Zonas */}
<Text
position={[0, 0.35, -3 * length / 8]}
rotation={[-Math.PI / 2, 0, 0]}
rotation={[-Math.PI / 2, 0, Math.PI]}
fontSize={zoneFontSize}
color="#000000"
outlineWidth={0.035}
@@ -563,7 +537,7 @@ export function WarehouseModel() {
</Text>
<Text
position={[0, 0.35, -length / 8]}
rotation={[-Math.PI / 2, 0, 0]}
rotation={[-Math.PI / 2, 0, Math.PI]}
fontSize={zoneFontSize}
color="#000000"
outlineWidth={0.035}
@@ -576,7 +550,7 @@ export function WarehouseModel() {
</Text>
<Text
position={[0, 0.35, length / 8]}
rotation={[-Math.PI / 2, 0, 0]}
rotation={[-Math.PI / 2, 0, Math.PI]}
fontSize={zoneFontSize}
color="#000000"
outlineWidth={0.035}
@@ -589,7 +563,7 @@ export function WarehouseModel() {
</Text>
<Text
position={[0, 0.35, 3 * length / 8]}
rotation={[-Math.PI / 2, 0, 0]}
rotation={[-Math.PI / 2, 0, Math.PI]}
fontSize={zoneFontSize}
color="#000000"
outlineWidth={0.035}
@@ -601,8 +575,8 @@ export function WarehouseModel() {
{isParallel ? `Zona H (${roofCpe.H})` : `Zona H (${roofCpe.H})`}
</Text>
<Text
position={[widthSlope * 0.38, 0.35, 0]}
rotation={[-Math.PI / 2, 0, 0]}
position={[widthSlope * 0.44, 0.35, 0]}
rotation={[-Math.PI / 2, 0, -Math.PI / 2]}
fontSize={zoneFontSize}
color="#000000"
outlineWidth={0.035}
+21 -2
View File
@@ -61,15 +61,22 @@ export function WindFlowGrid2D({ type }: WindFlowGrid2DProps) {
.vibe-cyl {
animation: vibration 1.2s ease-in-out infinite;
}
@keyframes cpiPulse {
0%, 100% { opacity: 0.4; stroke-width: 1.5; }
50% { opacity: 1; stroke-width: 2.5; }
}
.cpi-arrow-pos {
stroke: #f97316;
stroke-width: 2;
fill: none;
animation: cpiPulse 1.5s ease-in-out infinite;
}
.cpi-arrow-neg {
stroke: #3b82f6;
stroke-width: 2;
fill: none;
animation: cpiPulse 1.5s ease-in-out infinite;
animation-delay: 0.75s;
}
`}</style>
);
@@ -488,8 +495,8 @@ export function WindFlowGrid2D({ type }: WindFlowGrid2DProps) {
<path d="M -20,85 L 80,85" className="wind-line" />
{/* Esteira turbulenta no telhado e atrás */}
<path d="M 80,70 Q 110,50 150,70" fill="none" stroke="#ef4444" strokeWidth="1.5" strokeDasharray="3 6" />
<path d="M 220,70 Q 250,90 280,110" fill="none" stroke="#ef4444" strokeWidth="1.5" strokeDasharray="3 6" />
<path d="M 80,70 Q 110,50 150,70" className="vortex-line" />
<path d="M 220,70 Q 250,90 280,110" className="vortex-line" />
</svg>
</div>
<p className="text-[10px] text-muted-foreground leading-snug">Vento incide a 0° (paralelo à cumeeira). O fluxo contorna o oitão frontal, gerando forte sucção nas bordas longitudinais.</p>
@@ -503,6 +510,12 @@ export function WindFlowGrid2D({ type }: WindFlowGrid2DProps) {
{styleBlock}
{legendDef}
<line x1="0" y1="130" x2="300" y2="130" stroke="#475569" strokeWidth="2" />
{/* Fluxo entrando */}
<path d="M -20,20 L 320,20" className="wind-line-slow" />
<path d="M -20,60 C 100,60 110,35 150,35 C 190,35 200,65 320,65" className="wind-line-fast" />
<path d="M -20,100 L 110,100" className="wind-line" />
<path d="M 110,130 L 110,85 L 150,55 L 190,85 L 190,130 Z" fill="#475569" stroke="#64748b" strokeWidth="2" />
{/* Setas laranjas apontando para fora (inflado) */}
<path d="M 130,110 L 115,110" className="cpi-arrow-pos" markerEnd="url(#cpi-pos)" />
@@ -522,6 +535,12 @@ export function WindFlowGrid2D({ type }: WindFlowGrid2DProps) {
{styleBlock}
{legendDef}
<line x1="0" y1="130" x2="300" y2="130" stroke="#475569" strokeWidth="2" />
{/* Fluxo passando e sugando */}
<path d="M -20,20 L 320,20" className="wind-line-slow" />
<path d="M -20,60 C 100,60 110,35 150,35 C 190,35 200,65 320,65" className="wind-line-fast" />
<path d="M -20,100 C 90,90 100,75 115,75 L 185,75 C 200,75 210,100 320,100" className="wind-line" />
<path d="M 110,130 L 110,85 L 150,55 L 190,85 L 190,130 Z" fill="#475569" stroke="#64748b" strokeWidth="2" />
{/* Setas azuis apontando para dentro (sugado) */}
<path d="M 115,110 L 130,110" className="cpi-arrow-neg" markerEnd="url(#cpi-neg)" />
+9 -7
View File
@@ -1,6 +1,7 @@
import { useRef, useMemo, useEffect } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { useCanvasTheme } from '../../lib/theme';
interface WallCoefficients {
A: number; B: number; C: number; D: number;
@@ -21,6 +22,7 @@ interface AirflowSystemProps {
}
export function AirflowSystem({ windAngle, width, length, height, permeabilityCase, wallCpe, roofCpe, cpi }: AirflowSystemProps) {
const isDark = useCanvasTheme() === 'dark';
const count = 400; // Quantidade reduzida
const meshRef = useRef<THREE.InstancedMesh>(null);
@@ -130,9 +132,9 @@ export function AirflowSystem({ windAngle, width, length, height, permeabilityCa
if (inY && inX && inZ) {
// Perto das paredes
if (Math.abs(pz - (-halfL)) < margin) localCpe = wallCpe.A;
else if (Math.abs(pz - (halfL)) < margin) localCpe = wallCpe.B;
else if (Math.abs(pz - (halfL)) < margin) localCpe = (windAngle === 90 ? 0.5 : wallCpe.B);
else if (Math.abs(px - (-halfW)) < margin) localCpe = wallCpe.C;
else if (Math.abs(px - (halfW)) < margin) localCpe = wallCpe.D;
else if (Math.abs(px - (halfW)) < margin) localCpe = (windAngle === 0 ? 0.5 : wallCpe.D);
else localCpe = cpi;
// Colisão dura (Impede entrar na parede sólida)
@@ -193,12 +195,12 @@ export function AirflowSystem({ windAngle, width, length, height, permeabilityCa
meshRef.current!.setMatrixAt(i, dummy.matrix);
if (p.currentCpe > 0) {
colorObj.set('#ef4444').lerp(new THREE.Color('#fcd34d'), 1 - Math.min(1, p.currentCpe));
colorObj.set(isDark ? '#f87171' : '#ef4444'); // Vermelho (Turbulento/Pressão)
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj.set('#7dd3fc').lerp(new THREE.Color('#1e40af'), intensity);
colorObj.set(isDark ? '#c084fc' : '#a855f7').lerp(new THREE.Color(isDark ? '#7e22ce' : '#6b21a8'), intensity); // Roxo (Acelerado/Sucção)
} else {
colorObj.set('#f1f5f9');
colorObj.set(isDark ? '#60a5fa' : '#3b82f6'); // Azul (Corrente livre)
}
meshRef.current!.setColorAt(i, colorObj);
});
@@ -215,8 +217,8 @@ export function AirflowSystem({ windAngle, width, length, height, permeabilityCa
<meshBasicMaterial
color="#ffffff"
transparent
opacity={0.6}
blending={THREE.AdditiveBlending}
opacity={isDark ? 0.6 : 0.8}
blending={isDark ? THREE.AdditiveBlending : THREE.NormalBlending}
depthWrite={false}
/>
</instancedMesh>
+53 -32
View File
@@ -6,6 +6,7 @@ import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
import { WindArrow } from './WindArrow';
import { useCanvasTheme } from '@/lib/theme';
import { BridgeAirflowSystem } from './BridgeAirflowSystem';
export interface Bridge3DInput {
/** Maior vão Lₚ (m) */
@@ -26,6 +27,8 @@ export interface Bridge3DInput {
fzPerLength: number;
/** Ângulo de ataque do vento (graus) */
alpha: number;
/** Modo de visualização */
viewMode?: 'solid' | 'airflow';
}
function BridgeModel({
@@ -35,9 +38,11 @@ function BridgeModel({
heg,
cx,
alpha,
viewMode = 'solid',
}: Bridge3DInput) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const isAirflow = viewMode === 'airflow';
const halfL = lp / 2;
const halfW = width / 2;
@@ -46,33 +51,34 @@ function BridgeModel({
// Cor do tabuleiro baseada em Cx
const deckColor = useMemo(() => {
if (isAirflow) return new THREE.Color(isDark ? '#334155' : '#e2e8f0');
const intensity = Math.min(1, Math.abs(cx) / 3);
const hue = 200 - intensity * 60;
const l = isDark ? (60 - intensity * 8) : (50 - intensity * 8);
return new THREE.Color(`hsl(${hue}, ${55 + intensity * 30}%, ${l}%)`);
}, [cx, isDark]);
}, [cx, isDark, isAirflow]);
// Pilar heights: posicionar 3 pilares ao longo do vão
const pillarHeights = useMemo(() => [deckY - 0.5, deckY - 0.5, deckY - 0.5], [deckY]);
const barrierColor = isDark ? '#cbd5e1' : '#94a3b8';
const pillarColor = isDark ? '#94a3b8' : '#64748b';
const barrierColor = isDark ? (isAirflow ? '#475569' : '#cbd5e1') : (isAirflow ? '#cbd5e1' : '#94a3b8');
const pillarColor = isDark ? (isAirflow ? '#334155' : '#94a3b8') : (isAirflow ? '#e2e8f0' : '#64748b');
return (
<group>
{/* Tabuleiro (deck) */}
<mesh position={[0, deckY, 0]} castShadow receiveShadow>
<boxGeometry args={[lp, deckThickness, width]} />
<meshStandardMaterial color={deckColor} roughness={0.5} />
<meshStandardMaterial color={deckColor} opacity={isAirflow ? 0.35 : 0.9} transparent roughness={0.5} />
</mesh>
{/* Guarda-rodas/barreira lateral */}
<mesh position={[0, deckY + deckThickness / 2 + 0.3, halfW - 0.15]} castShadow>
<boxGeometry args={[lp, 0.5, 0.1]} />
<meshStandardMaterial color={barrierColor} roughness={0.7} />
<meshStandardMaterial color={barrierColor} opacity={isAirflow ? 0.35 : 0.9} transparent roughness={0.7} />
</mesh>
<mesh position={[0, deckY + deckThickness / 2 + 0.3, -halfW + 0.15]} castShadow>
<boxGeometry args={[lp, 0.5, 0.1]} />
<meshStandardMaterial color={barrierColor} roughness={0.7} />
<meshStandardMaterial color={barrierColor} opacity={isAirflow ? 0.35 : 0.9} transparent roughness={0.7} />
</mesh>
{/* Pilares (3 ao longo do comprimento) */}
@@ -87,53 +93,68 @@ function BridgeModel({
})}
{/* Solo / água */}
<mesh position={[0, -0.5, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[lp * 1.6, width * 3]} />
<meshStandardMaterial color="#60a5fa" opacity={0.4} transparent roughness={0.3} />
</mesh>
{!isAirflow && (
<mesh position={[0, -0.5, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[lp * 1.6, width * 3]} />
<meshStandardMaterial color="#60a5fa" opacity={0.4} transparent roughness={0.3} />
</mesh>
)}
{/* Seta de Vento (bate no meio do tabuleiro) */}
<WindArrow
target={[0, deckY + deckThickness / 2, 0]}
direction={[0, Math.sin((alpha * Math.PI) / 180), Math.cos((alpha * Math.PI) / 180)]}
scale={4}
/>
<Text
position={[0, deckY + deckThickness + 1.5, 0]}
fontSize={1.2}
color={isDark ? "#93c5fd" : "#1e40af"}
anchorX="center"
anchorY="bottom"
>
Lp={lp}m | B={width}m | Cx={cx.toFixed(2)}
</Text>
{/* Seta de Vento */}
{isAirflow ? (
<group>
<WindArrow
target={[-halfL * 0.8, deckY, -halfW - 2]}
direction={[0, 0, 1]}
scale={4}
/>
<Text
position={[-halfL * 0.8, deckY + 1.5, -halfW - 4]}
rotation={[0, Math.PI, 0]}
fontSize={1.2}
color={isDark ? '#60a5fa' : '#3b82f6'}
anchorX="center"
anchorY="bottom"
>
Vento
</Text>
<BridgeAirflowSystem width={width} lp={lp} deckHeight={deckHeight} cx={cx} />
</group>
) : (
<WindArrow
target={[0, deckY + deckThickness / 2, 0]}
direction={[0, Math.sin((alpha * Math.PI) / 180), Math.cos((alpha * Math.PI) / 180)]}
scale={4}
/>
)}
</group>
);
}
export default function Bridge3DViewer(input: Bridge3DInput) {
export default function Bridge3DViewer(props: Bridge3DInput) {
const { lp, width, deckHeight, heg, cx, cz, fxPerLength, fzPerLength, viewMode = 'solid' } = props;
const cameraDistance = Math.max(lp * 1.2, width * 2, deckHeight * 2, 15);
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const { lp, deckHeight, width } = input;
const dist = Math.max(lp * 0.6, deckHeight * 2);
const fallback = (
<FallbackDiagram
type="bridge"
props={input}
props={{ lp, width, deckHeight, heg, cx, cz, fxPerLength, fzPerLength }}
/>
);
return (
<SceneCanvas moduleId="ponte"
<SceneCanvas moduleId="pontes"
shadows
frameloop={viewMode === 'airflow' ? 'always' : 'demand'}
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [dist * 0.8, deckHeight + width, dist], fov: 45 }}
camera={{ position: [-cameraDistance * 0.8, cameraDistance * 0.6, -cameraDistance], fov: 45 }}
fallback={fallback}
>
<ambientLight intensity={0.6} />
<directionalLight position={[lp, deckHeight * 3, width * 3]} intensity={1.2} castShadow shadow-mapSize-width={1024} shadow-mapSize-height={1024} />
<BridgeModel {...input} />
<BridgeModel {...props} viewMode={viewMode} />
<Grid
infiniteGrid
fadeDistance={lp * 0.5}
@@ -0,0 +1,132 @@
import React, { useRef, useMemo, useEffect } from 'react';
import * as THREE from 'three';
import { useFrame } from '@react-three/fiber';
import { useCanvasTheme } from '@/lib/theme';
interface Particle {
position: THREE.Vector3;
baseSpeed: number;
wobbleSpeed: number;
wobbleOffset: number;
currentCpe: number;
}
interface BridgeAirflowSystemProps {
width: number;
lp: number;
deckHeight: number;
cx: number;
}
export const BridgeAirflowSystem: React.FC<BridgeAirflowSystemProps> = ({
width,
lp,
deckHeight,
cx,
}) => {
const meshRef = useRef<THREE.InstancedMesh>(null);
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const COUNT = 100;
const dummy = useMemo(() => new THREE.Object3D(), []);
const colorObj = useMemo(() => new THREE.Color(), []);
const halfWidth = width / 2;
const halfLp = lp / 2;
const particles = useMemo(() => {
const arr: Particle[] = [];
for (let i = 0; i < COUNT; i++) {
arr.push({
position: new THREE.Vector3(
(Math.random() - 0.5) * lp,
Math.random() * (deckHeight + 10),
-halfWidth - 10 - Math.random() * 20
),
baseSpeed: 0.075 + Math.random() * 0.05,
wobbleSpeed: 0.5 + Math.random() * 1.5,
wobbleOffset: Math.random() * Math.PI * 2,
currentCpe: 0,
});
}
return arr;
}, [width, lp, deckHeight]);
useEffect(() => {
if (meshRef.current) {
for (let i = 0; i < COUNT; i++) {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6');
meshRef.current.setColorAt(i, colorObj);
}
meshRef.current.instanceColor!.needsUpdate = true;
}
}, [isDark, colorObj]);
useFrame((state) => {
if (!meshRef.current) return;
const time = state.clock.elapsedTime;
particles.forEach((p, i) => {
const px = p.position.x;
const py = p.position.y;
const pz = p.position.z;
let currentSpeed = p.baseSpeed;
let localCpe = 0;
let deflectY = 0;
const inX = Math.abs(px) < halfLp + 1;
const inZ = Math.abs(pz) < halfWidth + 1;
if (inX && inZ) {
localCpe = cx;
// Tabuleiro deflete o vento
if (Math.abs(py - deckHeight) < 2) {
const dist = -pz;
const lift = Math.max(0, deckHeight + 1 - py) / (dist + 1);
deflectY += lift * currentSpeed * 0.6 * (py < deckHeight ? -1 : 1);
}
}
p.currentCpe += (localCpe - p.currentCpe) * 0.1;
p.position.z += currentSpeed;
p.position.y += deflectY;
p.position.y += Math.sin(time * p.wobbleSpeed * 10 + p.wobbleOffset) * 0.01;
if (p.position.z > halfWidth + 10) {
p.position.z = -halfWidth - 10 - Math.random() * 5;
p.position.y = Math.random() * (deckHeight + 10);
p.position.x = (Math.random() - 0.5) * lp;
p.currentCpe = 0;
}
dummy.position.copy(p.position);
dummy.rotation.set(0, 0, 0);
dummy.rotateX(Math.PI / 2);
dummy.scale.set(1, 1 + currentSpeed * 6, 1);
dummy.updateMatrix();
meshRef.current!.setMatrixAt(i, dummy.matrix);
if (p.currentCpe > 0) {
colorObj.set(isDark ? '#f87171' : '#ef4444');
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj.set(isDark ? '#c084fc' : '#a855f7').lerp(new THREE.Color(isDark ? '#7e22ce' : '#6b21a8'), intensity);
} else {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6');
}
meshRef.current!.setColorAt(i, colorObj);
});
meshRef.current.instanceMatrix.needsUpdate = true;
meshRef.current.instanceColor!.needsUpdate = true;
});
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, COUNT]}>
<cylinderGeometry args={[0.04, 0.04, 1.0, 4]} />
<meshBasicMaterial transparent opacity={0.6} depthWrite={false} />
</instancedMesh>
);
};
+38 -17
View File
@@ -5,6 +5,7 @@ import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
import { useCanvasTheme } from '@/lib/theme';
import { CylinderAirflowSystem } from './CylinderAirflowSystem';
export interface Cylinder3DInput {
diameter: number;
@@ -12,6 +13,7 @@ export interface Cylinder3DInput {
/** Cpe profile ao longo da circunferência (0° a 180°) */
cpeProfile: { angle: number; cpe: number }[];
cpi: number;
viewMode?: 'solid' | 'airflow';
}
function cylinderColor(cpe: number, cpi: number): THREE.Color {
@@ -23,9 +25,10 @@ function cylinderColor(cpe: number, cpi: number): THREE.Color {
return new THREE.Color(`hsl(0, ${70 + intensity * 25}%, ${Math.max(40, 65 - intensity * 20)}%)`);
}
function CylinderModel({ diameter, height, cpeProfile, cpi }: Cylinder3DInput) {
function CylinderModel({ diameter, height, cpeProfile, cpi, viewMode = 'solid' }: Cylinder3DInput) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const isAirflow = viewMode === 'airflow';
const segments = 64;
const radius = diameter / 2;
@@ -111,7 +114,13 @@ function CylinderModel({ diameter, height, cpeProfile, cpi }: Cylinder3DInput) {
args={[normals, 3]}
/>
</bufferGeometry>
<meshStandardMaterial color={face.color} opacity={0.9} transparent roughness={0.4} side={THREE.DoubleSide} />
<meshStandardMaterial
color={face.color}
opacity={isAirflow ? 0.35 : 0.9}
transparent
roughness={0.4}
side={THREE.DoubleSide}
/>
</mesh>
);
})}
@@ -119,7 +128,13 @@ function CylinderModel({ diameter, height, cpeProfile, cpi }: Cylinder3DInput) {
{/* Tampa superior sólida */}
<mesh position={[0, height, 0]} rotation={[-Math.PI / 2, 0, 0]} castShadow receiveShadow>
<circleGeometry args={[radius, segments]} />
<meshStandardMaterial color={cylinderColor(cpeProfile[cpeProfile.length - 1].cpe, cpi)} opacity={0.8} transparent side={THREE.DoubleSide} roughness={0.4} />
<meshStandardMaterial
color={cylinderColor(cpeProfile[cpeProfile.length - 1].cpe, cpi)}
opacity={isAirflow ? 0.35 : 0.8}
transparent
side={THREE.DoubleSide}
roughness={0.4}
/>
</mesh>
{/* Anéis de detalhe (bordas do cilindro) */}
@@ -142,22 +157,24 @@ function CylinderModel({ diameter, height, cpeProfile, cpi }: Cylinder3DInput) {
<cylinderGeometry args={[0.1, 0.1, 1.2, 16]} />
<meshStandardMaterial color="#3b82f6" roughness={0.3} />
</mesh>
<Text
position={[0, -1.5, 0]}
rotation={[Math.PI / 2, 0, 0]}
fontSize={0.4}
color="#3b82f6"
anchorX="center"
anchorY="middle"
>
Vento
</Text>
</group>
{/* Texto de Informação */}
{/* Texto Vento - acima da seta com o dobro do tamanho (1.2) */}
<Text
position={[-radius - 2.5, height / 2 + 1.0, 0]}
rotation={[0, 0, 0]}
fontSize={1.2}
color="#3b82f6"
anchorX="center"
anchorY="bottom"
>
Vento
</Text>
{/* Texto de Informação - 50% maior */}
<Text
position={[0, height + 0.8, 0]}
fontSize={Math.max(0.3, Math.min(0.6, diameter / 10))}
fontSize={Math.max(0.45, Math.min(0.9, (diameter / 10) * 1.5))}
color={isDark ? "#cbd5e1" : "#1a202c"}
anchorX="center"
anchorY="bottom"
@@ -168,7 +185,7 @@ function CylinderModel({ diameter, height, cpeProfile, cpi }: Cylinder3DInput) {
);
}
export default function Cylinder3DViewer({ diameter, height, cpeProfile, cpi }: Cylinder3DInput) {
export default function Cylinder3DViewer({ diameter, height, cpeProfile, cpi, viewMode = 'solid' }: Cylinder3DInput) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
@@ -184,6 +201,7 @@ export default function Cylinder3DViewer({ diameter, height, cpeProfile, cpi }:
return (
<SceneCanvas moduleId="cilindro"
shadows
frameloop={viewMode === 'airflow' ? 'always' : 'demand'}
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [diameter * 1.5, height * 1.2, diameter * 1.5], fov: 40 }}
fallback={fallback}
@@ -201,7 +219,10 @@ export default function Cylinder3DViewer({ diameter, height, cpeProfile, cpi }:
shadow-camera-top={maxDim}
shadow-camera-bottom={-maxDim}
/>
<CylinderModel diameter={diameter} height={height} cpeProfile={cpeProfile} cpi={cpi} />
<CylinderModel diameter={diameter} height={height} cpeProfile={cpeProfile} cpi={cpi} viewMode={viewMode} />
{viewMode === 'airflow' && (
<CylinderAirflowSystem diameter={diameter} height={height} />
)}
<Grid
infiniteGrid
fadeDistance={maxDim * 5}
@@ -0,0 +1,154 @@
import { useRef, useMemo, useEffect } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { useCanvasTheme } from '../../lib/theme';
export interface CylinderAirflowSystemProps {
diameter: number;
height: number;
}
export function CylinderAirflowSystem({ diameter, height }: CylinderAirflowSystemProps) {
const isDark = useCanvasTheme() === 'dark';
const count = 450;
const meshRef = useRef<THREE.InstancedMesh>(null);
const R = diameter / 2;
const particles = useMemo(() => {
const temp = [];
const spanX = Math.max(15, R * 5);
const spanZ = Math.max(10, R * 3.5);
for (let i = 0; i < count; i++) {
temp.push({
position: new THREE.Vector3(
(Math.random() - 0.5) * spanX * 2,
Math.random() * (height * 1.3),
(Math.random() - 0.5) * spanZ * 2
),
baseSpeed: 0.12 + Math.random() * 0.08,
wobbleSpeed: Math.random() * 0.05,
wobbleOffset: Math.random() * Math.PI * 2,
currentCpe: 0,
});
}
return temp;
}, [count, R, height]);
const dummy = useMemo(() => new THREE.Object3D(), []);
const colorObj = useMemo(() => new THREE.Color(), []);
useEffect(() => {
if (meshRef.current) {
for (let i = 0; i < count; i++) {
meshRef.current.setColorAt(i, new THREE.Color('#ffffff'));
}
if (meshRef.current.instanceColor) {
meshRef.current.instanceColor.needsUpdate = true;
}
}
}, [count]);
useFrame((state) => {
if (!meshRef.current) return;
const time = state.clock.elapsedTime;
const spanX = Math.max(15, R * 5);
const spanZ = Math.max(10, R * 3.5);
particles.forEach((p, i) => {
let deflectX = 1.0;
let deflectY = 0;
let deflectZ = 0;
const px = p.position.x;
const py = p.position.y;
const pz = p.position.z;
const r = Math.sqrt(px * px + pz * pz);
const safeR = Math.max(R + 0.3, r);
// Proteção para não entrar no cilindro
if (r < R + 0.25 && py <= height) {
const angle = Math.atan2(pz, px);
p.position.x = Math.cos(angle) * (R + 0.25);
p.position.z = Math.sin(angle) * (R + 0.25);
}
// Cálculo aerodinâmico (escoamento potencial ao redor de cilindro)
if (py <= height * 1.05 && r < R * 3.0) {
const r2 = safeR * safeR;
const R2 = R * R;
// Velocidades de escoamento potencial em 2D ao redor do cilindro
deflectX = 1 - (R2 * (px * px - pz * pz)) / (r2 * r2);
deflectZ = -(2 * R2 * px * pz) / (r2 * r2);
// Esteira turbulenta na região à jusante (+X)
if (px > 0 && Math.abs(pz) < R * 1.3) {
deflectZ += Math.sin(time * 5 + px * 0.8 + p.wobbleOffset) * 0.35;
deflectX = Math.max(0.4, deflectX);
}
}
// Determina coeficiente para cor da partícula
if (py <= height && r < R * 2.2) {
if (px < -R * 0.3 && Math.abs(pz) < R * 0.8) {
p.currentCpe = 0.8; // Barlavento (pressão positiva)
} else if (Math.abs(px) <= R * 0.6 && r < R * 1.5) {
p.currentCpe = -1.2; // Laterais (forte sucção)
} else if (px > R * 0.3 && Math.abs(pz) < R * 1.2) {
p.currentCpe = 0.5; // Esteira (agora positivo para ficar vermelho)
} else {
p.currentCpe = 0;
}
} else {
p.currentCpe = 0;
}
const moveVec = new THREE.Vector3(deflectX, deflectY, deflectZ).normalize();
const currentSpeed = p.baseSpeed * Math.max(0.3, Math.min(1.5, deflectX));
p.position.addScaledVector(moveVec, currentSpeed);
// Reposição quando sai do volume
if (p.position.x > spanX) {
p.position.x = -spanX;
p.position.y = Math.random() * (height * 1.3);
p.position.z = (Math.random() - 0.5) * spanZ * 2;
}
dummy.position.copy(p.position);
dummy.lookAt(p.position.clone().add(moveVec));
dummy.rotateX(Math.PI / 2);
dummy.scale.set(1, 1 + currentSpeed * 6, 1);
dummy.updateMatrix();
meshRef.current!.setMatrixAt(i, dummy.matrix);
if (p.currentCpe > 0) {
colorObj.set(isDark ? '#f87171' : '#ef4444'); // Vermelho (Turbulento/Pressão)
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj.set(isDark ? '#c084fc' : '#a855f7').lerp(new THREE.Color(isDark ? '#7e22ce' : '#6b21a8'), intensity); // Roxo (Acelerado/Sucção)
} else {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6'); // Azul (Corrente livre)
}
meshRef.current!.setColorAt(i, colorObj);
});
meshRef.current.instanceMatrix.needsUpdate = true;
if (meshRef.current.instanceColor) {
meshRef.current.instanceColor.needsUpdate = true;
}
});
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
<cylinderGeometry args={[0.01, 0.01, 1.5, 4]} />
<meshBasicMaterial
color="#ffffff"
transparent
opacity={isDark ? 0.6 : 0.8}
blending={isDark ? THREE.AdditiveBlending : THREE.NormalBlending}
depthWrite={false}
/>
</instancedMesh>
);
}
+25 -15
View File
@@ -5,6 +5,7 @@ import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
import { useCanvasTheme } from '@/lib/theme';
import { DomeAirflowSystem } from './DomeAirflowSystem';
export interface Dome3DInput {
diameter: number;
@@ -14,6 +15,7 @@ export interface Dome3DInput {
cpeBarlavento: number;
cpeTopo: number;
cpeLateral: number;
viewMode?: 'solid' | 'airflow';
}
function domeColor(cpe: number, cpi: number, isDark: boolean): THREE.Color {
@@ -26,9 +28,10 @@ function domeColor(cpe: number, cpi: number, isDark: boolean): THREE.Color {
return new THREE.Color(`hsl(0, ${70 + intensity * 25}%, ${Math.max(40, l + 5)}%)`);
}
function DomeModel({ diameter, rise, wallHeight, cpi, cpeBarlavento, cpeTopo, cpeLateral }: Dome3DInput) {
function DomeModel({ diameter, rise, wallHeight, cpi, cpeBarlavento, cpeTopo, cpeLateral, viewMode = 'solid' }: Dome3DInput) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const isAirflow = viewMode === 'airflow';
const radius = diameter / 2;
const segments = 64;
@@ -64,7 +67,7 @@ function DomeModel({ diameter, rise, wallHeight, cpi, cpeBarlavento, cpeTopo, cp
{/* Parede cilíndrica inferior */}
<mesh position={[0, wallHeight / 2, 0]} castShadow receiveShadow>
<cylinderGeometry args={[radius, radius, wallHeight, segments, 1, false]} />
<meshStandardMaterial color="#cbd5e1" opacity={0.8} transparent roughness={0.4} />
<meshStandardMaterial color="#cbd5e1" opacity={isAirflow ? 0.25 : 0.8} transparent roughness={0.4} />
</mesh>
{/* Detalhes de anéis metálicos nas bordas */}
@@ -124,13 +127,13 @@ function DomeModel({ diameter, rise, wallHeight, cpi, cpeBarlavento, cpeTopo, cp
<bufferAttribute attach="attributes-normal" args={[new Float32Array(normals), 3]} />
<bufferAttribute attach="index" args={[new Uint16Array(indices), 1]} />
</bufferGeometry>
<meshStandardMaterial color={zone.color} opacity={0.92} transparent roughness={0.4} side={THREE.DoubleSide} />
<meshStandardMaterial color={zone.color} opacity={isAirflow ? 0.35 : 0.92} transparent roughness={0.4} side={THREE.DoubleSide} />
</mesh>
);
})}
{/* Seta indicativa de direção do vento */}
<group position={[radius + 2.5, wallHeight / 2, 0]} rotation={[0, 0, Math.PI / 2]}>
<group position={[-radius - 2.5, wallHeight / 2, 0]} rotation={[0, 0, -Math.PI / 2]}>
<mesh castShadow>
<coneGeometry args={[0.3, 0.8, 16]} />
<meshStandardMaterial color="#3b82f6" roughness={0.3} />
@@ -139,22 +142,24 @@ function DomeModel({ diameter, rise, wallHeight, cpi, cpeBarlavento, cpeTopo, cp
<cylinderGeometry args={[0.1, 0.1, 1.2, 16]} />
<meshStandardMaterial color="#3b82f6" roughness={0.3} />
</mesh>
<Text
position={[0, -1.5, 0]}
rotation={[Math.PI / 2, 0, 0]}
fontSize={0.4}
color="#3b82f6"
anchorX="center"
anchorY="middle"
>
Vento
</Text>
</group>
{/* Texto Vento */}
<Text
position={[-radius - 2.5, wallHeight / 2 + 1.2, 0]}
rotation={[0, 0, 0]}
fontSize={1.2}
color="#3b82f6"
anchorX="center"
anchorY="bottom"
>
Vento
</Text>
{/* Texto informativo */}
<Text
position={[0, wallHeight + rise + 0.8, 0]}
fontSize={Math.max(0.3, Math.min(0.6, diameter / 12))}
fontSize={Math.max(0.35, Math.min(0.7, (diameter / 12) * 1.3))}
color={isDark ? "#cbd5e1" : "#1a202c"}
anchorX="center"
anchorY="bottom"
@@ -168,6 +173,7 @@ function DomeModel({ diameter, rise, wallHeight, cpi, cpeBarlavento, cpeTopo, cp
export default function Dome3DViewer(props: Dome3DInput) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const { viewMode = 'solid' } = props;
const fallback = (
<FallbackDiagram
@@ -181,6 +187,7 @@ export default function Dome3DViewer(props: Dome3DInput) {
return (
<SceneCanvas moduleId="cupula"
shadows
frameloop={viewMode === 'airflow' ? 'always' : 'demand'}
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [props.diameter * 1.5, (props.wallHeight + props.rise) * 1.5, props.diameter * 1.5], fov: 40 }}
fallback={fallback}
@@ -199,6 +206,9 @@ export default function Dome3DViewer(props: Dome3DInput) {
shadow-camera-bottom={-maxDim}
/>
<DomeModel {...props} />
{viewMode === 'airflow' && (
<DomeAirflowSystem diameter={props.diameter} rise={props.rise} wallHeight={props.wallHeight} />
)}
<Grid
infiniteGrid
fadeDistance={maxDim * 5}
@@ -0,0 +1,184 @@
import { useRef, useMemo, useEffect } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { useCanvasTheme } from '../../lib/theme';
export interface DomeAirflowSystemProps {
diameter: number;
rise: number;
wallHeight: number;
}
export function DomeAirflowSystem({ diameter, rise, wallHeight }: DomeAirflowSystemProps) {
const isDark = useCanvasTheme() === 'dark';
const count = 400;
const meshRef = useRef<THREE.InstancedMesh>(null);
const R = diameter / 2;
const H = wallHeight + rise;
const particles = useMemo(() => {
const temp = [];
const spanX = Math.max(15, R * 5);
const spanZ = Math.max(12, R * 4);
for (let i = 0; i < count; i++) {
temp.push({
position: new THREE.Vector3(
(Math.random() - 0.5) * spanX * 2,
Math.random() * (H * 1.4),
(Math.random() - 0.5) * spanZ * 2
),
baseSpeed: 0.12 + Math.random() * 0.08,
wobbleSpeed: Math.random() * 0.05,
wobbleOffset: Math.random() * Math.PI * 2,
currentCpe: 0,
});
}
return temp;
}, [count, R, H]);
const dummy = useMemo(() => new THREE.Object3D(), []);
const colorObj = useMemo(() => new THREE.Color(), []);
useEffect(() => {
if (meshRef.current) {
for (let i = 0; i < count; i++) {
meshRef.current.setColorAt(i, new THREE.Color('#ffffff'));
}
if (meshRef.current.instanceColor) {
meshRef.current.instanceColor.needsUpdate = true;
}
}
}, [count]);
useFrame((state) => {
if (!meshRef.current) return;
const time = state.clock.elapsedTime;
const spanX = Math.max(15, R * 5);
const spanZ = Math.max(12, R * 4);
// Parâmetros da esfera da calota
const rSphere = (R * R + rise * rise) / (2 * rise);
const centerY = wallHeight - (rSphere - rise);
particles.forEach((p, i) => {
let deflectX = 1.0;
let deflectY = 0.0;
let deflectZ = 0.0;
const px = p.position.x;
const py = p.position.y;
const pz = p.position.z;
const r = Math.sqrt(px * px + pz * pz);
const safeR = Math.max(R + 0.3, r);
// Colisão / Deflexão com a parede cilíndrica inferior
if (py <= wallHeight) {
if (r < R + 0.25) {
const angle = Math.atan2(pz, px);
p.position.x = Math.cos(angle) * (R + 0.25);
p.position.z = Math.sin(angle) * (R + 0.25);
}
if (r < R * 3.0) {
const r2 = safeR * safeR;
const R2 = R * R;
deflectX = 1 - (R2 * (px * px - pz * pz)) / (r2 * r2);
deflectZ = -(2 * R2 * px * pz) / (r2 * r2);
}
}
// Colisão / Deflexão com a calota esférica (Cúpula)
else if (py > wallHeight && py <= H + 2.0) {
// Distância até o centro virtual da calota
const dySphere = py - centerY;
const distToSphere = Math.sqrt(px * px + dySphere * dySphere + pz * pz);
if (distToSphere < rSphere + 0.25) {
// Normal da esfera
const nx = px / distToSphere;
const ny = dySphere / distToSphere;
const nz = pz / distToSphere;
// Reposiciona na casca externa
p.position.x = nx * (rSphere + 0.25);
p.position.y = centerY + ny * (rSphere + 0.25);
p.position.z = nz * (rSphere + 0.25);
// Direciona o vento para subir
deflectY = ny * 1.2;
deflectX = nx * 1.2;
deflectZ = nz * 0.5;
}
}
// Esteira de sucção sotavento
if (px > R * 0.8 && Math.abs(pz) < R * 1.5 && py <= H) {
deflectZ += Math.sin(time * 5 + px * 0.8 + p.wobbleOffset) * 0.25;
deflectY -= 0.1;
}
// Mapear Coeficiente Cpe local para cor
if (py <= H + 0.5 && r < R * 2.2) {
if (px < -R * 0.3) {
p.currentCpe = 0.6; // Barlavento
} else if (Math.abs(px) <= R * 0.4 && py > wallHeight) {
p.currentCpe = -1.0; // Topo da cúpula (forte sucção)
} else if (px > R * 0.2) {
p.currentCpe = 0.5; // Sotavento / Esteira (agora positivo para ficar vermelho)
} else {
p.currentCpe = -0.5; // Laterais / transição
}
} else {
p.currentCpe = 0;
}
const moveVec = new THREE.Vector3(deflectX, deflectY, deflectZ).normalize();
const currentSpeed = p.baseSpeed * Math.max(0.3, Math.min(1.5, deflectX));
p.position.addScaledVector(moveVec, currentSpeed);
// Reposição quando sai do volume
if (p.position.x > spanX) {
p.position.x = -spanX;
p.position.y = Math.random() * (H * 1.4);
p.position.z = (Math.random() - 0.5) * spanZ * 2;
p.currentCpe = 0;
}
dummy.position.copy(p.position);
dummy.lookAt(p.position.clone().add(moveVec));
dummy.rotateX(Math.PI / 2);
dummy.scale.set(1, 1 + currentSpeed * 6, 1);
dummy.updateMatrix();
meshRef.current!.setMatrixAt(i, dummy.matrix);
if (p.currentCpe > 0) {
colorObj.set(isDark ? '#f87171' : '#ef4444'); // Vermelho (Turbulento/Pressão)
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj.set(isDark ? '#c084fc' : '#a855f7').lerp(new THREE.Color(isDark ? '#7e22ce' : '#6b21a8'), intensity); // Roxo (Acelerado/Sucção)
} else {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6'); // Azul (Corrente livre)
}
meshRef.current!.setColorAt(i, colorObj);
});
meshRef.current.instanceMatrix.needsUpdate = true;
if (meshRef.current.instanceColor) {
meshRef.current.instanceColor.needsUpdate = true;
}
});
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
<cylinderGeometry args={[0.01, 0.01, 1.5, 4]} />
<meshBasicMaterial
color="#ffffff"
transparent
opacity={isDark ? 0.6 : 0.8}
blending={isDark ? THREE.AdditiveBlending : THREE.NormalBlending}
depthWrite={false}
/>
</instancedMesh>
);
}
+61 -33
View File
@@ -4,12 +4,12 @@ import { ViewerOrbitControls as OrbitControls } from './ViewerOrbitControls';
import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
import { WindArrow } from './WindArrow';
import { useCanvasTheme } from '@/lib/theme';
import { IsolatedRoofAirflowSystem } from './IsolatedRoofAirflowSystem';
export interface IsolatedRoof3DInput {
/** Tipo de cobertura: 'shed' (uma água) ou 'gable' (duas águas) */
type: 'shed' | 'gable';
/** Tipo de cobertura: 'shed' (uma água), 'gable' (duas águas) ou 'butterfly' (borboleta) */
type: 'shed' | 'gable' | 'butterfly';
/** Inclinação θ (graus) */
theta: number;
/** Altura livre dos suportes (m) */
@@ -26,6 +26,7 @@ export interface IsolatedRoof3DInput {
cpeTop: number;
/** Força resultante na cobertura (kN) */
forceKN: number;
viewMode?: 'solid' | 'airflow';
}
function pressureColor(cpe: number, isDark: boolean): THREE.Color {
@@ -50,9 +51,11 @@ function IsolatedRoofModel({
cpeWindward,
cpeLeeward,
forceKN,
viewMode = 'solid',
}: IsolatedRoof3DInput) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const isAirflow = viewMode === 'airflow';
const labelColor = isDark ? '#cbd5e1' : '#475569';
const textColor = isDark ? '#93c5fd' : '#1a202c';
const lineMaterialColor = isDark ? '#94a3b8' : '#64748b';
@@ -81,7 +84,7 @@ function IsolatedRoofModel({
{ pos: [-halfWidth, (height + h_diff) / 2, halfDepth], h: height + h_diff },
{ pos: [halfWidth, (height + h_diff) / 2, halfDepth], h: height + h_diff },
);
} else {
} else if (type === 'gable') {
list.push(
{ pos: [-halfWidth, height / 2, -halfDepth], h: height },
{ pos: [halfWidth, height / 2, -halfDepth], h: height },
@@ -90,6 +93,15 @@ function IsolatedRoofModel({
{ pos: [-halfWidth, (height + h_half) / 2, 0], h: height + h_half },
{ pos: [halfWidth, (height + h_half) / 2, 0], h: height + h_half },
);
} else if (type === 'butterfly') {
list.push(
{ pos: [-halfWidth, (height + h_half) / 2, -halfDepth], h: height + h_half },
{ pos: [halfWidth, (height + h_half) / 2, -halfDepth], h: height + h_half },
{ pos: [-halfWidth, (height + h_half) / 2, halfDepth], h: height + h_half },
{ pos: [halfWidth, (height + h_half) / 2, halfDepth], h: height + h_half },
{ pos: [-halfWidth, height / 2, 0], h: height },
{ pos: [halfWidth, height / 2, 0], h: height },
);
}
return list;
}, [type, depth, width, height, h_diff, h_half, halfDepth, halfWidth]);
@@ -106,25 +118,9 @@ function IsolatedRoofModel({
{type === 'shed' ? (
// Uma água (Shed): dividida em metade barlavento e metade sotavento
<group>
{/* Metade Barlavento (Z < 0) */}
<mesh
position={[0, height + h_diff / 4, -depth / 4]}
rotation={[-thetaRad, 0, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[width, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<meshStandardMaterial color={windwardColor} opacity={0.9} transparent roughness={0.4} />
</mesh>
{/* Metade Sotavento (Z > 0) */}
<mesh
position={[0, height + (3 * h_diff) / 4, depth / 4]}
rotation={[-thetaRad, 0, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[width, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<meshStandardMaterial color={leewardColor} opacity={0.9} transparent roughness={0.4} />
<mesh position={[0, height + h_diff / 2, 0]} rotation={[-thetaRad, 0, 0]} castShadow receiveShadow>
<boxGeometry args={[width, 0.08, depth / Math.cos(thetaRad)]} />
<meshStandardMaterial color={windwardColor} opacity={isAirflow ? 0.35 : 0.9} transparent roughness={0.4} />
</mesh>
</group>
) : (
@@ -133,22 +129,22 @@ function IsolatedRoofModel({
{/* Água Esquerda / Barlavento (Z < 0) */}
<mesh
position={[0, height + h_half / 2, -depth / 4]}
rotation={[-thetaRad, 0, 0]}
rotation={[type === 'butterfly' ? thetaRad : -thetaRad, 0, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[width, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<meshStandardMaterial color={windwardColor} opacity={0.9} transparent roughness={0.4} />
<meshStandardMaterial color={windwardColor} opacity={isAirflow ? 0.35 : 0.9} transparent roughness={0.4} />
</mesh>
{/* Água Direita / Sotavento (Z > 0) */}
<mesh
position={[0, height + h_half / 2, depth / 4]}
rotation={[thetaRad, 0, 0]}
rotation={[type === 'butterfly' ? -thetaRad : thetaRad, 0, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[width, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<meshStandardMaterial color={leewardColor} opacity={0.9} transparent roughness={0.4} />
<meshStandardMaterial color={leewardColor} opacity={isAirflow ? 0.35 : 0.9} transparent roughness={0.4} />
</mesh>
</group>
)}
@@ -161,11 +157,27 @@ function IsolatedRoofModel({
</mesh>
))}
{/* Vetor de vento (Seta verde/azul batendo no elemento) */}
<WindArrow
target={new THREE.Vector3(0, centerY, 0)}
direction={new THREE.Vector3(0, 0, 1)}
/>
{/* Vetor de vento (Seta mais visível com texto) */}
<group position={[0, centerY, -halfDepth - 3.5]} rotation={[Math.PI / 2, 0, 0]}>
<mesh castShadow>
<coneGeometry args={[0.4, 1.2, 16]} />
<meshStandardMaterial color="#3b82f6" roughness={0.3} />
</mesh>
<mesh position={[0, -1.0, 0]} castShadow>
<cylinderGeometry args={[0.15, 0.15, 2.0, 16]} />
<meshStandardMaterial color="#3b82f6" roughness={0.3} />
</mesh>
</group>
<Text
position={[0, centerY + 1.2, -halfDepth - 3.5]}
rotation={[0, Math.PI, 0]}
fontSize={1.2}
color="#3b82f6"
anchorX="center"
anchorY="bottom"
>
Vento
</Text>
{/* === LINHAS DE COTA (CAD-Style) === */}
{/* Cota de Altura (h) */}
@@ -248,6 +260,8 @@ function IsolatedRoofModel({
{/* Rótulo Superior */}
<Text
position={[0, centerY + 3, 0]}
rotation={[0, Math.PI, 0]}
fontSize={Math.max(0.4, width / 15)}
color={textColor}
anchorX="center"
anchorY="bottom"
@@ -268,6 +282,7 @@ export default function IsolatedRoof3DViewer({
cpeLeeward,
cpeTop,
forceKN,
viewMode = 'solid',
}: IsolatedRoof3DInput) {
const cameraDistance = Math.max(Math.max(width, depth) * 1.3, height * 1.5, 8);
@@ -281,8 +296,9 @@ export default function IsolatedRoof3DViewer({
return (
<SceneCanvas moduleId="cobertura_isolada"
shadows
frameloop={viewMode === 'airflow' ? 'always' : 'demand'}
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [cameraDistance, cameraDistance * 0.8, cameraDistance], fov: 40 }}
camera={{ position: [-cameraDistance * 0.8, cameraDistance * 0.8, -cameraDistance], fov: 40 }}
fallback={fallback}
>
<ambientLight intensity={0.7} />
@@ -303,7 +319,19 @@ export default function IsolatedRoof3DViewer({
cpeLeeward={cpeLeeward}
cpeTop={cpeTop}
forceKN={forceKN}
viewMode={viewMode}
/>
{viewMode === 'airflow' && (
<IsolatedRoofAirflowSystem
type={type}
theta={theta}
height={height}
width={width}
depth={depth}
cpeWindward={cpeWindward}
cpeLeeward={cpeLeeward}
/>
)}
<Grid infiniteGrid fadeDistance={cameraDistance * 5} sectionColor="#94a3b8" cellColor="#cbd5e1" position={[0, -0.01, 0]} />
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2 - 0.05} />
<Environment preset="city" />
@@ -0,0 +1,170 @@
import React, { useRef, useMemo, useEffect } from 'react';
import * as THREE from 'three';
import { useFrame } from '@react-three/fiber';
import { useCanvasTheme } from '@/lib/theme';
interface Particle {
position: THREE.Vector3;
baseSpeed: number;
wobbleSpeed: number;
wobbleOffset: number;
currentCpe: number;
}
interface IsolatedRoofAirflowSystemProps {
type: 'shed' | 'gable' | 'butterfly';
theta: number;
height: number;
width: number;
depth: number;
cpeWindward: number;
cpeLeeward: number;
}
export const IsolatedRoofAirflowSystem: React.FC<IsolatedRoofAirflowSystemProps> = ({
type,
theta,
height,
width,
depth,
cpeWindward,
cpeLeeward,
}) => {
const meshRef = useRef<THREE.InstancedMesh>(null);
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const COUNT = 100;
const dummy = useMemo(() => new THREE.Object3D(), []);
const colorObj = useMemo(() => new THREE.Color(), []);
const thetaRad = (theta * Math.PI) / 180;
const halfDepth = depth / 2;
const halfWidth = width / 2;
const h_diff = depth * Math.tan(thetaRad);
const h_half = halfDepth * Math.tan(thetaRad);
const particles = useMemo(() => {
const arr: Particle[] = [];
for (let i = 0; i < COUNT; i++) {
arr.push({
position: new THREE.Vector3(
(Math.random() - 0.5) * width * 3.0,
Math.random() * (height + h_diff + 3),
-halfDepth - 5 - Math.random() * 20
),
baseSpeed: 0.075 + Math.random() * 0.05,
wobbleSpeed: 0.5 + Math.random() * 1.5,
wobbleOffset: Math.random() * Math.PI * 2,
currentCpe: 0,
});
}
return arr;
}, [width, height, depth, h_diff]);
useEffect(() => {
if (meshRef.current) {
for (let i = 0; i < COUNT; i++) {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6');
meshRef.current.setColorAt(i, colorObj);
}
meshRef.current.instanceColor!.needsUpdate = true;
}
}, [isDark, colorObj]);
useFrame((state) => {
if (!meshRef.current) return;
const time = state.clock.elapsedTime;
particles.forEach((p, i) => {
const px = p.position.x;
const py = p.position.y;
const pz = p.position.z;
let currentSpeed = p.baseSpeed;
let localCpe = 0;
let deflectY = 0;
const inX = Math.abs(px) < halfWidth + 1;
const inZ = Math.abs(pz) < halfDepth + 2;
if (inX && inZ) {
if (type === 'butterfly') {
if (pz < 0) {
localCpe = cpeWindward;
// Vento acompanha a descida em direção ao vale
if (py > height + 0.2) {
deflectY -= currentSpeed * 0.3;
}
} else {
localCpe = cpeLeeward;
// Vento bate na segunda água que sobe e é defletido para cima
if (py < height + h_half + 2) {
deflectY += currentSpeed * 0.6;
}
}
} else {
if (pz < 0) {
localCpe = cpeWindward;
const maxH = type === 'shed' ? height + h_diff : height + h_half;
if (py < maxH + 1) {
const dist = -pz;
const lift = Math.max(0, maxH - py) / (dist + 1);
deflectY += lift * currentSpeed * 0.4;
}
} else {
localCpe = cpeLeeward;
if (cpeLeeward < 0) {
deflectY -= currentSpeed * 0.15;
p.position.y += Math.sin(time * 5 + p.wobbleOffset) * 0.05;
}
}
}
if (pz > halfDepth && py < height + h_diff) {
localCpe = 0.5; // Esteira
deflectY -= currentSpeed * 0.2;
}
}
p.currentCpe += (localCpe - p.currentCpe) * 0.1;
p.position.z += currentSpeed;
p.position.y += deflectY;
p.position.y += Math.sin(time * p.wobbleSpeed * 10 + p.wobbleOffset) * 0.01;
if (p.position.z > halfDepth + 10) {
p.position.z = -halfDepth - 10 - Math.random() * 5;
p.position.y = Math.random() * (height + h_diff + 3);
p.position.x = (Math.random() - 0.5) * width * 3.0;
p.currentCpe = 0;
}
dummy.position.copy(p.position);
dummy.rotation.set(0, 0, 0);
dummy.rotateX(Math.PI / 2);
dummy.scale.set(1, 1 + currentSpeed * 6, 1);
dummy.updateMatrix();
meshRef.current!.setMatrixAt(i, dummy.matrix);
if (p.currentCpe > 0) {
colorObj.set(isDark ? '#f87171' : '#ef4444');
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj.set(isDark ? '#c084fc' : '#a855f7').lerp(new THREE.Color(isDark ? '#7e22ce' : '#6b21a8'), intensity);
} else {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6');
}
meshRef.current!.setColorAt(i, colorObj);
});
meshRef.current.instanceMatrix.needsUpdate = true;
meshRef.current.instanceColor!.needsUpdate = true;
});
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, COUNT]}>
<cylinderGeometry args={[0.04, 0.04, 1.0, 4]} />
<meshBasicMaterial transparent opacity={0.6} depthWrite={false} />
</instancedMesh>
);
};
+4 -3
View File
@@ -7,6 +7,7 @@ interface ResizableBox3DProps {
l1: number;
l2: number;
h: number;
viewMode?: 'solid' | 'airflow';
}
function WindParticles({ l1, l2, h }: { l1: number, l2: number, h: number }) {
@@ -79,7 +80,7 @@ function WindParticles({ l1, l2, h }: { l1: number, l2: number, h: number }) {
);
}
export function ResizableBox3D({ l1, l2, h }: ResizableBox3DProps) {
export function ResizableBox3D({ l1, l2, h, viewMode = 'solid' }: ResizableBox3DProps) {
const meshRef = useRef<THREE.Mesh>(null);
// Create target scale and position based on inputs
@@ -109,12 +110,12 @@ export function ResizableBox3D({ l1, l2, h }: ResizableBox3DProps) {
{/* The Animated Box */}
<mesh ref={meshRef} castShadow receiveShadow>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#4f46e5" transparent opacity={0.7} roughness={0.2} metalness={0.8} />
<meshStandardMaterial color="#4f46e5" transparent opacity={viewMode === 'airflow' ? 0.3 : 0.7} roughness={0.2} metalness={0.8} />
<Edges scale={1} threshold={15} color="#818cf8" />
</mesh>
{/* Wind Flow Animation */}
<WindParticles l1={l1} l2={l2} h={h} />
{viewMode === 'airflow' && <WindParticles l1={l1} l2={l2} h={h} />}
</group>
);
}
+93 -80
View File
@@ -1,10 +1,9 @@
import React, { useRef } from 'react';
import * as THREE from 'three';
import { useFrame } from '@react-three/fiber';
import { Text, Grid, Environment } from '@react-three/drei';
import { WindArrow as GlobalWindArrow } from './WindArrow';
import { ViewerOrbitControls as OrbitControls } from './ViewerOrbitControls';
import SceneCanvas from '../SceneCanvas';
import { useCanvasTheme } from '@/lib/theme';
import { ShelterAirflowSystem } from './ShelterAirflowSystem';
import type { ShelterCondition, OpeningPosition, ShelterWindAngle } from '@/lib/nbr-tables/table-shelters';
export interface Shelter3DProps {
@@ -18,44 +17,11 @@ export interface Shelter3DProps {
rightClosure: number;
openingPos: OpeningPosition;
backRange?: [number, number];
numColumns?: number;
windAngle?: ShelterWindAngle;
viewMode: '3d' | 'elevation';
viewMode: '3d' | 'elevation' | 'airflow';
}
const WindArrow: React.FC<{
start: [number, number, number];
end: [number, number, number];
color?: string;
speed?: number;
}> = ({ start, end, color = '#38bdf8', speed = 1.5 }) => {
const arrowRef = useRef<THREE.Group>(null);
const quaternion = React.useMemo(() => {
const dir = new THREE.Vector3(...end).sub(new THREE.Vector3(...start)).normalize();
return new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
}, [start, end]);
useFrame(({ clock, invalidate }) => {
if (arrowRef.current) {
const t = (clock.getElapsedTime() * speed) % 1;
const x = start[0] + (end[0] - start[0]) * t;
const y = start[1] + (end[1] - start[1]) * t;
const z = start[2] + (end[2] - start[2]) * t;
arrowRef.current.position.set(x, y, z);
invalidate();
}
});
return (
<group ref={arrowRef} quaternion={quaternion}>
<mesh>
<coneGeometry args={[0.25, 0.6, 8]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={0.6} />
</mesh>
</group>
);
};
function ShelterModel({
condition: _condition,
depth,
@@ -67,14 +33,18 @@ function ShelterModel({
rightClosure,
openingPos,
backRange,
numColumns = 2,
windAngle = 0,
viewMode,
}: Shelter3DProps) {
const isElevation = viewMode === 'elevation';
const theme = useCanvasTheme();
const isDark = theme === 'dark';
// Geometria básica
const halfW = width / 2;
const halfD = depth / 2;
const isAirflow = viewMode === 'airflow';
const thetaRad = (theta * Math.PI) / 180;
const slopeRise = depth * Math.tan(thetaRad);
@@ -123,10 +93,17 @@ function ShelterModel({
);
};
const overhang = 0.6;
const spanWidth = Math.max(0.1, width - 2 * overhang);
const spacing = spanWidth / Math.max(1, numColumns - 1);
const columnPositions = Array.from({ length: numColumns }).map(
(_, i) => -halfW + overhang + i * spacing
);
return (
<group position={[0, -height / 2, 0]}>
{/* 1. Estrutura Metálica Principal (Pilares e Vigas em Balanço - Tubos Quadrados) */}
{[-halfW + 0.6, halfW - 0.6].map((xPos, idx) => (
{columnPositions.map((xPos, idx) => (
<group key={`frame-${idx}`} position={[xPos, 0, 0]}>
{/* Pilar traseiro (lado direito na elevação) */}
<mesh position={[0, height / 2, halfD - 0.25]}>
@@ -161,50 +138,85 @@ function ShelterModel({
{/* Parede Lateral Direita (X = +halfW) */}
{renderWallWithGap(depth, height, rightClosure, 'distributed', Math.PI / 2, [halfW, 0, 0])}
{/* 4. Linhas de Corrente e Vetores de Vento (Ilustrativo e Didático) */}
<group position={[0, 0, 0]} rotation={[0, -((windAngle || 0) * Math.PI) / 180, 0]}>
{/* Vento de entrada (Barlavento) entrando em direção ao abrigo */}
<WindArrow start={[0, height * 0.5, -halfD - 4]} end={[0, height * 0.5, -halfD + 1]} color="#38bdf8" speed={1.2} />
<WindArrow start={[-halfW * 0.5, height * 0.7, -halfD - 4]} end={[-halfW * 0.5, height * 0.7, -halfD + 1]} color="#38bdf8" speed={1.4} />
<WindArrow start={[halfW * 0.5, height * 0.4, -halfD - 4]} end={[halfW * 0.5, height * 0.4, -halfD + 1]} color="#38bdf8" speed={1.3} />
{/* Linhas de corrente sobre o telhado */}
<WindArrow start={[0, height + 1, -halfD - 3]} end={[0, height + slopeRise + 1.5, halfD + 2]} color="#60a5fa" speed={1.6} />
{/* Fresta na parede de fundo: se houver abertura no topo, mostrar vento escapando pela fresta superior */}
{backClosure > 0 && backClosure < 100 && (openingPos === 'top' || (backRange && backRange[1] < 98)) && (
<>
<WindArrow start={[0, height * 0.85, 0]} end={[0, height * 0.95, halfD + 2.5]} color="#34d399" speed={1.8} />
<Text position={[0, height + 0.35, halfD]} rotation={[0, Math.PI, 0]} fontSize={0.4} color="#34d399">
Alívio (Fresta Topo)
{/* Setas de Vento Global (0°, 45°, 90°) */}
{[
{
angle: 0,
label: 'Vento 0°',
target: [0, height * 1.5 - 1.5, -halfD - 4] as [number, number, number],
direction: [0, 0, 1] as [number, number, number],
textPos: [0, height * 1.5, -halfD - 6] as [number, number, number],
textRot: [0, Math.PI, 0] as [number, number, number],
},
{
angle: 45,
label: 'Vento 45°',
target: [-halfW - 3, height * 1.5 - 1.5, -halfD - 3] as [number, number, number],
direction: [0.707, 0, 0.707] as [number, number, number],
textPos: [-halfW - 5, height * 1.5, -halfD - 5] as [number, number, number],
textRot: [0, Math.PI * 0.75, 0] as [number, number, number],
},
{
angle: 90,
label: 'Vento 90°',
target: [-halfW - 4, height * 1.5 - 1.5, 0] as [number, number, number],
direction: [1, 0, 0] as [number, number, number],
textPos: [-halfW - 6, height * 1.5, 0] as [number, number, number],
textRot: [0, Math.PI / 2, 0] as [number, number, number],
}
].map((arrow) => {
const isActive = windAngle === arrow.angle;
const arrowColor = isActive ? (isDark ? '#60a5fa' : '#2563eb') : (isDark ? '#475569' : '#94a3b8');
return (
<group key={`wind-${arrow.angle}`}>
<GlobalWindArrow
target={arrow.target}
direction={arrow.direction}
scale={isActive ? 3.2 : 2.0}
color={arrowColor}
opacity={isActive ? 1 : 0.3}
/>
<Text
position={arrow.textPos}
rotation={arrow.textRot}
fontSize={isActive ? 0.96 : 0.75}
color={arrowColor}
fillOpacity={isActive ? 1 : 0.5}
anchorX="center"
anchorY="bottom"
>
{arrow.label}
</Text>
</>
)}
</group>
);
})}
{/* Fresta na base */}
{backClosure > 0 && backClosure < 100 && (openingPos === 'bottom' || (backRange && backRange[0] > 2)) && (
<>
<WindArrow start={[0, 0.4, 0]} end={[0, 0.4, halfD + 2.5]} color="#facc15" speed={1.8} />
<Text position={[0, 1.2, halfD]} rotation={[0, Math.PI, 0]} fontSize={0.4} color="#facc15">
Vão Livre Inferior
</Text>
</>
)}
</group>
{/* Partículas de Ar */}
{isAirflow && (
<group>
<ShelterAirflowSystem
width={width}
depth={depth}
height={height}
theta={theta}
/>
</group>
)}
{/* Textos Informativos 3D - Virados para quem olha da frente (-Z) */}
<Text position={[0, height + slopeRise + 0.8, 0]} rotation={[0, Math.PI, 0]} fontSize={0.45} color="#e2e8f0">
{isElevation ? 'CORTE A-A (Elevação)' : 'Abrigo / Pórtico (NBR 6123)'}
</Text>
<Text position={[0, 0.3, -halfD - 1.2]} rotation={[0, Math.PI, 0]} fontSize={0.35} color="#38bdf8">
{`VENTO ${windAngle === 0 ? 'FRONTAL (0°)' : windAngle === 45 ? 'OBLÍQUO (45°)' : 'LATERAL (90°)'}`}
</Text>
<Text position={[0, height + slopeRise + 0.25, 0]} rotation={[0, Math.PI, 0]} fontSize={0.32} color="#f43f5e">
Arrancamento [+Fz ]
</Text>
<Text position={[0, height * 0.5, halfD + 1.2]} rotation={[0, Math.PI, 0]} fontSize={0.32} color="#fb923c">
Empuxo no Fundo [+Fx ]
</Text>
{!isAirflow && (
<>
<Text position={[0, height + slopeRise + 1.4, 0]} rotation={[0, Math.PI, 0]} fontSize={0.45} color={isDark ? '#f8fafc' : '#0f172a'}>
{isElevation ? 'CORTE A-A (Elevação)' : 'Abrigo / Pórtico (NBR 6123)'}
</Text>
<Text position={[0, height + slopeRise + 0.6, 0]} rotation={[0, Math.PI, 0]} fontSize={0.51} color={isDark ? '#ff4d6d' : '#b91c1c'}>
Arrancamento [+Fz ]
</Text>
<Text position={[0, height * 0.5, halfD + 1.2]} rotation={[0, Math.PI, 0]} fontSize={0.51} color={isDark ? '#ff9f43' : '#c2410c'}>
Empuxo no Fundo [+Fx ]
</Text>
</>
)}
</group>
);
}
@@ -212,13 +224,14 @@ function ShelterModel({
export function Shelter3D(props: Shelter3DProps) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const { width, depth, height } = props;
const { width, depth, height, viewMode } = props;
const dist = Math.max(width, depth) * 1.8 + 8;
return (
<SceneCanvas
moduleId="shelter"
shadows
frameloop={viewMode === 'airflow' ? 'always' : 'demand'}
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [dist * 0.75, height * 1.1, -dist * 0.85], fov: 45 }}
fallback={<div className="flex items-center justify-center h-full text-muted-foreground">3D indisponível</div>}
@@ -0,0 +1,134 @@
import React, { useRef, useMemo, useEffect } from 'react';
import * as THREE from 'three';
import { useFrame } from '@react-three/fiber';
import { useCanvasTheme } from '@/lib/theme';
interface Particle {
position: THREE.Vector3;
baseSpeed: number;
wobbleSpeed: number;
wobbleOffset: number;
currentCpe: number;
}
interface ShelterAirflowSystemProps {
width: number;
depth: number;
height: number;
theta: number;
}
export const ShelterAirflowSystem: React.FC<ShelterAirflowSystemProps> = ({
width,
depth,
height,
theta,
}) => {
const meshRef = useRef<THREE.InstancedMesh>(null);
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const COUNT = 100;
const dummy = useMemo(() => new THREE.Object3D(), []);
const colorObj = useMemo(() => new THREE.Color(), []);
const halfWidth = width / 2;
const halfDepth = depth / 2;
const thetaRad = (theta * Math.PI) / 180;
const h_diff = depth * Math.tan(thetaRad);
const particles = useMemo(() => {
const arr: Particle[] = [];
for (let i = 0; i < COUNT; i++) {
arr.push({
position: new THREE.Vector3(
(Math.random() - 0.5) * width * 3,
Math.random() * (height + h_diff + 5),
-halfDepth - 10 - Math.random() * 20
),
baseSpeed: 0.075 + Math.random() * 0.05,
wobbleSpeed: 0.5 + Math.random() * 1.5,
wobbleOffset: Math.random() * Math.PI * 2,
currentCpe: 0,
});
}
return arr;
}, [width, depth, height, h_diff]);
useEffect(() => {
if (meshRef.current) {
for (let i = 0; i < COUNT; i++) {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6');
meshRef.current.setColorAt(i, colorObj);
}
meshRef.current.instanceColor!.needsUpdate = true;
}
}, [isDark, colorObj]);
useFrame((state) => {
if (!meshRef.current) return;
const time = state.clock.elapsedTime;
particles.forEach((p, i) => {
const px = p.position.x;
const py = p.position.y;
const pz = p.position.z;
let currentSpeed = p.baseSpeed;
let localCpe = 0;
let deflectY = 0;
const inX = Math.abs(px) < halfWidth + 1;
const inZ = Math.abs(pz) < halfDepth + 1;
if (inX && inZ) {
localCpe = 0.5;
// Teto inclinado deflete o vento
if (py < height + h_diff + 1 && py > height - 1) {
const dist = -pz;
const lift = Math.max(0, height + h_diff - py) / (dist + 1);
deflectY += lift * currentSpeed * 0.4;
}
}
p.currentCpe += (localCpe - p.currentCpe) * 0.1;
p.position.z += currentSpeed;
p.position.y += deflectY;
p.position.y += Math.sin(time * p.wobbleSpeed * 10 + p.wobbleOffset) * 0.01;
if (p.position.z > halfDepth + 10) {
p.position.z = -halfDepth - 10 - Math.random() * 5;
p.position.y = Math.random() * (height + h_diff + 5);
p.position.x = (Math.random() - 0.5) * width * 3;
p.currentCpe = 0;
}
dummy.position.copy(p.position);
dummy.rotation.set(0, 0, 0);
dummy.rotateX(Math.PI / 2);
dummy.scale.set(1, 1 + currentSpeed * 6, 1);
dummy.updateMatrix();
meshRef.current!.setMatrixAt(i, dummy.matrix);
if (p.currentCpe > 0) {
colorObj.set(isDark ? '#f87171' : '#ef4444');
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj.set(isDark ? '#c084fc' : '#a855f7').lerp(new THREE.Color(isDark ? '#7e22ce' : '#6b21a8'), intensity);
} else {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6');
}
meshRef.current!.setColorAt(i, colorObj);
});
meshRef.current.instanceMatrix.needsUpdate = true;
meshRef.current.instanceColor!.needsUpdate = true;
});
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, COUNT]}>
<cylinderGeometry args={[0.04, 0.04, 1.0, 4]} />
<meshBasicMaterial transparent opacity={0.6} depthWrite={false} />
</instancedMesh>
);
};
+27 -8
View File
@@ -6,6 +6,7 @@ import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
import { WindArrow } from './WindArrow';
import { useCanvasTheme } from '@/lib/theme';
import { SignAirflowSystem } from './SignAirflowSystem';
export interface Sign3DInput {
/** Comprimento (m) */
@@ -22,6 +23,7 @@ export interface Sign3DInput {
forceKN: number;
/** Excentricidade e (m) */
applicationPoint: number;
viewMode?: 'solid' | 'airflow';
}
/**
@@ -35,9 +37,11 @@ function SignModel({
alpha,
cf,
applicationPoint,
viewMode = 'solid',
}: Sign3DInput) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const isAirflow = viewMode === 'airflow';
const baseY = groundClearance;
const topY = baseY + height;
const halfL = length / 2;
@@ -47,11 +51,13 @@ function SignModel({
const angleToNormalRad = ((90 - alpha) * Math.PI) / 180;
const arrowDir = useMemo(() => new THREE.Vector3(Math.cos(angleToNormalRad), 0, Math.sin(angleToNormalRad)), [angleToNormalRad]);
// Posição da seta no plano da placa (inicia no ponto de aplicação com a excentricidade ao longo de Z)
const arrowStart = useMemo(
() => new THREE.Vector3(0, baseY + height / 2, applicationPoint),
[applicationPoint, height, baseY],
);
// Posição alvo da seta no plano da placa (inicia no ponto de aplicação com a excentricidade ao longo de Z)
// Afastada um pouco do muro para não encostar fisicamente
const arrowStart = useMemo(() => {
const targetPos = new THREE.Vector3(0, baseY + height / 2, applicationPoint);
const offset = arrowDir.clone().multiplyScalar(-0.8);
return targetPos.add(offset);
}, [applicationPoint, height, baseY, arrowDir]);
// Cor da placa: Azul (Pressão/Empuxo) com intensidade proporcional ao Cf
const plateColor = useMemo(() => {
@@ -67,7 +73,7 @@ function SignModel({
{/* Placa principal */}
<mesh position={[0, (baseY + topY) / 2, 0]} castShadow receiveShadow>
<boxGeometry args={[0.1, height, length]} />
<meshStandardMaterial color={plateColor} opacity={0.8} transparent roughness={0.4} side={THREE.DoubleSide} />
<meshStandardMaterial color={plateColor} opacity={isAirflow ? 0.3 : 0.8} transparent roughness={0.4} side={THREE.DoubleSide} />
</mesh>
{/* Placas de extremidade (retornos aerodinâmicos nas pontas) */}
@@ -75,11 +81,11 @@ function SignModel({
<>
<mesh position={[0, (baseY + topY) / 2, halfL]} castShadow>
<boxGeometry args={[0.3, height * 0.95, 0.04]} />
<meshStandardMaterial color={isDark ? "#475569" : "#64748b"} opacity={0.6} transparent side={THREE.DoubleSide} />
<meshStandardMaterial color={isDark ? "#475569" : "#64748b"} opacity={isAirflow ? 0.2 : 0.6} transparent side={THREE.DoubleSide} />
</mesh>
<mesh position={[0, (baseY + topY) / 2, -halfL]} castShadow>
<boxGeometry args={[0.3, height * 0.95, 0.04]} />
<meshStandardMaterial color={isDark ? "#475569" : "#64748b"} opacity={0.6} transparent side={THREE.DoubleSide} />
<meshStandardMaterial color={isDark ? "#475569" : "#64748b"} opacity={isAirflow ? 0.2 : 0.6} transparent side={THREE.DoubleSide} />
</mesh>
</>
)}
@@ -118,6 +124,7 @@ function SignModel({
<WindArrow
target={arrowStart}
direction={arrowDir}
scale={1.8}
/>
{/* === LINHAS DE COTA (CAD-Style Dimensions) === */}
@@ -206,6 +213,7 @@ export default function Sign3DViewer({
cf,
forceKN,
applicationPoint,
viewMode = 'solid',
}: Sign3DInput) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
@@ -222,6 +230,7 @@ export default function Sign3DViewer({
return (
<SceneCanvas moduleId="muro_placa"
shadows
frameloop={viewMode === 'airflow' ? 'always' : 'demand'}
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [length * 1.3, height * 1.5, length * 1.3], fov: 40 }}
fallback={fallback}
@@ -236,7 +245,17 @@ export default function Sign3DViewer({
cf={cf}
forceKN={forceKN}
applicationPoint={applicationPoint}
viewMode={viewMode}
/>
{viewMode === 'airflow' && (
<SignAirflowSystem
length={length}
height={height}
groundClearance={groundClearance}
alpha={alpha}
cf={cf}
/>
)}
<Grid
infiniteGrid
fadeDistance={maxDim * 5}
@@ -0,0 +1,183 @@
import { useRef, useMemo, useEffect } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { useCanvasTheme } from '../../lib/theme';
export interface SignAirflowSystemProps {
length: number;
height: number;
groundClearance: number;
alpha: 0 | 50 | 90;
cf: number;
}
export function SignAirflowSystem({ length, height, groundClearance, alpha, cf }: SignAirflowSystemProps) {
const isDark = useCanvasTheme() === 'dark';
const count = 175;
const meshRef = useRef<THREE.InstancedMesh>(null);
const baseY = groundClearance;
const topY = baseY + height;
const halfL = length / 2;
const rad = ((90 - alpha) * Math.PI) / 180;
const windDir = useMemo(() => new THREE.Vector3(Math.cos(rad), 0, Math.sin(rad)).normalize(), [rad]);
const particles = useMemo(() => {
const temp = [];
const limitX = Math.max(15, length * 1.5);
const limitZ = Math.max(15, length * 1.5);
for (let i = 0; i < count; i++) {
// Spawn no plano do vento
temp.push({
position: new THREE.Vector3(
(Math.random() - 0.5) * limitX * 2,
Math.random() * (topY * 1.6),
(Math.random() - 0.5) * limitZ * 2
),
baseSpeed: 0.06 + Math.random() * 0.04,
wobbleSpeed: Math.random() * 0.05,
wobbleOffset: Math.random() * Math.PI * 2,
currentCpe: 0,
});
}
return temp;
}, [count, length, topY]);
const dummy = useMemo(() => new THREE.Object3D(), []);
const colorObj = useMemo(() => new THREE.Color(), []);
useEffect(() => {
if (meshRef.current) {
for (let i = 0; i < count; i++) {
meshRef.current.setColorAt(i, new THREE.Color('#ffffff'));
}
if (meshRef.current.instanceColor) {
meshRef.current.instanceColor.needsUpdate = true;
}
}
}, [count]);
useFrame((state) => {
if (!meshRef.current) return;
const time = state.clock.elapsedTime;
const limitX = Math.max(15, length * 1.5);
const limitZ = Math.max(15, length * 1.5);
particles.forEach((p, i) => {
let deflectX = windDir.x;
let deflectY = 0.0;
let deflectZ = windDir.z;
const px = p.position.x;
const py = p.position.y;
const pz = p.position.z;
// Verifica se a partícula está prestes a colidir com a placa (X=0)
const isInsideY = py >= baseY && py <= topY;
const isInsideZ = pz >= -halfL && pz <= halfL;
if (Math.abs(px) < 1.8 && isInsideY && isInsideZ) {
// Distância e fator de proximidade
const dist = Math.abs(px);
const force = Math.max(0, 1.8 - dist); // maior quanto mais perto de X = 0
// Se está vindo a barlavento, desvia para as bordas
if (px < 0) {
// Desvio em Y (por cima do topo ou por baixo do solo)
const distToTop = Math.abs(py - topY);
const distToBottom = Math.abs(py - baseY);
if (distToTop < distToBottom || baseY < 0.3) {
deflectY += force * 0.25;
} else {
deflectY -= force * 0.25;
}
// Desvio em Z (pelas laterais da placa)
if (pz > 0) {
deflectZ += force * 0.3;
} else {
deflectZ -= force * 0.3;
}
// Reduz a velocidade frontal (X) para simular desaceleração do ponto de estagnação
deflectX *= Math.max(0.2, 1 - force * 0.4);
}
// Colisão dura para não atravessar fisicamente a placa a X=0
if (Math.abs(px) < 0.15) {
p.position.x = px < 0 ? -0.15 : 0.15;
}
}
// Zona de esteira turbulenta a sotavento (atrás do muro X > 0)
if (px > 0.1 && px < length && isInsideZ) {
// Redução de velocidade na esteira
deflectX *= 0.6;
// Turbulência oscilatória
deflectZ += Math.sin(time * 6 + px * 0.8 + p.wobbleOffset) * 0.15;
deflectY += Math.cos(time * 4 + px * 0.8 + p.wobbleOffset) * 0.1;
}
// Definir cor das partículas (Cpe/Cf equivalente para coloração)
if (Math.abs(px) < length && isInsideZ && py < topY + 1.0) {
if (px < 0) {
p.currentCpe = cf * 0.6; // Pressão positiva
} else {
p.currentCpe = -cf * 0.6; // Sucção
}
} else {
p.currentCpe = 0;
}
const moveVec = new THREE.Vector3(deflectX, deflectY, deflectZ).normalize();
const currentSpeed = p.baseSpeed * (px > 0 && px < length ? 0.6 : 1.0);
p.position.addScaledVector(moveVec, currentSpeed);
// Reposição quando sai dos limites
// Como o vento tem direção inclinada, reposicionamos quando sai de um cilindro ou caixa
if (p.position.x > limitX || p.position.z > limitZ) {
// Reinicia a barlavento extremo
p.position.x = -limitX;
p.position.z = (Math.random() - 0.5) * limitZ * 2;
p.position.y = Math.random() * (topY * 1.6);
p.currentCpe = 0;
}
dummy.position.copy(p.position);
dummy.lookAt(p.position.clone().add(moveVec));
dummy.rotateX(Math.PI / 2);
dummy.scale.set(1, 1 + currentSpeed * 6, 1);
dummy.updateMatrix();
meshRef.current!.setMatrixAt(i, dummy.matrix);
if (p.currentCpe > 0) {
colorObj.set(isDark ? '#f87171' : '#ef4444'); // Vermelho (Turbulento/Pressão)
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj.set(isDark ? '#c084fc' : '#a855f7').lerp(new THREE.Color(isDark ? '#7e22ce' : '#6b21a8'), intensity); // Roxo (Acelerado/Sucção)
} else {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6'); // Azul (Corrente livre)
}
meshRef.current!.setColorAt(i, colorObj);
});
meshRef.current.instanceMatrix.needsUpdate = true;
if (meshRef.current.instanceColor) {
meshRef.current.instanceColor.needsUpdate = true;
}
});
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
<cylinderGeometry args={[0.01, 0.01, 1.5, 4]} />
<meshBasicMaterial
color="#ffffff"
transparent
opacity={isDark ? 0.6 : 0.8}
blending={isDark ? THREE.AdditiveBlending : THREE.NormalBlending}
depthWrite={false}
/>
</instancedMesh>
);
}
+35 -7
View File
@@ -5,6 +5,7 @@ import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import { WindArrow } from './WindArrow';
import FallbackDiagram from '../FallbackDiagram';
import { TowerAirflowSystem } from './TowerAirflowSystem';
export interface Tower3DInput {
/** Forma da seção */
@@ -23,6 +24,8 @@ export interface Tower3DInput {
alphaWind: 0 | 45 | 90;
/** Força total estimada na torre (kN) */
forceKN: number;
/** Modo de visualização */
viewMode?: 'solid' | 'airflow';
}
/**
@@ -136,9 +139,11 @@ function TowerModel({
phi,
alphaWind,
forceKN,
viewMode = 'solid',
}: Tower3DInput) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const isAirflow = viewMode === 'airflow';
const geometry = useMemo(
() => buildTowerGeometry(section, panels, baseWidth, height),
@@ -201,11 +206,32 @@ function TowerModel({
);
})}
{/* Seta de Vento única atingindo o meio da torre */}
<WindArrow
target={[0, height / 2, 0]}
direction={forceDir}
/>
{/* Seta de Vento */}
{isAirflow ? (
<group>
<WindArrow
target={[0, height / 2, -baseWidth * 1.5]}
direction={[0, 0, 1]}
scale={4}
/>
<Text
position={[0, height / 2 + 1.5, -baseWidth * 1.5 - 2]}
rotation={[0, Math.PI, 0]}
fontSize={1.2}
color={isDark ? '#60a5fa' : '#3b82f6'}
anchorX="center"
anchorY="bottom"
>
Vento
</Text>
<TowerAirflowSystem baseWidth={baseWidth} height={height} phi={phi} />
</group>
) : (
<WindArrow
target={[0, height / 2, 0]}
direction={forceDir}
/>
)}
{/* Labels indicativos */}
<mesh position={[baseWidth / 2 + 0.5, 0.5, 0]}>
@@ -214,6 +240,7 @@ function TowerModel({
</mesh>
<Text
position={[0, height + 1.0, 0]}
rotation={[0, Math.PI, 0]}
fontSize={0.5}
color={isDark ? "#93c5fd" : "#1e40af"}
anchorX="center"
@@ -228,7 +255,7 @@ function TowerModel({
export default function Tower3DViewer(input: Tower3DInput) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const { baseWidth, height } = input;
const { baseWidth, height, viewMode = 'solid' } = input;
const dist = Math.max(baseWidth * 3, height * 1.2);
const fallback = (
@@ -241,13 +268,14 @@ export default function Tower3DViewer(input: Tower3DInput) {
return (
<SceneCanvas moduleId="torre"
shadows
frameloop={viewMode === 'airflow' ? 'always' : 'demand'}
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [dist, height * 0.6, dist], fov: 45 }}
fallback={fallback}
>
<ambientLight intensity={0.6} />
<directionalLight position={[dist, height, dist]} intensity={1.2} castShadow shadow-mapSize-width={1024} shadow-mapSize-height={1024} />
<TowerModel {...input} />
<TowerModel {...input} viewMode={viewMode} />
<Grid
infiniteGrid
fadeDistance={height * 2}
@@ -0,0 +1,130 @@
import React, { useRef, useMemo, useEffect } from 'react';
import * as THREE from 'three';
import { useFrame } from '@react-three/fiber';
import { useCanvasTheme } from '@/lib/theme';
interface Particle {
position: THREE.Vector3;
baseSpeed: number;
wobbleSpeed: number;
wobbleOffset: number;
currentCpe: number;
}
interface TowerAirflowSystemProps {
baseWidth: number;
height: number;
phi: number;
}
export const TowerAirflowSystem: React.FC<TowerAirflowSystemProps> = ({
baseWidth,
height,
phi,
}) => {
const meshRef = useRef<THREE.InstancedMesh>(null);
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const COUNT = 100;
const dummy = useMemo(() => new THREE.Object3D(), []);
const colorObj = useMemo(() => new THREE.Color(), []);
const halfWidth = baseWidth / 2;
const particles = useMemo(() => {
const arr: Particle[] = [];
for (let i = 0; i < COUNT; i++) {
arr.push({
position: new THREE.Vector3(
(Math.random() - 0.5) * baseWidth * 3,
Math.random() * (height + 5),
-halfWidth - 10 - Math.random() * 20
),
baseSpeed: 0.075 + Math.random() * 0.05,
wobbleSpeed: 0.5 + Math.random() * 1.5,
wobbleOffset: Math.random() * Math.PI * 2,
currentCpe: 0,
});
}
return arr;
}, [baseWidth, height]);
useEffect(() => {
if (meshRef.current) {
for (let i = 0; i < COUNT; i++) {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6');
meshRef.current.setColorAt(i, colorObj);
}
meshRef.current.instanceColor!.needsUpdate = true;
}
}, [isDark, colorObj]);
useFrame((state) => {
if (!meshRef.current) return;
const time = state.clock.elapsedTime;
particles.forEach((p, i) => {
const px = p.position.x;
const py = p.position.y;
const pz = p.position.z;
let currentSpeed = p.baseSpeed;
let localCpe = 0;
let deflectX = 0;
const inX = Math.abs(px) < halfWidth + 2;
const inZ = Math.abs(pz) < halfWidth + 2;
if (inX && inZ && py < height) {
localCpe = 0.8;
// Se a torre é densa (phi alto), o ar tenta desviar pelas laterais
if (pz < 0) {
const dist = halfWidth - Math.abs(px);
if (dist > 0) {
deflectX += (px > 0 ? 1 : -1) * currentSpeed * phi * 0.5;
}
}
}
p.currentCpe += (localCpe - p.currentCpe) * 0.1;
p.position.z += currentSpeed;
p.position.x += deflectX;
p.position.y += Math.sin(time * p.wobbleSpeed * 10 + p.wobbleOffset) * 0.01;
if (p.position.z > halfWidth + 10) {
p.position.z = -halfWidth - 10 - Math.random() * 5;
p.position.y = Math.random() * (height + 5);
p.position.x = (Math.random() - 0.5) * baseWidth * 3;
p.currentCpe = 0;
}
dummy.position.copy(p.position);
dummy.rotation.set(0, 0, 0);
dummy.rotateX(Math.PI / 2);
dummy.scale.set(1, 1 + currentSpeed * 6, 1);
dummy.updateMatrix();
meshRef.current!.setMatrixAt(i, dummy.matrix);
if (p.currentCpe > 0) {
colorObj.set(isDark ? '#f87171' : '#ef4444');
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj.set(isDark ? '#c084fc' : '#a855f7').lerp(new THREE.Color(isDark ? '#7e22ce' : '#6b21a8'), intensity);
} else {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6');
}
meshRef.current!.setColorAt(i, colorObj);
});
meshRef.current.instanceMatrix.needsUpdate = true;
meshRef.current.instanceColor!.needsUpdate = true;
});
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, COUNT]}>
<cylinderGeometry args={[0.04, 0.04, 1.0, 4]} />
<meshBasicMaterial transparent opacity={0.6} depthWrite={false} />
</instancedMesh>
);
};
+47 -7
View File
@@ -5,6 +5,7 @@ import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
import { useCanvasTheme } from '@/lib/theme';
import { VaultAirflowSystem } from './VaultAirflowSystem';
export interface Vault3DInput {
span: number;
@@ -14,6 +15,7 @@ export interface Vault3DInput {
cpeProfile: Record<string, number>;
cpeParallel?: Record<string, number>;
viewDirection?: 'perpendicular' | 'parallel';
viewMode?: 'solid' | 'airflow';
}
function vaultColor(cpe: number, cpi: number, isDark: boolean): THREE.Color {
@@ -26,9 +28,10 @@ function vaultColor(cpe: number, cpi: number, isDark: boolean): THREE.Color {
return new THREE.Color(`hsl(0, ${70 + intensity * 25}%, ${Math.max(40, l + 5)}%)`);
}
function VaultModel({ span, length, rise, cpi, cpeProfile, cpeParallel, viewDirection = 'perpendicular' }: Vault3DInput) {
function VaultModel({ span, length, rise, cpi, cpeProfile, cpeParallel, viewDirection = 'perpendicular', viewMode = 'solid' }: Vault3DInput) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const isAirflow = viewMode === 'airflow';
const segments = 64;
const points = useMemo(() => {
@@ -92,7 +95,7 @@ function VaultModel({ span, length, rise, cpi, cpeProfile, cpeParallel, viewDire
<bufferAttribute attach="attributes-position" args={[new Float32Array(verts), 3]} />
<bufferAttribute attach="index" args={[new Uint16Array(indices), 1]} />
</bufferGeometry>
<meshStandardMaterial color={color} opacity={0.92} transparent side={THREE.DoubleSide} roughness={0.4} />
<meshStandardMaterial color={color} opacity={isAirflow ? 0.35 : 0.92} transparent side={THREE.DoubleSide} roughness={0.4} />
</mesh>
<Text
position={[midPt.x, midPt.y + 0.5, length / 2]}
@@ -139,7 +142,7 @@ function VaultModel({ span, length, rise, cpi, cpeProfile, cpeParallel, viewDire
<bufferAttribute attach="attributes-position" args={[new Float32Array(verts), 3]} />
<bufferAttribute attach="index" args={[new Uint16Array(indices), 1]} />
</bufferGeometry>
<meshStandardMaterial color={color} opacity={0.92} transparent side={THREE.DoubleSide} roughness={0.4} />
<meshStandardMaterial color={color} opacity={isAirflow ? 0.35 : 0.92} transparent side={THREE.DoubleSide} roughness={0.4} />
</mesh>
<Text
position={[0, rise + 0.5, (zStart + zEnd) / 2]}
@@ -163,15 +166,39 @@ function VaultModel({ span, length, rise, cpi, cpeProfile, cpeParallel, viewDire
{/* Tímpano Traseiro (Z = 0) */}
<mesh position={[0, 0, 0]} castShadow receiveShadow>
<shapeGeometry args={[archShape]} />
<meshStandardMaterial color={isDark ? "#334155" : "#cbd5e1"} opacity={0.7} transparent side={THREE.DoubleSide} roughness={0.5} />
<meshStandardMaterial color={isDark ? "#334155" : "#cbd5e1"} opacity={isAirflow ? 0.25 : 0.7} transparent side={THREE.DoubleSide} roughness={0.5} />
</mesh>
{/* Tímpano Frontal (Z = length) */}
<mesh position={[0, 0, length]} castShadow receiveShadow>
<shapeGeometry args={[archShape]} />
<meshStandardMaterial color={isDark ? "#334155" : "#cbd5e1"} opacity={0.7} transparent side={THREE.DoubleSide} roughness={0.5} />
<meshStandardMaterial color={isDark ? "#334155" : "#cbd5e1"} opacity={isAirflow ? 0.25 : 0.7} transparent side={THREE.DoubleSide} roughness={0.5} />
</mesh>
{/* Seta indicativa de direção do vento */}
<group position={[-span / 2 - 2.5, rise / 2, length / 2]} rotation={[0, 0, -Math.PI / 2]}>
<mesh castShadow>
<coneGeometry args={[0.3, 0.8, 16]} />
<meshStandardMaterial color="#3b82f6" roughness={0.3} />
</mesh>
<mesh position={[0, -0.6, 0]} castShadow>
<cylinderGeometry args={[0.1, 0.1, 1.2, 16]} />
<meshStandardMaterial color="#3b82f6" roughness={0.3} />
</mesh>
</group>
{/* Texto Vento */}
<Text
position={[-span / 2 - 2.5, rise / 2 + 1.2, length / 2]}
rotation={[0, 0, 0]}
fontSize={1.2}
color="#3b82f6"
anchorX="center"
anchorY="bottom"
>
Vento
</Text>
{/* Rótulo de dimensões */}
<Text
position={[0, rise + 1.5, length / 2]}
@@ -186,7 +213,7 @@ function VaultModel({ span, length, rise, cpi, cpeProfile, cpeParallel, viewDire
);
}
export default function Vault3DViewer({ span, length, rise, cpi, cpeProfile, cpeParallel, viewDirection = 'perpendicular' }: Vault3DInput) {
export default function Vault3DViewer({ span, length, rise, cpi, cpeProfile, cpeParallel, viewDirection = 'perpendicular', viewMode = 'solid' }: Vault3DInput) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
@@ -202,6 +229,7 @@ export default function Vault3DViewer({ span, length, rise, cpi, cpeProfile, cpe
return (
<SceneCanvas moduleId="abobada"
shadows
frameloop={viewMode === 'airflow' ? 'always' : 'demand'}
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [span * 1.2, rise * 1.5, length * 1.2], fov: 40 }}
fallback={fallback}
@@ -214,7 +242,19 @@ export default function Vault3DViewer({ span, length, rise, cpi, cpeProfile, cpe
shadow-mapSize-width={1024}
shadow-mapSize-height={1024}
/>
<VaultModel span={span} length={length} rise={rise} cpi={cpi} cpeProfile={cpeProfile} cpeParallel={cpeParallel} viewDirection={viewDirection} />
<VaultModel
span={span}
length={length}
rise={rise}
cpi={cpi}
cpeProfile={cpeProfile}
cpeParallel={cpeParallel}
viewDirection={viewDirection}
viewMode={viewMode}
/>
{viewMode === 'airflow' && (
<VaultAirflowSystem span={span} length={length} rise={rise} viewDirection={viewDirection} />
)}
<Grid
infiniteGrid
fadeDistance={maxDimension * 5}
@@ -0,0 +1,203 @@
import { useRef, useMemo, useEffect } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { useCanvasTheme } from '../../lib/theme';
export interface VaultAirflowSystemProps {
span: number;
length: number;
rise: number;
viewDirection?: 'perpendicular' | 'parallel';
}
export function VaultAirflowSystem({ span, length, rise, viewDirection = 'perpendicular' }: VaultAirflowSystemProps) {
const isDark = useCanvasTheme() === 'dark';
const count = 400;
const meshRef = useRef<THREE.InstancedMesh>(null);
const particles = useMemo(() => {
const temp = [];
const limitX = Math.max(20, span * 2.5);
const limitZ = Math.max(20, length * 1.5);
for (let i = 0; i < count; i++) {
temp.push({
position: new THREE.Vector3(
(Math.random() - 0.5) * limitX * 2,
Math.random() * (rise * 2.5),
(Math.random() - 0.5) * limitZ * 2
),
baseSpeed: 0.12 + Math.random() * 0.08,
wobbleSpeed: Math.random() * 0.05,
wobbleOffset: Math.random() * Math.PI * 2,
currentCpe: 0,
});
}
return temp;
}, [count, span, length, rise]);
const dummy = useMemo(() => new THREE.Object3D(), []);
const colorObj = useMemo(() => new THREE.Color(), []);
useEffect(() => {
if (meshRef.current) {
for (let i = 0; i < count; i++) {
meshRef.current.setColorAt(i, new THREE.Color('#ffffff'));
}
if (meshRef.current.instanceColor) {
meshRef.current.instanceColor.needsUpdate = true;
}
}
}, [count]);
useFrame((state) => {
if (!meshRef.current) return;
const time = state.clock.elapsedTime;
const limitX = Math.max(20, span * 2.5);
const limitZ = Math.max(20, length * 1.5);
const isPerp = viewDirection === 'perpendicular';
particles.forEach((p, i) => {
let deflectX = isPerp ? 1.0 : 0.0;
let deflectY = 0.0;
let deflectZ = isPerp ? 0.0 : 1.0;
const px = p.position.x;
const py = p.position.y;
const pz = p.position.z;
// Altura teórica do arco da abóboda na coordenada X do ponto
const inSpan = px >= -span / 2 && px <= span / 2;
const inLength = pz >= 0 && pz <= length; // Lembrar que no Vault3D o modelo é desenhado de Z=0 a Z=length
const archY = inSpan ? rise * Math.sin(((px + span / 2) / span) * Math.PI) : 0;
if (isPerp) {
// Vento perpendicular (ao longo do eixo X)
if (inLength) {
if (px >= -span / 2 - 2 && px <= span / 2 + 2) {
// Deflexão ao se aproximar e passar pela abóboda
const theta = ((Math.max(-span / 2, Math.min(span / 2, px)) + span / 2) / span) * Math.PI;
const slope = (rise / span) * Math.PI * Math.cos(theta);
// A velocidade vertical induzida acompanha a inclinação do arco
if (py < archY + 3.0) {
deflectY = slope * 0.8;
}
// Evita colisão dura (ficar abaixo do arco)
if (py < archY + 0.3) {
p.position.y = archY + 0.3;
}
}
// Zona de turbulência/esteira atrás da abóboda (X > span/2)
if (px > span / 2 && px < span / 2 + 10) {
deflectY -= 0.15;
deflectZ += Math.sin(time * 5 + px * 0.5 + p.wobbleOffset) * 0.2;
}
}
// Mapear cor com base na posição
if (inLength && inSpan && py < archY + 2.0) {
if (px < -span * 0.15) {
p.currentCpe = 0.7; // Compressão a barlavento
} else if (px >= -span * 0.15 && px <= span * 0.15) {
p.currentCpe = -0.9; // Forte sucção no topo
} else if (px > span / 4) {
p.currentCpe = 0.5; // Sotavento / Esteira (agora positivo para ficar vermelho)
} else {
p.currentCpe = -0.5; // Sucção a sotavento
}
} else {
p.currentCpe = 0;
}
} else {
// Vento paralelo (ao longo do eixo Z)
if (inSpan) {
// Se estiver muito baixo (abaixo da abóboda), empurra para cima da casca
if (py < archY + 0.3) {
p.position.y = archY + 0.3;
}
// Deflexão nas bordas frontais/traseiras (Z=0 e Z=length)
if (pz < -2 && pz > -8) {
const dist = (-pz);
const lift = Math.max(0, archY - py) / (dist + 1);
deflectY += lift * 0.4;
}
}
// Mapear cor para vento paralelo (normalmente sucção em toda a extensão)
if (inSpan && inLength && py < archY + 2.0) {
// Zonas A, B, C, D longitudinais
const zRatio = pz / length;
if (zRatio < 0.25) p.currentCpe = -0.8; // Entrada
else if (zRatio < 0.5) p.currentCpe = -0.6;
else p.currentCpe = -0.4;
} else {
p.currentCpe = 0;
}
}
// Suaviza a velocidade
const moveVec = new THREE.Vector3(deflectX, deflectY, deflectZ).normalize();
const currentSpeed = p.baseSpeed * (isPerp ? Math.max(0.4, 1 - Math.abs(deflectY) * 0.2) : 1.0);
p.position.addScaledVector(moveVec, currentSpeed);
// Reposicionar partículas fora do limite
if (isPerp) {
if (p.position.x > limitX) {
p.position.x = -limitX;
p.position.y = Math.random() * (rise * 2.5);
p.position.z = (Math.random() - 0.5) * limitZ * 2;
p.currentCpe = 0;
}
} else {
if (p.position.z > limitZ) {
p.position.z = -limitZ;
p.position.y = Math.random() * (rise * 2.5);
p.position.x = (Math.random() - 0.5) * limitX * 2;
p.currentCpe = 0;
}
}
dummy.position.copy(p.position);
dummy.lookAt(p.position.clone().add(moveVec));
dummy.rotateX(Math.PI / 2);
dummy.scale.set(1, 1 + currentSpeed * 6, 1);
dummy.updateMatrix();
meshRef.current!.setMatrixAt(i, dummy.matrix);
// Colorir
if (p.currentCpe > 0) {
colorObj.set(isDark ? '#f87171' : '#ef4444'); // Vermelho (Turbulento/Pressão)
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj.set(isDark ? '#c084fc' : '#a855f7').lerp(new THREE.Color(isDark ? '#7e22ce' : '#6b21a8'), intensity); // Roxo (Acelerado/Sucção)
} else {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6'); // Azul (Corrente livre)
}
meshRef.current!.setColorAt(i, colorObj);
});
meshRef.current.instanceMatrix.needsUpdate = true;
if (meshRef.current.instanceColor) {
meshRef.current.instanceColor.needsUpdate = true;
}
});
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
<cylinderGeometry args={[0.01, 0.01, 1.5, 4]} />
<meshBasicMaterial
color="#ffffff"
transparent
opacity={isDark ? 0.6 : 0.8}
blending={isDark ? THREE.AdditiveBlending : THREE.NormalBlending}
depthWrite={false}
/>
</instancedMesh>
);
}
+7 -4
View File
@@ -8,11 +8,14 @@ export interface WindArrowProps {
direction: THREE.Vector3 | [number, number, number];
/** Fator de escala da seta (default: 1) */
scale?: number;
/** Cor da seta */
color?: string;
/** Opacidade da seta (0 a 1) */
opacity?: number;
}
export function WindArrow({ target, direction, scale = 1 }: WindArrowProps) {
export function WindArrow({ target, direction, scale = 1, color = '#3b82f6', opacity = 1 }: WindArrowProps) {
const length = 2.5 * scale; // Tamanho visual fixo
const color = '#3b82f6'; // Azul padrão do vento
const targetVec = target instanceof THREE.Vector3 ? target : new THREE.Vector3(...target);
const dirVec = direction instanceof THREE.Vector3 ? direction : new THREE.Vector3(...direction);
@@ -46,13 +49,13 @@ export function WindArrow({ target, direction, scale = 1 }: WindArrowProps) {
{shaftLength > 0 && (
<mesh position={midPoint.toArray()} rotation={rotation} castShadow>
<cylinderGeometry args={[shaftRadius, shaftRadius, shaftLength, 12]} />
<meshStandardMaterial color={color} />
<meshStandardMaterial color={color} transparent={opacity < 1} opacity={opacity} />
</mesh>
)}
{/* Ponta da seta (cone) */}
<mesh position={[end.x, end.y, end.z]} rotation={rotation} castShadow>
<coneGeometry args={[headRadius, headLen, 12]} />
<meshStandardMaterial color={color} />
<meshStandardMaterial color={color} transparent={opacity < 1} opacity={opacity} />
</mesh>
</group>
);
+34
View File
@@ -0,0 +1,34 @@
import { create } from 'zustand';
interface PersistentStore {
data: Record<string, any>;
setValue: (key: string, value: any) => void;
}
export const usePersistentStore = create<PersistentStore>((set) => ({
data: {},
setValue: (key, value) => set((state) => ({ data: { ...state.data, [key]: value } })),
}));
/**
* Hook para substituir o useState e manter o valor salvo globalmente na memória (Zustand)
* durante a navegação entre os componentes.
*/
export function usePersistentState<T>(key: string, initialValue: T): [T, (val: T | ((prev: T) => T)) => void] {
const storeValue = usePersistentStore((s) => s.data[key]);
const setValue = usePersistentStore((s) => s.setValue);
// Como o store não tem o valor inicial no momento da criação, precisamos lidar com isso.
// Se o valor estiver no store, usamos ele. Senão, usamos initialValue.
const value = storeValue !== undefined ? storeValue : initialValue;
const setter = (newVal: T | ((prev: T) => T)) => {
if (typeof newVal === 'function') {
setValue(key, (newVal as any)(value));
} else {
setValue(key, newVal);
}
};
return [value, setter];
}
+210
View File
@@ -17,6 +17,13 @@ import { evaluateComfort } from '../comfort';
import { classifyBridge, type BridgeClassificationInput } from '../modules/bridge';
import { calculateTower } from '../modules/tower';
import { calculatePiperack, type PiperackInput } from '../modules/piperack';
import { calculateVault, type VaultInput } from '../modules/vault';
import { calculateDome, type DomeInput } from '../modules/dome';
import { calculateIsolatedGableRoof, calculateIsolatedShedRoof } from '../nbr-tables/table-24-25';
import { calculateFlatBarForce, type FlatBarForceInput } from '../nbr-tables/table-26';
import { applyRoughnessChange } from '../wind-direction';
import { TABLE_1 } from '../nbr-tables/table-1';
import { getStrouhalNumber, criticalVelocity, scrutonNumber } from '../nbr-tables/table-33';
function calcWind(
v0: number, s1: number, s3: number,
@@ -267,6 +274,209 @@ export function generateAllScenarios(): AuditScenario[] {
));
}
// --- NOVOS CENÁRIOS BIBLIOGRÁFICOS EXPANDIDOS (LITERATURA & NBR 6123) ---
// 1.1 Abóbada Cilíndrica (Blessmann / Tabela 15 NBR 6123)
{
const vaultIn: VaultInput = { f: 4, l: 20, b: 40, vk: 35, regime: 'laminar-rough', cpi: 0 };
const vaultRes = calculateVault(vaultIn);
scenarios.push(makeScenario(
'blessmann-abobada-f4-l20', 'vault', 'Blessmann: Abóbada Cilíndrica f=4m l=20m b=40m',
{ ...vaultIn },
{ q: vaultRes.q, fOverL: 4 / 20 },
{ ...vaultRes.windPerpendicular, q: vaultRes.q },
{ q: { min: 0.5, max: 1.5 } },
'vault',
{ f: 4, l: 20, b: 40, cpeValues: vaultRes.windPerpendicular },
'Sec. 6.2.3 / Tabela 15',
true,
'Blessmann, J. Ação do Vento em Coberturas Curvas e Abóbadas, Exemplo 3.2 / NBR 6123:2023 Tabela 15',
'Abóbada cilíndrica com vão de 20m, flecha de 4m (f/l = 0,20) e comprimento de 40m sob vento perpendicular de 35 m/s. Avaliação da sucção no topo e pressões no bordo de ataque.'
));
}
// 1.2 Cúpula Semiesférica apoiada no solo (Rebello / Tabela 21 NBR 6123)
{
const domeIn: DomeInput = { d: 30, f: 6, vk: 40, type: 'on-ground', cpi: 0 };
const domeRes = calculateDome(domeIn);
scenarios.push(makeScenario(
'rebello-cupula-d30-f6', 'dome', 'Rebello: Cúpula no Solo d=30m f=6m',
{ ...domeIn },
{ q: domeRes.q, fOverD: domeRes.fOverD },
{ cpeBarlavento: domeRes.cpeBarlavento, cpeTopo: domeRes.cpeTopo, liftCoefficient: domeRes.liftCoefficient, liftForceKN: domeRes.liftForceKN },
{ q: { min: 0.5, max: 1.8 }, liftForceKN: { min: 50, max: 1000 } },
'dome',
{ d: 30, f: 6, cpeTopo: domeRes.cpeTopo, cpeBarlavento: domeRes.cpeBarlavento },
'Sec. 6.2.4 / Tabela 21',
true,
'Rebello, Y. A Concepção Estrutural e a Arquitetura / NBR 6123 Tabela 21 (Cúpulas sobre o solo)',
'Cúpula esférica implantada diretamente no terreno com diâmetro d=30m e flecha f=6m (f/d = 0,20). Determinação da força global de sustentação aerodinâmica (empuxo ascendente) para velocidade característica de 40 m/s.'
));
}
// 1.3 Cobertura Isolada a Duas Águas - Hangar Aberto (Pfeil / Tabela 25 NBR 6123)
{
const isoIn = { theta: 10, height: 5, depth: 15 };
const isoRes = calculateIsolatedGableRoof(isoIn);
scenarios.push(makeScenario(
'pfeil-cobertura-isolada-2aguas', 'isolated-roof', 'Pfeil: Cobertura Isolada 2 Águas θ=10°',
{ ...isoIn, q: 0.85 },
{ cpb1: isoRes.cpb.cpb1, cpa1: isoRes.cpa.cpa1 },
{ cpb1: isoRes.cpb.cpb1, cpb2: isoRes.cpb.cpb2, cpa1: isoRes.cpa.cpa1, cpa2: isoRes.cpa.cpa2, applies: isoRes.applies ? 1 : 0 },
{ cpb1: { min: 0.5, max: 2.0 }, cpa1: { min: -2.0, max: -0.5 } },
'isolatedRoof',
{ theta: 10, height: 5, depth: 15, cpa: isoRes.cpa, cpb: isoRes.cpb },
'Sec. 7.2 / Tabela 25',
true,
'Pfeil & Pfeil, Estruturas de Aço, 8ª ed., Cap. 5 (Galpões Abertos e Hangares Isolados) / NBR 6123 Tabela 25',
'Cobertura isolada simétrica de duas águas com inclinação de 10° e pé-direito livre h=5m (b=15m). Análise dos coeficientes de pressão líquida nos carregamentos extremos de sobrepressão e sucção.'
));
}
// 1.4 Cobertura Isolada a Uma Água - Marquise / Abrigo (NBR 6123 Tabela 24)
{
const shedIn = { theta: 5, height: 3.5, depth: 10 };
const shedRes = calculateIsolatedShedRoof(shedIn);
scenarios.push(makeScenario(
'nbr-marquise-isolada-1agua', 'isolated-roof', 'NBR 6123: Marquise Isolada a Uma Água θ=5°',
{ ...shedIn, q: 0.95 },
{ cph1High: shedRes.cph1.high, cph1Low: shedRes.cph1.low },
{ cph1High: shedRes.cph1.high, cph1Low: shedRes.cph1.low, applies: shedRes.applies ? 1 : 0 },
{ cph1High: { min: -1.5, max: 0.0 }, cph1Low: { min: -2.5, max: -0.5 } },
'isolatedRoof',
{ theta: 5, height: 3.5, depth: 10 },
'Sec. 7.2.1 / Tabela 24',
true,
'NBR 6123:2023, Tabela 24 — Coberturas isoladas a uma água plana / Exemplo Didático CBCA',
'Marquise/alpendre isolado a 1 água com inclinação suave de 5° e altura de 3.5m. Verificação dos coeficientes médios e limites de sucção no bordo de ataque.'
));
}
// 1.5 Torre Treliçada Triangular de Telecomunicações (NBR 6123 Tabela 29/30 & Blessmann)
{
const towerIn = { section: 'triangular' as const, barType: 'flat' as const, phi: 0.25, aFace: 90, alphaWind: 0 as const, q: 1.15 };
const towerRes = calculateTower(towerIn);
scenarios.push(makeScenario(
'blessmann-torre-triangular-phi025', 'tower', 'Blessmann: Torre Triangular φ=0.25',
{ ...towerIn },
{ caEff: towerRes.caEff },
{ caEff: towerRes.caEff, forceKN: towerRes.forceKN },
{ caEff: { min: 1.5, max: 3.5 }, forceKN: { min: 100, max: 500 } },
'tower',
{ section: 'triangular', baseWidth: 3, height: 30, panels: 6, phi: 0.25, alphaWind: 0, forceKN: towerRes.forceKN },
'Sec. 8.5 / Tabelas 29-30',
true,
'Blessmann, J. Ação do Vento em Torres e Estruturas Treliçadas, Cap. 7 / NBR 6123:2023 Sec. 8.5',
'Torre treliçada de seção triangular equilátera com índice de plenitude φ=0,25 e área projetada da face de 90 m² submetida a vento frontal (0°). Cálculo do coeficiente de arrasto efetivo e da força global na torre.'
));
}
// 1.6 Barra Isolada Perfil L / Cantoneira de Torre (Pfeil & NBR 6123 Tabela 26)
{
const barIn: FlatBarForceInput = { section: 'l', alpha: 45, width: 0.15, length: 3.0, q: 1.05 };
const barRes = calculateFlatBarForce(barIn);
scenarios.push(makeScenario(
'pfeil-cantoneira-l-alpha45', 'bar', 'Pfeil: Cantoneira L150x150 sob Vento a 45°',
{ ...barIn },
{ cx: barRes.cx, cy: barRes.cy, kFactor: barRes.kFactor },
{ fxKN: barRes.fxKN, fyKN: barRes.fyKN, cx: barRes.cx, cy: barRes.cy },
{ cx: { min: 1.0, max: 2.2 }, cy: { min: 1.0, max: 2.2 }, fxKN: { min: 0.5, max: 2.0 } },
'bar',
{ section: 'l', width: 0.15, length: 3.0, alpha: 45, fxKN: barRes.fxKN, fyKN: barRes.fyKN },
'Sec. 8.1 / Tabelas 26 e 28',
true,
'Pfeil, M. & Pfeil, W. Estruturas de Aço, Cap. Ações em Barras Isoladas / NBR 6123 Tabela 26',
'Barra prismática isolada de seção cantoneira de abas iguais (L 150x150 mm) com 3 metros de comprimento livre, sob incidência oblíqua do vento a 45°. Determinação das componentes de força Cx, Cy e redução por comprimento finito (Fator K).'
));
}
// 1.7 Galpão Extenso — Força de Atrito Superficial (Blessmann Cap. 5 / NBR 6123 Sec. 6.1.5)
{
const fricIn = { roughness: 'rounded-ribs' as const, length: 80, width: 15, height: 6, roofPitch: 8, q: 1.0, windAngle: 0 as const };
const fricRes = calculateFriction(fricIn);
scenarios.push(makeScenario(
'blessmann-galpao-extenso-atrito', 'galpao', 'Blessmann: Galpão Extenso 80x15m — Atrito Superficial',
{ ...fricIn },
{ applies: fricRes.applies ? 1 : 0, ratioLh: 80 / 6 },
{ applies: fricRes.applies ? 1 : 0, forceKN: fricRes.forceKN, roofArea: fricRes.roofArea, cf: fricRes.cf },
{ forceKN: { min: 10, max: 50 }, roofArea: { min: 1000, max: 1500 } },
'warehouse',
{ width: 15, length: 80, height: 6, roofPitch: 8, windAngle: 0, cpi: 0 },
'Sec. 6.1.5',
true,
'Blessmann, J. O Vento na Engenharia Estrutural, Cap. 5 (Forças de Atrito em Edificações Longas) / NBR 6123 Sec. 6.1.5',
'Galpão industrial longo de 80 metros de comprimento por 15m de largura e 6m de altura (l/h = 13,3 > 4). Telhas com nervuras onduladas/arredondadas (Cf = 0,02). Cálculo da força de arrasto por atrito superficial na cobertura.'
));
}
// 1.8 Transição de Rugosidade de Terreno — Litoral para Centro Urbano (NBR 6123 Sec. 5.5)
{
const transIn = { near: 'IV' as TerrainCategory, far: 'I' as TerrainCategory, distance: 500, height: 30 };
const params = new Map<TerrainCategory, { b: number; p: number; fr: number }>();
(['I', 'II', 'III', 'IV', 'V'] as TerrainCategory[]).forEach(cat => {
params.set(cat, TABLE_1[cat].B);
});
const transRes = applyRoughnessChange(transIn, params);
scenarios.push(makeScenario(
'nbr-transicao-rugosidade-catI-IV', 'galpao', 'NBR 6123: Transição de Rugosidade Cat I -> Cat IV (x=500m, z=30m)',
{ ...transIn },
{ z1: transRes.z1, z2: transRes.z2, s2: transRes.s2 },
{ s2: transRes.s2, z1: transRes.z1 },
{ s2: { min: 0.8, max: 1.3 } },
'warehouse',
{ width: 20, length: 40, height: 30, roofPitch: 0, windAngle: 0, cpi: 0 },
'Sec. 5.5',
true,
'NBR 6123:2023, Seção 5.5 (Mudança de Rugosidade do Terreno) / Notas de Aula Prof. Blessmann',
'Edificação de 30m de altura situada a 500 metros da linha de transição costeira (saída de Terreno Aberto Cat. I para Zona Urbana Densa Cat. IV). Determinação da altura da camada limite interna (z1) e do fator S2 interpolado.'
));
}
// 1.9 Desprendimento de Vórtices em Chaminé Esbelta (Strouhal & Scruton — NBR 6123 Sec. 10)
{
const st = getStrouhalNumber('circle', 0); // 0.20
const fn = 1.2; // Hz
const d = 1.5; // m
const vcr = criticalVelocity(fn, d, st); // 1.2 * 1.5 / 0.20 = 9.0 m/s
const sc = scrutonNumber(0.02, 600, 1.225, 1.5); // 0.02 * 600 / (1.225 * 1.5) = 6.53
scenarios.push(makeScenario(
'nbr-vortices-chamine-strouhal', 'dynamics', 'NBR 6123: Vórtices de Von Kármán em Chaminé Cilíndrica',
{ shape: 'circle', diameter: d, naturalFreq: fn, massPerLength: 600, dampingRatio: 0.02 },
{ st, vcr, sc },
{ st, vcr, sc, isSusceptible: sc < 20 ? 1 : 0 },
{ vcr: { min: 5, max: 20 }, sc: { min: 2, max: 20 } },
'dynamics',
{ height: 45, freq: fn, windSpeed: vcr, scruton: sc, sectionShape: 'circle', sectionSize: d },
'Sec. 10 / Tabela 33',
true,
'NBR 6123:2023, Seção 10 (Efeitos Dinâmicos de Desprendimento de Vórtices) & Simiu & Scanlan',
'Chaminé metálica esbelta circular com d=1.5m, frequência natural fundamental fn=1.2 Hz, massa linear de 600 kg/m e amortecimento estrutural ξ=0,02. Cálculo do Número de Strouhal (St=0,20), velocidade crítica de ressonância (Vcr) e Número de Scruton (Sc).'
));
}
// 1.10 Pipe-Rack Industrial com Múltiplos Planos (NBR 6123 Sec. 8.4 / Blessmann)
{
const pipeIn: PiperackInput = {
width: 12, height: 5, elevation: 8, spacing: 6, numFrames: 4, phiStruct: 0.20,
pipes: [{ id: 'p1', diameter: 0.6, elevationOffset: 5 }, { id: 'p2', diameter: 0.6, elevationOffset: 5 }],
q: 1.10
};
const pipeRes = calculatePiperack(pipeIn);
scenarios.push(makeScenario(
'blessmann-piperack-4quadros', 'piperack', 'Blessmann: Pipe-Rack Industrial 4 Quadros com Tubulações',
{ ...pipeIn },
{ phiTotal: pipeRes.phiTotal, effectiveElevation: pipeRes.effectiveElevation },
{ caFrontal: pipeRes.caFrontal, eta: pipeRes.eta, globalForce: pipeRes.globalForce },
{ caFrontal: { min: 1.5, max: 3.0 }, eta: { min: 0.4, max: 1.0 }, globalForce: { min: 10, max: 300 } },
'warehouse',
{ width: 12, height: 5, length: 24 },
'Sec. 8.4',
true,
'Blessmann, J. Ação do Vento em Estruturas Industriais e Reticulados / NBR 6123:2023 Sec. 8.4',
'Pipe-rack industrial contendo 4 pórticos transversais espaçados de 6m com bandejas de cabos e 2 linhas de tubulação de 600mm de diâmetro. Cálculo do índice de plenitude total (φ), fator de sombreamento (η) e carga de arrasto global.'
));
}
// 2. GALPÃO VARREDURA
const galpaoGeos = [
{ w: 10, l: 20, h: 4 }, { w: 15, l: 30, h: 6 }, { w: 20, l: 50, h: 8 },
+51
View File
@@ -0,0 +1,51 @@
/**
* Módulo de Combinações de Ações (NBR 8681 / NBR 6118)
* Simplificado para uso prático com o vento.
*/
export interface LoadInputs {
/** Peso Próprio (Permanente) em kN/m ou kN/m² */
pp: number;
/** Sobrecarga (Acidental) em kN/m ou kN/m² */
sc: number;
/** Carga de Vento (Acidental) em kN/m ou kN/m² */
w: number;
}
/**
* Combinação ELU (Estado Limite Último) Normal
* Vento Principal: 1.4*PP + 1.4*W + 1.4*0.5*SC
* Sobrecarga Principal: 1.4*PP + 1.4*SC + 1.4*0.6*W
*/
export function calcELU(loads: LoadInputs): number {
const { pp, sc, w } = loads;
const g = 1.4 * pp;
// Vento como ação variável principal
const comb1 = g + (1.4 * w) + (1.4 * 0.5 * sc);
// Sobrecarga como ação variável principal
const comb2 = g + (1.4 * sc) + (1.4 * 0.6 * w);
return Math.max(Math.abs(comb1), Math.abs(comb2)) * Math.sign(w || pp || 1); // preserva direção da dominante
}
/**
* Combinação ELU Fiel (Arrancamento)
* Peso Próprio é favorável (reduz a chance de voar).
* Vento é desfavorável (puxa para cima, negativo).
* PP = 1.0, W = 1.4
*/
export function calcELUArrancamento(pp: number, w: number): number {
// pp é positivo para baixo. w é negativo para cima (sucção).
return (1.0 * pp) + (1.4 * w);
}
/**
* Combinação ELS (Estado Limite de Serviço) - Frequente
* PP + 0.4*SC + 0.3*W
*/
export function calcELS(loads: LoadInputs): number {
const { pp, sc, w } = loads;
return pp + (0.4 * sc) + (0.3 * w);
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Utilitários para Análise Dinâmica Simplificada (NBR 6123 - Anexo Dinâmico)
*/
/**
* Retorna verdadeiro se a frequência natural (Hz) indica que a estrutura
* é excessivamente flexível e exige cálculo pelo modelo dinâmico rigoroso.
* Uma regra de ouro da engenharia de vento brasileira é que f1 < 1Hz
* requer verificação atenciosa aos efeitos dinâmicos (rajadas e vórtices).
*/
export function isResonantRisk(f1: number): boolean {
return f1 > 0 && f1 < 1.0;
}
+16
View File
@@ -102,6 +102,22 @@ export function calculateIsolatedGableRoof(input: IsolatedGableRoofInput): Isola
};
}
/**
* Cobertura isolada em V (Borboleta).
* Extrapolação baseada na inversão geométrica das duas águas.
* A NBR 6123 não possui tabela explícita, portanto applies = false.
*/
export function calculateIsolatedButterflyRoof(input: IsolatedGableRoofInput): IsolatedGableRoofResult {
const base = calculateIsolatedGableRoof(input);
return {
// Barlavento (descendo): forte sucção no bordo de ataque
cpb: { cpb1: -Math.abs(base.cpa.cpa1), cpb2: -Math.abs(base.cpa.cpa2) },
// Sotavento (subindo): atua como uma barreira, sofrendo pressão
cpa: { cpa1: Math.abs(base.cpb.cpb1), cpa2: Math.abs(base.cpb.cpb2) },
applies: false,
};
}
/** Força de atrito na cobertura isolada: F = 0,05 · q · a · b (sec. 7.2.2) */
export function frictionForceIsolatedRoof(q: number, a: number, b: number): number {
return Number((0.05 * q * a * b).toFixed(3));
+63 -12
View File
@@ -29,6 +29,8 @@ export interface ShelterInput {
rightClosure: number;
/** Posição da abertura na parede de fundo ('top' | 'bottom' | 'distributed') */
openingPos: OpeningPosition;
/** Número de pórticos principais/pilares contínuos (padrão: 2) */
numColumns?: number;
/** Ângulo de incidência do vento (0° = frontal, 45° = oblíquo, 90° = lateral) */
windAngle?: ShelterWindAngle;
}
@@ -38,10 +40,14 @@ export interface ShelterResult {
windAngle: ShelterWindAngle;
/** Coeficiente de pressão superior da cobertura (Ce superior) */
cpeTop: number;
/** Coeficiente de pressão superior EXTREMO na zona de borda livre (Ce borda) */
cpeEdgeTop: number;
/** Coeficiente de pressão interna efetivo sob a cobertura (Cpi inferior) */
cpiBottom: number;
/** Coeficiente de força líquida vertical na cobertura (Cnf = cpeTop - cpiBottom) */
/** Coeficiente de força líquida vertical média na cobertura (Cnf = cpeTop - cpiBottom) */
cnfVertical: number;
/** Coeficiente de força líquida vertical de PICO na zona de borda livre */
cnfEdgeVertical: number;
/** Coeficiente de força horizontal na parede de fundo */
cfBack: number;
/** Coeficiente de força horizontal nas paredes laterais */
@@ -61,10 +67,18 @@ export interface ShelterResult {
};
/** Carga linear estimada nos pórticos metálicos principais (kN/m) (ex: Tubo 150x150) */
lineLoads: {
/** Carga horizontal em cada pilar (kN/m) */
/** Carga horizontal no pilar do PÓRTICO CENTRAL (mais carregado) (kN/m) */
columnLoad: number;
/** Carga vertical/horizontal na viga em balanço (kN/m) */
/** Carga horizontal no pilar do PÓRTICO DE EXTREMIDADE (kN/m) */
columnLoadEdge: number;
/** Carga vertical/horizontal média equivalente na viga do PÓRTICO CENTRAL (kN/m) */
beamLoad: number;
/** Carga vertical/horizontal média equivalente na viga do PÓRTICO DE EXTREMIDADE (kN/m) */
beamLoadEdge: number;
/** Momento fletor na raiz/engaste do balanço CENTRAL (kN.m) considerando o Efeito Asa de Avião */
beamMoment: number;
/** Momento fletor na raiz/engaste do balanço na EXTREMIDADE (kN.m) */
beamMomentEdge: number;
};
/** Alívio percentual de arrancamento obtido pela posição da abertura (%) */
reliefPercentage: number;
@@ -98,6 +112,7 @@ export function calculateShelterWind(input: ShelterInput, q: number): ShelterRes
leftClosure,
rightClosure,
openingPos,
numColumns = 2,
} = input;
const windAngle: ShelterWindAngle = input.windAngle ?? 0;
@@ -115,15 +130,19 @@ export function calculateShelterWind(input: ShelterInput, q: number): ShelterRes
// 1. Coeficiente externo superior na cobertura (Ce superior)
// Varia conforme o ângulo de incidência (0°, 45° ou 90°)
let cpeTop = -1.0;
let cpeEdgeTop = -2.0;
if (windAngle === 0) {
// Frontal: -1.0 a -1.4 de sucção de barlavento, dependendo de theta
cpeTop = theta <= 5 ? -1.0 : theta <= 15 ? -1.2 : -1.4;
cpeEdgeTop = theta <= 5 ? -1.8 : theta <= 15 ? -2.0 : -2.4;
} else if (windAngle === 45) {
// Oblíquo 45°: forte vórtice cônico de canto nas bordas de barlavento (aumento de sucção em ~20% a 30%)
cpeTop = theta <= 5 ? -1.3 : theta <= 15 ? -1.4 : -1.6;
cpeEdgeTop = theta <= 5 ? -2.2 : theta <= 15 ? -2.5 : -2.8;
} else {
// Lateral 90°: escoamento transversal sobre a cobertura, sucção constante por descolamento
cpeTop = -1.1;
cpeEdgeTop = -1.6;
}
// 2. Cálculo do Cpi sob a cobertura (efeito estagnação da parede traseira / laterais)
@@ -189,6 +208,7 @@ export function calculateShelterWind(input: ShelterInput, q: number): ShelterRes
// Cnf vertical total na cobertura (arrancamento para cima = negativo)
const cnfUp = cpeTop - Math.max(0, effectiveCpi);
const cnfDown = 0.2 - Math.min(0, effectiveCpi);
const cnfEdgeUp = cpeEdgeTop - Math.max(0, effectiveCpi);
// 4. Coeficientes horizontais em paredes de fundo e laterais
let cfBack = 1.3 * bClose;
@@ -210,20 +230,45 @@ export function calculateShelterWind(input: ShelterInput, q: number): ShelterRes
const fySide = cfSide * q * (areaSide * 2);
const fFriction = 0.04 * q * areaRoof;
// 6. Carga linear em pórticos (2 pórticos principais)
const numPorticos = 2;
let columnLoad = 0;
// 6. Carga linear em pórticos (usando Áreas Tributárias)
const numPorticos = Math.max(2, numColumns);
const numBays = numPorticos - 1;
const overhang = 0.6; // beiral genérico considerado no 3D
// Largura total efetiva entre o primeiro e o último pilar:
const spanWidth = Math.max(0.1, width - 2 * overhang);
const spacing = spanWidth / numBays;
// A área tributária de um pórtico central é o espaçamento 's'
// A área tributária de um pórtico de extremidade é 's/2' + overhang
const tributaryCentral = spacing;
const tributaryEdge = spacing / 2 + overhang;
// Fração da carga total que vai para o pórtico central vs extremidade
const fractionCentral = tributaryCentral / width;
const fractionEdge = tributaryEdge / width;
let columnLoadTotal = 0;
if (windAngle === 0) {
columnLoad = (fxBack / height / numPorticos) + (q * 1.8 * 0.15);
columnLoadTotal = fxBack;
} else if (windAngle === 45) {
const combForce = Math.sqrt(fxBack * fxBack + fySide * fySide);
columnLoad = (combForce / height / numPorticos) + (q * 1.8 * 0.15);
columnLoadTotal = Math.sqrt(fxBack * fxBack + fySide * fySide);
} else {
// Em 90°, a carga transversal principal atua nas paredes laterais (Fy)
columnLoad = (fySide / height / numPorticos) + (q * 1.8 * 0.15);
columnLoadTotal = fySide;
}
const beamLoad = (fzUp / depth / numPorticos);
// Cargas Distribuídas Lineares no Pilar
const windBaseLoadColumn = q * 1.8 * 0.15; // arrasto base no próprio perfil do pilar
const columnLoad = (columnLoadTotal * fractionCentral / height) + windBaseLoadColumn;
const columnLoadEdge = (columnLoadTotal * fractionEdge / height) + windBaseLoadColumn;
// Cargas Distribuídas Lineares na Viga (Balanço)
const beamLoad = (fzUp * fractionCentral / depth);
const beamLoadEdge = (fzUp * fractionEdge / depth);
// Cálculo do Momento Fletor na raiz da viga em balanço considerando o centro de pressão deslocado (L/3)
const cpRootDist = (2 / 3) * depth;
const beamMoment = (fzUp * fractionCentral) * cpRootDist;
const beamMomentEdge = (fzUp * fractionEdge) * cpRootDist;
// Texto explicativo por condição e ângulo
let statusText = '';
@@ -266,8 +311,10 @@ export function calculateShelterWind(input: ShelterInput, q: number): ShelterRes
return {
windAngle,
cpeTop: Number(cpeTop.toFixed(2)),
cpeEdgeTop: Number(cpeEdgeTop.toFixed(2)),
cpiBottom: Number(effectiveCpi.toFixed(2)),
cnfVertical: Number(cnfUp.toFixed(2)),
cnfEdgeVertical: Number(cnfEdgeUp.toFixed(2)),
cfBack: Number(cfBack.toFixed(2)),
cfSide: Number(cfSide.toFixed(2)),
forces: {
@@ -279,7 +326,11 @@ export function calculateShelterWind(input: ShelterInput, q: number): ShelterRes
},
lineLoads: {
columnLoad: Number(columnLoad.toFixed(2)),
columnLoadEdge: Number(columnLoadEdge.toFixed(2)),
beamLoad: Number(beamLoad.toFixed(2)),
beamLoadEdge: Number(beamLoadEdge.toFixed(2)),
beamMoment: Number(beamMoment.toFixed(2)),
beamMomentEdge: Number(beamMomentEdge.toFixed(2)),
},
reliefPercentage,
statusText,
+22 -1
View File
@@ -1,4 +1,5 @@
import React, { useMemo } from 'react';
import React, { useState, useMemo } from 'react';
import { Wind, Box } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { useHydrationStore } from '@/store/hydrationStore';
@@ -47,6 +48,7 @@ const BridgeModule: React.FC = () => {
const [fv, setFv] = React.useState(0.6);
const [vk, setVk] = React.useState(45);
const [alpha, setAlpha] = React.useState(0);
const [viewMode, setViewMode] = usePersistentState<'solid' | 'airflow'>('BridgeModule_viewMode', 'solid');
const [vf, setVf] = React.useState(100); // Velocidade crítica de flutter da seção
const classification = useMemo(
@@ -214,6 +216,24 @@ const BridgeModule: React.FC = () => {
Classe {classification.bridgeClass}
</Badge>
</div>
<div className="absolute top-4 right-16 z-10">
<div className="flex bg-background/80 backdrop-blur-sm border border-border/80 rounded-md shadow-sm p-1">
<button
onClick={() => setViewMode('solid')}
className={`px-3 py-1 text-xs font-medium rounded-sm transition-colors ${viewMode === 'solid' ? 'bg-primary/20 text-primary' : 'text-muted-foreground hover:bg-muted/50'}`}
>
<Box className="w-3.5 h-3.5 inline-block mr-1.5" />
Sólido
</button>
<button
onClick={() => setViewMode('airflow')}
className={`px-3 py-1 text-xs font-medium rounded-sm transition-colors ${viewMode === 'airflow' ? 'bg-primary/20 text-primary' : 'text-muted-foreground hover:bg-muted/50'}`}
>
<Wind className="w-3.5 h-3.5 inline-block mr-1.5" />
Fluxo
</button>
</div>
</div>
<div className="absolute top-4 right-4 z-10">
<EducationalManual type="bridge" params={{ alpha }} />
</div>
@@ -227,6 +247,7 @@ const BridgeModule: React.FC = () => {
fxPerLength={forces.fxPerLength}
fzPerLength={forces.fzPerLength}
alpha={alpha}
viewMode={viewMode}
/>
</div>
+112 -121
View File
@@ -1,21 +1,20 @@
import { useState } from 'react';
import { usePersistentState } from '@/hooks/usePersistentState';
import { getDragCoefficient } from '@/lib/drag';
import { useI18n } from '@/store/i18nStore';
import { DragCoefficientChart } from '@/components/DragCoefficientChart';
import { ResizableBox3D } from '@/components/three/ResizableBox3D';
import SceneCanvas from '@/components/SceneCanvas';
import { Environment } from '@react-three/drei';
import { ViewerOrbitControls as OrbitControls } from '@/components/three/ViewerOrbitControls';
import { EducationalManual } from '@/components/EducationalManual';
import { Calculator, Info } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Wind, Box } from 'lucide-react';
import { Slider } from '@/components/ui/slider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export default function CalcCaModule() {
const { t } = useI18n();
const [l1, setL1] = useState<number>(20);
const [l2, setL2] = useState<number>(40);
const [h, setH] = useState<number>(10);
const [l1, setL1] = usePersistentState<number>('CalcCaModule_l1', 20);
const [l2, setL2] = usePersistentState<number>('CalcCaModule_l2', 40);
const [h, setH] = usePersistentState<number>('CalcCaModule_h', 10);
const [viewMode, setViewMode] = usePersistentState<'solid' | 'airflow'>('CalcCaModule_viewMode', 'solid');
// Clamp inputs to avoid Infinity or negative values breaking the UI
const safeL1 = Math.max(0.1, l1 || 1);
@@ -26,128 +25,120 @@ export default function CalcCaModule() {
const caHigh = getDragCoefficient(safeL1, safeL2, safeH, 'high');
return (
<div className="p-6 max-w-7xl mx-auto space-y-6">
<header className="flex items-center gap-4 mb-8">
<div className="p-3 bg-primary/10 rounded-xl">
<Calculator className="w-8 h-8 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('nav_calc_ca')}</h1>
<p className="text-muted-foreground">
Cálculo rápido de Coeficiente de Arrasto (Ca) - NBR 6123:2023, Fig. 4 e 5.
</p>
</div>
</header>
<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 */}
<div className="w-full lg:w-80 shrink-0 flex flex-col gap-4 overflow-y-auto pb-8 lg:pb-0">
<Card className="shadow-sm border-border">
<CardHeader className="pb-3 flex flex-row items-start justify-between space-y-0 pr-6">
<div>
<CardTitle className="text-lg">Calc_Ca</CardTitle>
<CardDescription>Cálculo rápido de Coeficiente de Arrasto (Ca)</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
<div className="flex justify-between">
<label className="text-sm font-medium">L (Largura)</label>
<span className="font-mono text-sm">{l1} m</span>
</div>
<Slider min={1} max={100} step={1} value={[l1]} onValueChange={(v) => setL1(v[0])} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left Column: Inputs & Results */}
<div className="lg:col-span-4 space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">Entrada de Dados</CardTitle>
<CardDescription>Dimensões da edificação retangular</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="l1" className="text-sm font-medium leading-none">L (Largura perpendicular ao vento - m)</label>
<Input
id="l1"
type="number"
min="0.1"
step="0.1"
value={l1}
onChange={(e) => setL1(parseFloat(e.target.value))}
/>
<div className="space-y-3">
<div className="flex justify-between">
<label className="text-sm font-medium">L (Profundidade)</label>
<span className="font-mono text-sm">{l2} m</span>
</div>
<div className="space-y-2">
<label htmlFor="l2" className="text-sm font-medium leading-none">L (Profundidade paralela ao vento - m)</label>
<Input
id="l2"
type="number"
min="0.1"
step="0.1"
value={l2}
onChange={(e) => setL2(parseFloat(e.target.value))}
/>
</div>
<div className="space-y-2">
<label htmlFor="h" className="text-sm font-medium leading-none">H (Altura - m)</label>
<Input
id="h"
type="number"
min="0.1"
step="0.1"
value={h}
onChange={(e) => setH(parseFloat(e.target.value))}
/>
</div>
</CardContent>
</Card>
<Slider min={1} max={100} step={1} value={[l2]} onValueChange={(v) => setL2(v[0])} />
</div>
<Card className="border-primary/50 shadow-sm bg-primary/5">
<CardHeader className="pb-2">
<CardTitle className="text-lg flex items-center gap-2">
<Info className="w-5 h-5 text-primary" />
Resultados (Ca)
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4 mt-2">
<div className="flex justify-between items-center p-3 bg-background rounded-lg border">
<span className="font-medium text-muted-foreground">Baixa Turbulência</span>
<span className="text-2xl font-bold text-primary">{caLow.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center p-3 bg-background rounded-lg border">
<span className="font-medium text-muted-foreground">Alta Turbulência</span>
<span className="text-2xl font-bold text-primary">{caHigh.toFixed(2)}</span>
</div>
<div className="text-xs text-muted-foreground flex gap-4 mt-2 px-1">
<span>H/L = {(safeH / safeL1).toFixed(2)}</span>
<span>L/L = {(safeL1 / safeL2).toFixed(2)}</span>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<label className="text-sm font-medium">H (Altura)</label>
<span className="font-mono text-sm">{h} m</span>
</div>
</CardContent>
</Card>
<Slider min={1} max={100} step={1} value={[h]} onValueChange={(v) => setH(v[0])} />
</div>
</CardContent>
</Card>
</div>
{/* Coluna Central: 3D */}
<div className="flex-1 flex flex-col min-h-[500px] lg:min-h-0 bg-background rounded-xl border border-border shadow-sm overflow-hidden relative">
<div className="absolute top-4 right-16 z-10">
<div className="flex bg-background/80 backdrop-blur-sm border border-border/80 rounded-md shadow-sm p-1">
<button
onClick={() => setViewMode('solid')}
className={`px-3 py-1 text-xs font-medium rounded-sm transition-colors ${viewMode === 'solid' ? 'bg-primary/20 text-primary' : 'text-muted-foreground hover:bg-muted/50'}`}
>
<Box className="w-3.5 h-3.5 inline-block mr-1.5" />
Sólido
</button>
<button
onClick={() => setViewMode('airflow')}
className={`px-3 py-1 text-xs font-medium rounded-sm transition-colors ${viewMode === 'airflow' ? 'bg-primary/20 text-primary' : 'text-muted-foreground hover:bg-muted/50'}`}
>
<Wind className="w-3.5 h-3.5 inline-block mr-1.5" />
Fluxo
</button>
</div>
</div>
<div className="absolute top-4 right-4 z-10">
<EducationalManual type="calc_ca" params={{ l1: safeL1, l2: safeL2, h: safeH }} />
</div>
{/* Center Column: 3D Visualization */}
<div className="lg:col-span-4">
<Card className="h-full flex flex-col">
<CardHeader className="pb-2 shrink-0">
<CardTitle className="text-lg">Visualização 3D</CardTitle>
<CardDescription>Proporções dinâmicas</CardDescription>
</CardHeader>
<CardContent className="flex-1 p-0 min-h-[400px] relative rounded-b-xl overflow-hidden bg-gradient-to-b from-muted/30 to-muted/10">
<div className="absolute top-4 right-4 z-10">
<EducationalManual type="calc_ca" params={{ l1: safeL1, l2: safeL2, h: safeH }} />
</div>
<SceneCanvas moduleId="calc_ca" camera={{ position: [50, 40, 50], fov: 45 }} fallback={<div />} frameloop="always">
<ambientLight intensity={0.5} />
<directionalLight position={[10, 20, 10]} intensity={1} castShadow />
<Environment preset="city" />
<ResizableBox3D l1={safeL1} l2={safeL2} h={safeH} />
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2 - 0.1} />
</SceneCanvas>
<div className="absolute bottom-4 left-4 right-20 text-center text-xs text-muted-foreground pointer-events-none">
Arraste para rotacionar, role para aproximar
</div>
</CardContent>
</Card>
</div>
<SceneCanvas
moduleId="calc_ca"
camera={{ position: [50, 40, 50], fov: 45 }}
fallback={<div />}
frameloop={viewMode === 'airflow' ? 'always' : 'demand'}
>
<ambientLight intensity={0.5} />
<directionalLight position={[10, 20, 10]} intensity={1} castShadow />
<Environment preset="city" />
<ResizableBox3D l1={safeL1} l2={safeL2} h={safeH} viewMode={viewMode} />
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2 - 0.1} />
</SceneCanvas>
{/* Right Column: Chart */}
<div className="lg:col-span-4">
<Card className="h-full">
<CardHeader className="pb-2">
<CardTitle className="text-lg">Ábaco NBR 6123</CardTitle>
<CardDescription>Curvas isotermas aproximadas</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-center h-[calc(100%-80px)]">
<DragCoefficientChart l1={safeL1} l2={safeL2} h={safeH} caLow={caLow} caHigh={caHigh} />
</CardContent>
</Card>
<div className="absolute bottom-4 left-4 right-20 text-center text-xs text-muted-foreground pointer-events-none">
Arraste para rotacionar, role para aproximar
</div>
</div>
{/* Coluna Direita: Resultados */}
<div className="w-full lg:w-96 shrink-0 flex flex-col gap-4 overflow-y-auto pb-8 lg:pb-0">
<Card className="shadow-sm border-border">
<CardHeader className="pb-3">
<CardTitle className="text-lg">Resultados (Ca)</CardTitle>
<CardDescription>Valores baseados nas tabelas 4 e 5</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center p-3 bg-background rounded-lg border">
<span className="font-medium text-muted-foreground">Baixa Turbulência</span>
<span className="text-2xl font-bold text-primary">{caLow.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center p-3 bg-background rounded-lg border">
<span className="font-medium text-muted-foreground">Alta Turbulência</span>
<span className="text-2xl font-bold text-primary">{caHigh.toFixed(2)}</span>
</div>
<div className="text-xs text-muted-foreground flex gap-4 mt-2 px-1">
<span>H/L = {(safeH / safeL1).toFixed(2)}</span>
<span>L/L = {(safeL1 / safeL2).toFixed(2)}</span>
</div>
</CardContent>
</Card>
{/* Ábaco */}
<Card className="shadow-sm border-border flex-1 min-h-[300px]">
<CardHeader className="pb-2">
<CardTitle className="text-lg">Ábaco NBR 6123</CardTitle>
<CardDescription>Curvas isotermas aproximadas</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-center h-[calc(100%-80px)] min-h-[250px]">
<DragCoefficientChart l1={safeL1} l2={safeL2} h={safeH} caLow={caLow} caHigh={caHigh} />
</CardContent>
</Card>
</div>
</div>
);
}
+37 -11
View File
@@ -3,7 +3,9 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Slider } from '@/components/ui/slider';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Box, Wind } from 'lucide-react';
import { useWindStore } from '@/store/appStore';
import { CpiSettingsCard } from '@/components/CpiSettingsCard';
import { calculateCylinder, type CylinderEndType } from '@/lib/modules/cylinder';
@@ -23,6 +25,7 @@ const CylinderModule: React.FC = () => {
const [height, setHeight] = React.useState(20);
const [surface, setSurface] = React.useState<'rough' | 'smooth'>('rough');
const [endType, setEndType] = React.useState<CylinderEndType>('closed');
const [viewMode, setViewMode] = React.useState<'solid' | 'airflow'>('solid');
const result = useMemo(() => {
return calculateCylinder({
@@ -169,24 +172,47 @@ const CylinderModule: React.FC = () => {
{/* Coluna Central: 3D */}
<div className="flex-1 flex flex-col min-h-[500px] lg:min-h-0 bg-background rounded-xl border border-border shadow-sm overflow-hidden relative">
<div className="absolute top-4 left-4 z-10 flex gap-2">
<Badge variant="secondary" className="bg-background/80 text-foreground border border-border/50 backdrop-blur-sm shadow-sm">
h/d = {result.hOverD.toFixed(2)}
</Badge>
<Badge variant="outline" className="bg-background/80 backdrop-blur-sm">
Re = {result.re.toExponential(2)}
</Badge>
</div>
<div className="absolute top-4 right-4 z-10">
<EducationalManual type="cylinder" params={{ surface, endType, cpi: result.cpi }} />
<div className="absolute top-4 left-4 right-4 z-10 flex flex-col sm:flex-row sm:justify-between gap-3 pointer-events-none">
<div className="flex flex-wrap gap-2 pointer-events-auto">
<Badge variant="secondary" className="bg-background/80 text-foreground border border-border/50 backdrop-blur-sm shadow-sm">
h/d = {result.hOverD.toFixed(2)}
</Badge>
<Badge variant="outline" className="bg-background/80 backdrop-blur-sm">
Re = {result.re.toExponential(2)}
</Badge>
</div>
<div className="flex bg-background/80 backdrop-blur-sm rounded-md p-1 shadow-sm border border-border pointer-events-auto ml-auto">
<Button
variant={viewMode === 'solid' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-3 text-xs"
onClick={() => setViewMode('solid')}
>
<Box className="w-3.5 h-3.5 mr-1.5" />
Sólido
</Button>
<Button
variant={viewMode === 'airflow' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-3 text-xs"
onClick={() => setViewMode('airflow')}
>
<Wind className="w-3.5 h-3.5 mr-1.5" />
Fluxo
</Button>
</div>
<div className="flex flex-wrap gap-2 pointer-events-auto justify-start sm:justify-end">
<EducationalManual type="cylinder" params={{ surface, endType, cpi: result.cpi }} />
</div>
</div>
<PressureLegend />
<div className="w-full h-full bg-muted/20">
<div className="w-full h-full cursor-grab active:cursor-grabbing bg-muted/20">
<Cylinder3DViewer
diameter={diameter}
height={height}
cpeProfile={result.profile}
cpi={result.cpi}
viewMode={viewMode}
/>
</div>
</div>
+25 -1
View File
@@ -4,6 +4,8 @@ import { Slider } from '@/components/ui/slider';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
import { Box, Wind } from 'lucide-react';
import { useWindStore } from '@/store/appStore';
import { CpiSettingsCard } from '@/components/CpiSettingsCard';
import { calculateDome, type DomeType } from '@/lib/modules/dome';
@@ -22,6 +24,7 @@ const DomeModule: React.FC = () => {
const [rise, setRise] = React.useState(5);
const [wallHeight, setWallHeight] = React.useState(8);
const [type, setType] = React.useState<DomeType>('on-ground');
const [viewMode, setViewMode] = React.useState<'solid' | 'airflow'>('solid');
const result = useMemo(() => calculateDome({ d: diameter, f: rise, h: wallHeight, vk, type, cpi }), [diameter, rise, wallHeight, vk, type, cpi]);
@@ -158,7 +161,27 @@ const DomeModule: React.FC = () => {
Cpi = {cpi.toFixed(2)}
</Badge>
</div>
<div className="absolute top-4 right-4 z-10">
<div className="absolute top-4 right-4 z-10 flex flex-wrap gap-2 pointer-events-auto justify-end">
<div className="flex bg-background/80 backdrop-blur-sm rounded-md p-1 shadow-sm border border-border">
<Button
variant={viewMode === 'solid' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-3 text-xs"
onClick={() => setViewMode('solid')}
>
<Box className="w-3.5 h-3.5 mr-1.5" />
Sólido
</Button>
<Button
variant={viewMode === 'airflow' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-3 text-xs"
onClick={() => setViewMode('airflow')}
>
<Wind className="w-3.5 h-3.5 mr-1.5" />
Fluxo
</Button>
</div>
<EducationalManual type="dome" params={{ rise, diameter, cpi }} />
</div>
<PressureLegend />
@@ -171,6 +194,7 @@ const DomeModule: React.FC = () => {
cpeBarlavento={result.cpeBarlavento}
cpeTopo={result.cpeTopo}
cpeLateral={result.cpeLateral}
viewMode={viewMode}
/>
</div>
</div>
+2 -1
View File
@@ -1,3 +1,4 @@
import { usePersistentState } from '@/hooks/usePersistentState';
import React, { useMemo, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Slider } from '@/components/ui/slider';
@@ -34,7 +35,7 @@ import { SaveModuleDialog } from '@/components/SaveModuleDialog';
const DynamicsModule: React.FC = () => {
const { v0, s1, s3, terrainCategory, structureClass, q } = useWindStore();
const [activeTab, setActiveTab] = useState('edificio');
const [activeTab, setActiveTab] = usePersistentState('DynamicsModule_activeTab', 'edificio');
const [structureType, setStructureType] = React.useState<StructureDynamicType>('concrete-shearwall');
const [height, setHeight] = React.useState(40);
+99 -55
View File
@@ -1,18 +1,20 @@
import React from 'react';
import Warehouse3DViewer from '../components/Warehouse3D';
import Heatmap2D from '../components/Heatmap2D';
import LinearLoadsTable from '../components/LinearLoadsTable';
import SceneCapturePanel from '../components/SceneCapturePanel';
import FtoolExportCard from '../components/FtoolExportCard';
import { CpiSettingsCard } from '../components/CpiSettingsCard';
import { useGalpaoStore } from '../store/galpaoStore';
import { useWindStore } from '../store/appStore';
import { getWallCpeOfficial as getWallCpe, getRoofCpeOfficial as getRoofCpe } from '../lib/coefficients';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Slider } from '@/components/ui/slider';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { AlertCircle, Lightbulb, Box, Wind } from 'lucide-react';
import ExportMenu from '../components/ExportMenu';
@@ -65,18 +67,25 @@ const GalpaoModule: React.FC = () => {
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 */}
<div className="w-full lg:w-80 shrink-0 flex flex-col gap-4 overflow-y-auto pb-8 lg:pb-0">
<Card className="shadow-sm border-border">
<CardHeader className="pb-3 flex flex-row items-start justify-between space-y-0 pr-6">
<div>
<CardTitle className="text-lg">Geometria</CardTitle>
<CardDescription>Tabelas 4 e 5 NBR 6123.</CardDescription>
</div>
<SaveModuleDialog
moduleType="galpao"
inputs={{ width, length, height, roofPitch }}
/>
</CardHeader>
<div className="w-full lg:w-96 shrink-0 flex flex-col gap-4 overflow-y-auto pb-8 lg:pb-0">
<Tabs defaultValue="geometria" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-4">
<TabsTrigger value="geometria">1. Geometria</TabsTrigger>
<TabsTrigger value="permeabilidade">2. Permeabilidade</TabsTrigger>
</TabsList>
<TabsContent value="geometria" className="space-y-4 m-0">
<Card className="shadow-sm border-border">
<CardHeader className="pb-3 flex flex-row items-start justify-between space-y-0 pr-6">
<div>
<CardTitle className="text-lg">Geometria e Vento</CardTitle>
<CardDescription>Tabelas 4 e 5 NBR 6123.</CardDescription>
</div>
<SaveModuleDialog
moduleType="galpao"
inputs={{ width, length, height, roofPitch }}
/>
</CardHeader>
<CardContent className="space-y-6">
<WindParametersSummary />
@@ -98,7 +107,7 @@ const GalpaoModule: React.FC = () => {
<div className="flex gap-2">
<AlertCircle className="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<p className="text-xs text-amber-600 dark:text-amber-400">
A platibanda bloqueia o fluxo. Calcule os esforços sobre ela no módulo de <b>Muros/Placas</b>.
A platibanda bloqueia o fluxo. Calcule os esforços nela no módulo de <b>Muros/Placas</b>.
</p>
</div>
</div>
@@ -243,55 +252,90 @@ const GalpaoModule: React.FC = () => {
</div>
</CardContent>
</Card>
</TabsContent>
<CpiSettingsCard />
<TabsContent value="permeabilidade" className="space-y-4 m-0">
<CpiSettingsCard />
</TabsContent>
</Tabs>
</div>
{/* Coluna Direita: Viewer 3D */}
{/* Coluna Central: Viewer 3D e Heatmap (Tab) */}
<div className="flex-1 flex flex-col min-h-[500px] lg:min-h-0 bg-background rounded-xl border border-border shadow-sm overflow-hidden relative">
<div className="absolute top-4 left-4 right-4 z-10 flex flex-col sm:flex-row sm:justify-between gap-3 pointer-events-none">
<div className="flex flex-wrap gap-2 pointer-events-auto">
<Badge variant="secondary" className="bg-background/80 backdrop-blur-sm border-muted-foreground/20 text-foreground shadow-sm">
Vento a {windAngle}°
</Badge>
<Badge variant="outline" className="bg-background/80 backdrop-blur-sm">
Cpi = {cpi.toFixed(2)}
</Badge>
<Badge variant="outline" className="bg-background/80 backdrop-blur-sm">
q = {q.toFixed(3)} kN/m²
</Badge>
</div>
<div className="flex bg-background/80 backdrop-blur-sm rounded-md p-1 shadow-sm border border-border pointer-events-auto ml-auto">
<Button
variant={viewMode === 'solid' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-3 text-xs"
onClick={() => setViewMode('solid')}
>
<Box className="w-3.5 h-3.5 mr-1.5" />
Sólido
</Button>
<Button
variant={viewMode === 'airflow' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-3 text-xs"
onClick={() => setViewMode('airflow')}
>
<Wind className="w-3.5 h-3.5 mr-1.5" />
Fluxo
</Button>
<Tabs defaultValue="3d" className="w-full h-full flex flex-col">
<div className="absolute top-4 left-4 right-4 z-10 flex flex-col sm:flex-row sm:justify-between gap-3 pointer-events-none">
<div className="flex flex-wrap gap-2 pointer-events-auto">
<Badge variant="secondary" className="bg-background/80 backdrop-blur-sm border-muted-foreground/20 text-foreground shadow-sm">
Vento a {windAngle}°
</Badge>
<Badge variant="outline" className="bg-background/80 backdrop-blur-sm">
Cpi = {cpi.toFixed(2)}
</Badge>
<Badge variant="outline" className="bg-background/80 backdrop-blur-sm">
q = {q.toFixed(3)} kN/m²
</Badge>
</div>
<div className="flex pointer-events-auto bg-background/80 backdrop-blur-sm rounded-md p-1 shadow-sm border border-border">
<TabsList className="h-7 px-1">
<TabsTrigger value="3d" className="text-xs h-6 px-3">Modelo 3D</TabsTrigger>
<TabsTrigger value="heatmap" className="text-xs h-6 px-3">Heatmap 2D</TabsTrigger>
<TabsTrigger value="compare" className="text-xs h-6 px-3">Modo A/B</TabsTrigger>
</TabsList>
</div>
<div className="flex flex-wrap gap-2 pointer-events-auto justify-start sm:justify-end ml-auto">
<EducationalManual type="warehouse" params={{ windAngle, cpi }} />
</div>
</div>
<div className="flex flex-wrap gap-2 pointer-events-auto justify-start sm:justify-end">
<EducationalManual type="warehouse" params={{ windAngle, cpi }} />
</div>
</div>
<TabsContent value="3d" className="flex-1 m-0 h-full w-full relative">
<div className="absolute bottom-20 left-4 z-20 flex bg-background/80 backdrop-blur-sm rounded-md p-1 shadow-sm border border-border pointer-events-auto">
<Button
variant={viewMode === 'solid' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-3 text-xs"
onClick={() => setViewMode('solid')}
>
<Box className="w-3.5 h-3.5 mr-1.5" />
Sólido
</Button>
<Button
variant={viewMode === 'airflow' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-3 text-xs"
onClick={() => setViewMode('airflow')}
>
<Wind className="w-3.5 h-3.5 mr-1.5" />
Fluxo
</Button>
</div>
<PressureLegend />
<PressureLegend />
<div className="w-full h-full cursor-grab active:cursor-grabbing bg-muted/20">
<Warehouse3DViewer />
</div>
</TabsContent>
<div className="w-full h-full cursor-grab active:cursor-grabbing bg-muted/20">
<Warehouse3DViewer />
</div>
<TabsContent value="heatmap" className="flex-1 m-0 h-full w-full bg-muted/10 overflow-auto flex items-center justify-center">
<Heatmap2D />
</TabsContent>
<TabsContent value="compare" className="flex-1 m-0 h-full w-full bg-muted/10 overflow-auto p-4 flex flex-col">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 flex-1">
<Heatmap2D
overrideAngle={0}
overrideWallCpe={getWallCpe(length, width, height, 0)}
overrideRoofCpe={getRoofCpe(length, width, height, roofPitch, 0)}
/>
<Heatmap2D
overrideAngle={90}
overrideWallCpe={getWallCpe(length, width, height, 90)}
overrideRoofCpe={getRoofCpe(length, width, height, roofPitch, 90)}
/>
</div>
</TabsContent>
</Tabs>
</div>
{/* Coluna Direita-Inferior: Cargas Lineares (M9.2) */}
+122
View File
@@ -0,0 +1,122 @@
import { Link } from 'react-router-dom';
import { Warehouse, Cylinder, Church, CircleDot, Square, Layers, BarChart3, Frame, Tent, Calculator } from 'lucide-react';
import { useI18n } from '../store/i18nStore';
import { useWindStore } from '../store/appStore';
// Mocked icons for bridge and tower to match App.tsx
const BridgeIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M3 18V6l3-1 6 3 6-3 3 1v12" />
<path d="M3 10h18" />
<path d="M8 10v8M16 10v8" />
<path d="M3 14c4-2 6-2 10 0s6 2 8 0" />
</svg>
);
const TowerIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M6 22L10 2M18 22L14 2" />
<path d="M10 2h4" />
<path d="M8.5 7h7" />
<path d="M7.5 12h9" />
<path d="M6.5 17h11" />
<path d="M6 22h12" />
<path d="M10 2l4 5M12 7l-4 5" />
<path d="M10 12l5 5M14 12l-5 5" />
<path d="M9.5 17l5.5 5M14.5 17l-5.5 5" />
</svg>
);
export default function HomeMock() {
const { t } = useI18n();
const { v0, s1, s2, s3, vk, q, locality } = useWindStore();
const modules = [
{ to: '/galpao', icon: Warehouse, label: 'Galpão Retangular', desc: 'Paredes A/B/C/D, telhados E-J, excentricidade, atrito, alta turbulência.' },
{ to: '/cilindro', icon: Cylinder, label: 'Cilindro Vertical', desc: 'Silos, reservatórios, chaminés. Cpe por ângulo (Tab. 13), Reynolds.' },
{ to: '/abobada', icon: Church, label: 'Abóbada Cilíndrica', desc: 'Coberturas curvas em arco. Tab. 15-20, 6 zonas.' },
{ to: '/cupula', icon: CircleDot, label: 'Cúpula', desc: 'Sobre terreno (Tab. 21) ou parede cilíndrica (Tab. 22).' },
{ to: '/muros', icon: Square, label: 'Muros e Placas', desc: 'Cf para vento perpendicular e oblíquo (Tab. 23).' },
{ to: '/cobertura-isolada', icon: Layers, label: 'Coberturas Isoladas', desc: 'Uma ou duas águas, abas perpendiculares (Tab. 24-25).' },
{ to: '/barras', icon: BarChart3, label: 'Barras Prismáticas', desc: 'Faces planas (Tab. 26) ou circulares (Tab. 27).' },
{ to: '/pontes', icon: BridgeIcon, label: 'Pontes', desc: 'Pse, Cx/Cz do tabuleiro, flutter, galope (sec. 11).' },
{ to: '/torre', icon: TowerIcon, label: 'Torres', desc: 'Torres treliçadas e mastros (sec. 8.4).' },
{ to: '/piperack', icon: Frame, label: 'Pipe-Racks Industriais', desc: 'Estruturas reticuladas múltiplas, tubulações, fator η (Tab. 28).' },
{ to: '/abrigo', icon: Tent, label: 'Abrigos e Pórticos Fechados', desc: 'Marquises, abrigos e pórticos. 4 condições topológicas, frestas de alívio, corte A-A (Tab. 23-25, sec. 6.3).' },
{ to: '/calc-ca', icon: Calculator, label: 'Calculadora de C_a', desc: 'Coeficiente de arrasto (Fig. 4 e 5).' },
];
return (
<div className="p-8 max-w-6xl mx-auto space-y-8 animate-in fade-in duration-500">
<header className="text-center space-y-4 flex flex-col items-center mt-4 mb-6">
<div className="relative inline-block">
<img src="/logo_brainwind.png" alt="BrainWind" className="h-20 md:h-28 w-auto object-contain dark:drop-shadow-[0_0_15px_rgba(255,255,255,0.4)]" />
<span className="absolute -bottom-1 -right-2 md:-right-6 bg-primary text-primary-foreground text-[10px] md:text-xs font-bold px-2 py-0.5 rounded-full shadow-sm border border-primary-foreground/20">v1.1</span>
</div>
<p className="text-muted-foreground text-lg">
{t('app_subtitle')}
</p>
</header>
{/* Dashboard de Vento Básico */}
<div className="bg-card border rounded-xl p-6 shadow-sm">
<h3 className="font-semibold text-lg mb-4 text-card-foreground">Parâmetros Atuais do Vento {locality ? `(${locality})` : ''}</h3>
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
<div className="flex flex-col items-center justify-center p-3 bg-muted/50 rounded-lg border">
<span className="text-xs text-muted-foreground">V (m/s)</span>
<span className="font-bold text-xl">{v0.toFixed(1)}</span>
</div>
<div className="flex flex-col items-center justify-center p-3 bg-muted/50 rounded-lg border">
<span className="text-xs text-muted-foreground">S</span>
<span className="font-bold text-xl">{s1.toFixed(2)}</span>
</div>
<div className="flex flex-col items-center justify-center p-3 bg-muted/50 rounded-lg border">
<span className="text-xs text-muted-foreground">S</span>
<span className="font-bold text-xl">{s2.toFixed(2)}</span>
</div>
<div className="flex flex-col items-center justify-center p-3 bg-muted/50 rounded-lg border">
<span className="text-xs text-muted-foreground">S</span>
<span className="font-bold text-xl">{s3.toFixed(2)}</span>
</div>
<div className="flex flex-col items-center justify-center p-3 bg-muted/50 rounded-lg border">
<span className="text-xs text-muted-foreground">Vₖ (m/s)</span>
<span className="font-bold text-xl text-primary">{vk.toFixed(1)}</span>
</div>
<div className="flex flex-col items-center justify-center p-3 bg-primary/10 rounded-lg border border-primary/20">
<span className="text-xs text-primary/80 font-medium">q (kN/m²)</span>
<span className="font-bold text-2xl text-primary">{q.toFixed(3)}</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{modules.map((m) => (
<Link key={m.to} to={m.to} className="block p-6 rounded-xl border bg-card hover:shadow-lg hover:border-primary transition-all">
<m.icon className="w-8 h-8 text-primary mb-3" />
<h2 className="font-semibold text-lg mb-1">{m.label}</h2>
<p className="text-sm text-muted-foreground">{m.desc}</p>
</Link>
))}
</div>
<div className="rounded-lg border bg-muted/40 p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground mb-2">{t('home_full_coverage')}</p>
<ul className="grid grid-cols-2 md:grid-cols-4 gap-1 text-xs">
<li> Sec. 5 V, S, S, S</li>
<li> Sec. 6.1 Paralelepipédicas</li>
<li> Sec. 6.2 Cilindros, abóbadas, cúpulas</li>
<li> Sec. 6.3 Pressão interna (Cpi)</li>
<li> Sec. 6.4 Vizinhança</li>
<li> Sec. 7 Muros, coberturas isoladas</li>
<li> Sec. 8 Barras e reticulados</li>
<li> Sec. 9 Efeitos dinâmicos</li>
<li> Sec. 10 Vórtices</li>
<li> Sec. 11 Pontes</li>
<li> Anexo A S(qualquer t)</li>
<li> Anexo B S(Pₘ, vida útil)</li>
<li> Anexo C 49 estações</li>
</ul>
</div>
</div>
);
}
+45 -13
View File
@@ -1,11 +1,13 @@
import React, { useMemo } from 'react';
import { usePersistentState } from '@/hooks/usePersistentState';
import React, { useMemo, useState } from 'react';
import { Box, Wind } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Slider } from '@/components/ui/slider';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { useWindStore } from '@/store/appStore';
import { calculateIsolatedShedRoof, calculateIsolatedGableRoof, frictionForceIsolatedRoof } from '@/lib/nbr-tables/table-24-25';
import { calculateIsolatedShedRoof, calculateIsolatedGableRoof, calculateIsolatedButterflyRoof, frictionForceIsolatedRoof } from '@/lib/nbr-tables/table-24-25';
import IsolatedRoof3DViewer from '@/components/three/IsolatedRoof3D';
import SceneCapturePanel from '../components/SceneCapturePanel';
import ExportMenu from '../components/ExportMenu';
@@ -17,17 +19,22 @@ import { exportGenericToPDF, type GenericPDFSection } from '../lib/export-generi
const IsolatedRoofModule: React.FC = () => {
const { q } = useWindStore();
const [type, setType] = React.useState<'shed' | 'gable'>('shed');
const [type, setType] = React.useState<'shed' | 'gable' | 'butterfly'>('shed');
const [theta, setTheta] = React.useState(15);
const [height, setHeight] = React.useState(2);
const [width, setWidth] = React.useState(10);
const [depth, setDepth] = React.useState(10);
const [width, setWidth] = usePersistentState('IsolatedRoofModule_width', 10);
const [depth, setDepth] = usePersistentState('IsolatedRoofModule_depth', 10);
const [viewMode, setViewMode] = usePersistentState<'solid' | 'airflow'>('IsolatedRoofModule_viewMode', 'solid');
const result = useMemo(() => {
if (type === 'shed') {
const r = calculateIsolatedShedRoof({ theta, height, depth });
return { ...r, cpb: { cpb1: 0, cpb2: 0 }, cpa: { cpa1: 0, cpa2: 0 } };
}
if (type === 'butterfly') {
const b = calculateIsolatedButterflyRoof({ theta, height, depth });
return { ...b, cph1: { high: 0, low: 0 }, cph2: { high: 0, low: 0 } };
}
const g = calculateIsolatedGableRoof({ theta, height, depth });
return { ...g, cph1: { high: 0, low: 0 }, cph2: { high: 0, low: 0 } };
}, [type, theta, height, depth]);
@@ -79,12 +86,15 @@ const IsolatedRoofModule: React.FC = () => {
title: 'Geometria da Cobertura Isolada',
type: 'grid',
gridItems: [
{ label: 'Tipo', value: type === 'shed' ? 'Uma água' : 'Duas águas' },
{ label: 'Tipo', value: type === 'shed' ? 'Uma água' : type === 'gable' ? 'Duas águas' : 'Borboleta (Extrapolação)' },
{ label: 'Inclinação (θ)', value: `${theta}°` },
{ label: 'Largura (b)', value: `${width} m` },
{ label: 'Profundidade ()', value: `${depth} m` },
{ label: 'Altura livre (h)', value: `${height} m` },
{ label: 'Aplicável à norma', value: result.applies ? 'Sim' : 'Não' },
{
label: 'Aplicável à norma',
value: result.applies ? 'Sim' : type === 'butterfly' ? 'Extrapolado (Não há tabela)' : 'Não'
},
],
},
{
@@ -116,7 +126,7 @@ const IsolatedRoofModule: React.FC = () => {
{
title: '1. Coeficientes de Pressão Líquida (Cpn) e Carregamentos',
formula: 'Cpn = Cp_superior Cp_inferior (Tabelas 24 e 25 NBR 6123)',
calculation: `Para inclinação θ = ${theta}° (${type === 'shed' ? 'Uma água' : 'Duas águas'}), os coeficientes são definidos por zona e por carregamento máximo/mínimo.`,
calculation: `Para inclinação θ = ${theta}° (${type === 'shed' ? 'Uma água' : type === 'gable' ? 'Duas águas' : 'Borboleta'}), os coeficientes são definidos por zona e por carregamento máximo/mínimo.`,
result: `θ = ${theta}° (Tab. ${type === 'shed' ? '24' : '25'})`,
note: 'Coberturas isoladas estão sujeitas a descolamento violento de fluxo, exigindo verificação para sucção (arrancamento) e compressão.',
},
@@ -159,11 +169,14 @@ const IsolatedRoofModule: React.FC = () => {
<WindParametersSummary />
<div className="space-y-2">
<label className="text-sm font-medium">Tipo de Cobertura</label>
<Select value={type} onValueChange={(v) => setType(v as 'shed' | 'gable')}>
<SelectTrigger><SelectValue /></SelectTrigger>
<Select value={type} onValueChange={(v: any) => setType(v)}>
<SelectTrigger>
<SelectValue placeholder="Tipo de Cobertura" />
</SelectTrigger>
<SelectContent>
<SelectItem value="shed">Uma água (Tab. 24)</SelectItem>
<SelectItem value="gable">Duas águas (Tab. 25)</SelectItem>
<SelectItem value="shed">Uma Água</SelectItem>
<SelectItem value="gable">Duas Águas</SelectItem>
<SelectItem value="butterfly">Telhado Borboleta (Em V)</SelectItem>
</SelectContent>
</Select>
</div>
@@ -200,7 +213,25 @@ const IsolatedRoofModule: React.FC = () => {
<div className="flex-1 flex flex-col min-h-[500px] lg:min-h-0 bg-background rounded-xl border border-border shadow-sm overflow-hidden relative">
<div className="absolute top-4 left-4 z-10 flex gap-2">
<Badge variant="secondary" className="bg-background/80 text-foreground border border-border/80 backdrop-blur-sm shadow-sm">θ = {theta}°</Badge>
<Badge variant="secondary" className="bg-background/80 text-foreground border border-border/80 backdrop-blur-sm shadow-sm">{type === 'shed' ? 'Uma água' : 'Duas águas'}</Badge>
<Badge variant="secondary" className="bg-background/80 text-foreground border border-border/80 backdrop-blur-sm shadow-sm">{type === 'shed' ? 'Uma água' : type === 'gable' ? 'Duas águas' : 'Borboleta'}</Badge>
</div>
<div className="absolute top-4 right-16 z-10">
<div className="flex bg-background/80 backdrop-blur-sm border border-border/80 rounded-md shadow-sm p-1">
<button
onClick={() => setViewMode('solid')}
className={`px-3 py-1 text-xs font-medium rounded-sm transition-colors ${viewMode === 'solid' ? 'bg-primary/20 text-primary' : 'text-muted-foreground hover:bg-muted/50'}`}
>
<Box className="w-3.5 h-3.5 inline-block mr-1.5" />
Sólido
</button>
<button
onClick={() => setViewMode('airflow')}
className={`px-3 py-1 text-xs font-medium rounded-sm transition-colors ${viewMode === 'airflow' ? 'bg-primary/20 text-primary' : 'text-muted-foreground hover:bg-muted/50'}`}
>
<Wind className="w-3.5 h-3.5 inline-block mr-1.5" />
Fluxo
</button>
</div>
</div>
<div className="absolute top-4 right-4 z-10">
<EducationalManual type="isolated-roof" params={{ theta, type }} />
@@ -216,6 +247,7 @@ const IsolatedRoofModule: React.FC = () => {
cpeLeeward={cpeLeeward}
cpeTop={cpeTop}
forceKN={forces.magnitude}
viewMode={viewMode}
/>
</div>
+41 -10
View File
@@ -1,4 +1,5 @@
import React, { useMemo, useState } from 'react';
import { usePersistentState } from '@/hooks/usePersistentState';
import React, { useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { useHydrationStore } from '@/store/hydrationStore';
@@ -8,25 +9,27 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useWindStore } from '@/store/appStore';
import { calculatePiperack, type PipeDef } from '@/lib/modules/piperack';
import { isResonantRisk } from '@/lib/dynamics';
import { Piperack3D } from '@/components/three/Piperack3D';
import SceneCapturePanel from '../components/SceneCapturePanel';
import ExportMenu from '../components/ExportMenu';
import { EducationalManual } from '@/components/EducationalManual';
import { WindParametersSummary } from '@/components/WindParametersSummary';
import { exportGenericToPDF, type GenericPDFSection } from '../lib/export-generic-pdf';
import { Plus, Trash2, Info } from 'lucide-react';
import { Plus, Trash2, Info, AlertTriangle } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip';
import { Input } from '@/components/ui/input';
const PiperackModule: React.FC = () => {
const { q } = useWindStore();
const [width, setWidth] = useState(17);
const [height, setHeight] = useState(2);
const [elevation, setElevation] = useState(6);
const [spacing, setSpacing] = useState(2);
const [numFrames, setNumFrames] = useState(2);
const [phiStruct, setPhiStruct] = useState(0.5);
const [pipes, setPipes] = useState<PipeDef[]>([]);
const [width, setWidth] = usePersistentState('PiperackModule_width', 17);
const [height, setHeight] = usePersistentState('PiperackModule_height', 2);
const [elevation, setElevation] = usePersistentState('PiperackModule_elevation', 6);
const [spacing, setSpacing] = usePersistentState('PiperackModule_spacing', 2);
const [numFrames, setNumFrames] = usePersistentState('PiperackModule_numFrames', 2);
const [phiStruct, setPhiStruct] = usePersistentState('PiperackModule_phiStruct', 0.5);
const [f1, setF1] = usePersistentState('PiperackModule_f1', 1.5); // Frequência natural (Hz)
const [pipes, setPipes] = usePersistentState<PipeDef[]>('PiperackModule_pipes', []);
const pendingLoad = useHydrationStore((s) => s.pendingLoad);
const clearPendingLoad = useHydrationStore((s) => s.clearPendingLoad);
@@ -41,6 +44,7 @@ const PiperackModule: React.FC = () => {
if (p.numFrames !== undefined) setNumFrames(p.numFrames);
if (p.pipes !== undefined) setPipes(p.pipes);
if (p.phiStruct !== undefined) setPhiStruct(p.phiStruct);
if (p.f1 !== undefined) setF1(p.f1);
clearPendingLoad();
}
}, [pendingLoad, clearPendingLoad]);
@@ -157,7 +161,7 @@ const PiperackModule: React.FC = () => {
</div>
<SaveModuleDialog
moduleType="piperack"
inputs={{ elevation, width, height, spacing, numFrames, pipes, phiStruct }}
inputs={{ elevation, width, height, spacing, numFrames, pipes, phiStruct, f1 }}
/>
</CardHeader>
<CardContent className="space-y-6">
@@ -219,6 +223,33 @@ const PiperackModule: React.FC = () => {
</div>
<Slider value={[phiStruct]} min={0.1} max={1.0} step={0.05} onValueChange={v => setPhiStruct(v[0])} />
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Freq. Natural (f)</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p>Frequência fundamental no sentido do vento (Hz). Valores abaixo de 1 Hz indicam risco dinâmico elevado.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<span className="text-sm text-muted-foreground">{f1.toFixed(1)} Hz</span>
</div>
<Slider value={[f1]} min={0.1} max={5.0} step={0.1} onValueChange={v => setF1(v[0])} />
{isResonantRisk(f1) && (
<div className="mt-2 p-2 bg-destructive/10 border border-destructive/20 rounded text-xs text-destructive flex gap-2 items-start">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<p>Estrutura flexível (f &lt; 1 Hz). O vento produzirá amplificação dinâmica (C.A.D) considerável. É exigida análise pela NBR 6123 Capítulo 9.</p>
</div>
)}
</div>
</div>
</div>
+3 -2
View File
@@ -1,4 +1,5 @@
import React, { useRef, useState } from 'react';
import { usePersistentState } from '@/hooks/usePersistentState';
import React, { useRef } 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, FlaskConical, Download, FolderOpen, FileBox, FileJson, GraduationCap } from 'lucide-react';
@@ -28,7 +29,7 @@ const SettingsModule: React.FC = () => {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const logoInputRef = useRef<HTMLInputElement | null>(null);
const [importing, setImporting] = useState(false);
const [importing, setImporting] = usePersistentState('SettingsModule_importing', false);
const { companyLogo, setCompanyLogo } = useWindStore();
const exportSnapshot = () => {
+105 -33
View File
@@ -1,9 +1,11 @@
import React, { useMemo, useState } from 'react';
import { usePersistentState } from '@/hooks/usePersistentState';
import React, { useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Slider } from '@/components/ui/slider';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useWindStore } from '@/store/appStore';
import { useHydrationStore } from '@/store/hydrationStore';
import {
calculateShelterWind,
calculateShelterWindAllAngles,
@@ -19,7 +21,7 @@ import { EducationalManual } from '@/components/EducationalManual';
import { WindParametersSummary } from '@/components/WindParametersSummary';
import { SaveModuleDialog } from '@/components/SaveModuleDialog';
import { exportGenericToPDF, type GenericPDFSection } from '../lib/export-generic-pdf';
import { Eye, Box, Layers, ArrowUpRight, CheckCircle2 } from 'lucide-react';
import { Eye, Box, Layers, ArrowUpRight, CheckCircle2, Wind, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
const conditionLabels: Record<ShelterCondition, string> = {
@@ -31,17 +33,40 @@ const conditionLabels: Record<ShelterCondition, string> = {
const ShelterModule: React.FC = () => {
const { q } = useWindStore();
const [condition, setCondition] = useState<ShelterCondition>('cond1');
const [windAngle, setWindAngle] = useState<ShelterWindAngle>(0);
const [depth, setDepth] = useState(6);
const [width, setWidth] = useState(10);
const [height, setHeight] = useState(4.8);
const [theta, setTheta] = useState(5);
const [backClosure, setBackClosure] = useState(75);
const [backRange, setBackRange] = useState<[number, number]>([0, 75]);
const [leftClosure, setLeftClosure] = useState(100);
const [rightClosure, setRightClosure] = useState(100);
const [viewMode, setViewMode] = useState<'3d' | 'elevation'>('3d');
const [condition, setCondition] = usePersistentState<ShelterCondition>('ShelterModule_condition', 'cond1');
const [windAngle, setWindAngle] = usePersistentState<ShelterWindAngle>('ShelterModule_windAngle', 0);
const [depth, setDepth] = usePersistentState('ShelterModule_depth', 6);
const [width, setWidth] = usePersistentState('ShelterModule_width', 10);
const [height, setHeight] = usePersistentState('ShelterModule_height', 4.8);
const [theta, setTheta] = usePersistentState('ShelterModule_theta', 5);
const [numColumns, setNumColumns] = usePersistentState('ShelterModule_numColumns', 2);
const [backClosure, setBackClosure] = usePersistentState('ShelterModule_backClosure', 75);
const [backRange, setBackRange] = usePersistentState<[number, number]>('ShelterModule_backRange', [0, 75]);
const [leftClosure, setLeftClosure] = usePersistentState('ShelterModule_leftClosure', 100);
const [rightClosure, setRightClosure] = usePersistentState('ShelterModule_rightClosure', 100);
const [surfaceMass, setSurfaceMass] = usePersistentState('ShelterModule_surfaceMass', 15); // kg/m² para lona/telha leve
const [viewMode, setViewMode] = usePersistentState<'3d' | 'elevation' | 'airflow'>('ShelterModule_viewMode', '3d');
const pendingLoad = useHydrationStore((s) => s.pendingLoad);
const clearPendingLoad = useHydrationStore((s) => s.clearPendingLoad);
React.useEffect(() => {
if (pendingLoad && pendingLoad.module === 'shelter') {
const p = pendingLoad.inputs as any;
if (p.condition !== undefined) setCondition(p.condition);
if (p.windAngle !== undefined) setWindAngle(p.windAngle);
if (p.depth !== undefined) setDepth(p.depth);
if (p.width !== undefined) setWidth(p.width);
if (p.height !== undefined) setHeight(p.height);
if (p.theta !== undefined) setTheta(p.theta);
if (p.numColumns !== undefined) setNumColumns(p.numColumns);
if (p.backClosure !== undefined) setBackClosure(p.backClosure);
if (p.leftClosure !== undefined) setLeftClosure(p.leftClosure);
if (p.rightClosure !== undefined) setRightClosure(p.rightClosure);
if (p.surfaceMass !== undefined) setSurfaceMass(p.surfaceMass);
clearPendingLoad();
}
}, [pendingLoad, clearPendingLoad, setCondition, setWindAngle, setDepth, setWidth, setHeight, setTheta, setNumColumns, setBackClosure, setLeftClosure, setRightClosure, setSurfaceMass]);
// Determinação automática da posição da fresta/vão a partir dos cursores (base e topo)
const bottomGap = backRange[0];
@@ -90,17 +115,21 @@ const ShelterModule: React.FC = () => {
leftClosure,
rightClosure,
openingPos,
numColumns,
};
return {
result: calculateShelterWind({ ...input, windAngle }, q),
envelope: calculateShelterWindAllAngles(input, q),
};
}, [condition, q, depth, width, height, theta, backClosure, backRange, leftClosure, rightClosure, openingPos, windAngle]);
}, [condition, q, depth, width, height, theta, backClosure, backRange, leftClosure, rightClosure, openingPos, windAngle, numColumns]);
const effAreaRoof = depth * width;
const effAreaBack = (width * height) * (backClosure / 100);
const effAreaSideTotal = (depth * height) * ((leftClosure + rightClosure) / 100);
const gravityForce = (surfaceMass * effAreaRoof * 9.81) / 1000; // in kN
const isUpliftCritical = envelope.criticalUp.value > gravityForce;
const handleExportPDF = () => {
const angleLabel = windAngle === 0 ? '0° (Frontal)' : windAngle === 45 ? '45° (Oblíquo)' : '90° (Lateral)';
const sections: GenericPDFSection[] = [
@@ -122,12 +151,13 @@ const ShelterModule: React.FC = () => {
title: `Coeficientes Aerodinâmicos e Pressões Líquidas - Direção ${angleLabel}`,
type: 'grid',
gridItems: [
{ label: 'Ce Superior (Cobertura - Tab. 24)', value: result.cpeTop },
{ label: 'Ce Médio (Cobertura)', value: result.cpeTop },
{ label: 'Ce Borda (Pico Aerodinâmico)', value: result.cpeEdgeTop },
{ label: 'Cpi Inferior (Sobrepressão sob Telhado)', value: result.cpiBottom },
{ label: 'Cnf Resultante (Ce - Cpi)', value: result.cnfVertical },
{ label: 'Cnf Médio Resultante', value: result.cnfVertical },
{ label: 'Cnf Borda Resultante', value: result.cnfEdgeVertical },
{ label: 'Cf Parede de Fundo (Tab. 23 / 6)', value: result.cfBack },
{ label: 'Cf Paredes Laterais', value: result.cfSide },
{ label: 'Alívio por Fresta Superior', value: `${result.reliefPercentage}%` },
],
},
{
@@ -220,11 +250,11 @@ const ShelterModule: React.FC = () => {
`Máx: +${Math.max(envelope.res0.cpiBottom, envelope.res45.cpiBottom, envelope.res90.cpiBottom).toFixed(2)}`
],
[
'Cnf Vertical Líquido',
String(envelope.res0.cnfVertical),
String(envelope.res45.cnfVertical),
String(envelope.res90.cnfVertical),
`Crítico: ${Math.min(envelope.res0.cnfVertical, envelope.res45.cnfVertical, envelope.res90.cnfVertical)}`
'Cnf Vertical Médio | Borda',
`${envelope.res0.cnfVertical} | ${envelope.res0.cnfEdgeVertical}`,
`${envelope.res45.cnfVertical} | ${envelope.res45.cnfEdgeVertical}`,
`${envelope.res90.cnfVertical} | ${envelope.res90.cnfEdgeVertical}`,
`Crítico Borda: ${Math.min(envelope.res0.cnfEdgeVertical, envelope.res45.cnfEdgeVertical, envelope.res90.cnfEdgeVertical)}`
],
[
'Força Vertical Telhado [Fz ↑]',
@@ -282,6 +312,13 @@ const ShelterModule: React.FC = () => {
`${envelope.res90.lineLoads.beamLoad} kN/m`,
`${Math.max(envelope.res0.lineLoads.beamLoad, envelope.res45.lineLoads.beamLoad, envelope.res90.lineLoads.beamLoad).toFixed(2)} kN/m`
],
[
'Momento na Raiz da Viga',
`${envelope.res0.lineLoads.beamMoment} kN.m`,
`${envelope.res45.lineLoads.beamMoment} kN.m`,
`${envelope.res90.lineLoads.beamMoment} kN.m`,
`${Math.max(envelope.res0.lineLoads.beamMoment, envelope.res45.lineLoads.beamMoment, envelope.res90.lineLoads.beamMoment).toFixed(2)} kN.m`
],
],
},
{
@@ -298,14 +335,19 @@ const ShelterModule: React.FC = () => {
title: `Cargas Lineares para Dimensionamento (${angleLabel}) (kN/m)`,
type: 'grid',
gridItems: [
{ label: 'Carga Horizontal no Pilar Traseiro', value: `${result.lineLoads.columnLoad} kN/m` },
{ label: 'Carga Vertical na Viga em Balanço', value: `${result.lineLoads.beamLoad} kN/m` },
{ label: 'Carga Horiz. Pórtico Central', value: `${result.lineLoads.columnLoad} kN/m` },
{ label: 'Carga Horiz. Extremidade', value: `${result.lineLoads.columnLoadEdge} kN/m` },
{ label: 'Carga Vert. Média Viga Central', value: `${result.lineLoads.beamLoad} kN/m` },
{ label: 'Carga Vert. Média Extremidade', value: `${result.lineLoads.beamLoadEdge} kN/m` },
{ label: 'Momento Engaste Viga Central', value: `${result.lineLoads.beamMoment} kN.m` },
{ label: 'Momento Engaste Extremidade', value: `${result.lineLoads.beamMomentEdge} kN.m` },
],
},
{
title: 'Parecer Técnico do Regime Topológico e Aerodinâmico',
title: 'Parecer Técnico e Alertas Aerodinâmicos (Zonas de Borda)',
type: 'text',
content: `${result.statusText} Permeabilidade líquida da parede de fundo: ${100 - backClosure}%. Alívio de arrancamento obtido pela fresta superior: ${result.reliefPercentage}%. O relatório acima apresenta a tabela comparativa das 3 incidências de vento (0°, 45° e 90°) para garantir a verificação da envoltória mais desfavorável conforme a NBR 6123:2023. As cargas lineares calculadas (kN/m) podem ser aplicadas diretamente em softwares de pórtico plano como Ftool, SAP2000 ou CYPE 3D.`,
content: `${result.statusText} Permeabilidade líquida da parede de fundo: ${100 - backClosure}%. Alívio de arrancamento obtido pela fresta superior: ${result.reliefPercentage}%.
ATENÇÃO ESTRUTURAL (Efeito Asa de Avião): O coeficiente global médio subestima a força de arrancamento na ponta do balanço. Os cálculos acima consideram as zonas de borda com coeficientes extremos (Cnf Borda = ${result.cnfEdgeVertical}). O Momento Fletor na raiz da viga foi calculado considerando que o centro de pressão está deslocado para próximo à extremidade livre (L/3), o que resulta num momento de engaste substancialmente maior que o de uma carga uniformemente distribuída simples.`,
},
{
title: 'Guia de Aplicação dos Eixos e Resultantes no Croqui',
@@ -351,7 +393,7 @@ const ShelterModule: React.FC = () => {
</div>
<SaveModuleDialog
moduleType="shelter"
inputs={{ condition, depth, width, height, theta, backClosure, leftClosure, rightClosure, openingPos, windAngle }}
inputs={{ condition, depth, width, height, theta, backClosure, leftClosure, rightClosure, openingPos, windAngle, surfaceMass, numColumns }}
/>
</CardHeader>
<CardContent className="space-y-5">
@@ -476,6 +518,28 @@ const ShelterModule: React.FC = () => {
</div>
<Slider min={0} max={30} step={1} value={[theta]} onValueChange={(v) => setTheta(v[0])} />
</div>
<div className="space-y-2">
<div className="flex justify-between text-xs">
<span>Número de Pórticos (Pilares):</span>
<span className="font-mono font-medium">{numColumns} pórticos</span>
</div>
<Slider min={2} max={20} step={1} value={[numColumns]} onValueChange={(v) => setNumColumns(v[0])} />
</div>
<div className="space-y-2 pt-2 border-t">
<div className="flex justify-between text-xs">
<span>Massa Superficial (kg/m²):</span>
<span className="font-mono font-medium">{surfaceMass} kg/m²</span>
</div>
<Slider min={2} max={100} step={1} value={[surfaceMass]} onValueChange={(v) => setSurfaceMass(v[0])} />
<p className="text-[10px] text-muted-foreground mt-1">Lonas ~3kg/m², Telhas Metálicas ~10-15kg/m²</p>
{isUpliftCritical && (
<div className="mt-2 p-2 bg-destructive/10 border border-destructive/20 rounded text-xs text-destructive flex gap-2 items-start">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<p>Alerta de Arrancamento: Força () {envelope.criticalUp.value.toFixed(1)} kN &gt; Peso ({gravityForce.toFixed(1)} kN). Exige ancoragem/estaiamento.</p>
</div>
)}
</div>
</div>
</CardContent>
</Card>
@@ -502,6 +566,9 @@ const ShelterModule: React.FC = () => {
<Button size="sm" variant={viewMode === '3d' ? 'default' : 'ghost'} className="h-6 px-2.5 text-xs rounded-full" onClick={() => setViewMode('3d')}>
<Box className="size-3 mr-1" /> Vista 3D
</Button>
<Button size="sm" variant={viewMode === 'airflow' ? 'default' : 'ghost'} className="h-6 px-2.5 text-xs rounded-full" onClick={() => setViewMode('airflow')}>
<Wind className="size-3 mr-1" /> Fluxo
</Button>
<Button size="sm" variant={viewMode === 'elevation' ? 'default' : 'ghost'} className="h-6 px-2.5 text-xs rounded-full" onClick={() => setViewMode('elevation')}>
<Eye className="size-3 mr-1" /> Corte A-A
</Button>
@@ -512,7 +579,7 @@ const ShelterModule: React.FC = () => {
<EducationalManual type="shelter" params={{ condition, backClosure, openingPos, theta, windAngle }} />
</div>
{viewMode === '3d' ? (
{viewMode === '3d' || viewMode === 'airflow' ? (
<Shelter3D
condition={condition}
depth={depth}
@@ -525,6 +592,7 @@ const ShelterModule: React.FC = () => {
rightClosure={rightClosure}
openingPos={openingPos}
windAngle={windAngle}
numColumns={numColumns}
viewMode={viewMode}
/>
) : (
@@ -551,8 +619,8 @@ const ShelterModule: React.FC = () => {
<span className="font-mono font-bold text-foreground">{result.forces.fzUp.toFixed(2)} kN</span>
</div>
<div className="flex justify-between items-center text-xs text-muted-foreground">
<span>Coeficiente Cnf</span>
<span className="font-mono">{result.cnfVertical}</span>
<span>Cnf Médio | Cnf Borda</span>
<span className="font-mono">{result.cnfVertical} | <span className="text-destructive font-bold">{result.cnfEdgeVertical}</span></span>
</div>
<div className="flex justify-between items-center text-[11px] text-muted-foreground/80">
<span>Pressão (/m²)</span>
@@ -592,13 +660,17 @@ const ShelterModule: React.FC = () => {
</div>
</div>
<div className="flex justify-between items-center pb-2 border-b">
<span className="text-primary font-medium">Carga no Pilar (Pórtico)</span>
<span className="text-primary font-medium">Carga no Pilar (Central)</span>
<span className="font-mono font-bold text-emerald-600 dark:text-emerald-400">{result.lineLoads.columnLoad.toFixed(2)} kN/m</span>
</div>
<div className="flex justify-between items-center pb-3 border-b">
<span className="text-primary font-medium">Carga na Viga (Balanço)</span>
<div className="flex justify-between items-center pt-2">
<span className="text-primary font-medium">Carga Vertical Viga (Central)</span>
<span className="font-mono font-bold text-sky-600 dark:text-sky-400">{result.lineLoads.beamLoad.toFixed(2)} kN/m</span>
</div>
<div className="flex justify-between items-center pb-3 border-b text-xs text-muted-foreground">
<span>Momento Engaste (Asa)</span>
<span className="font-mono text-destructive font-bold">{result.lineLoads.beamMoment.toFixed(2)} kN.m</span>
</div>
<div className="pt-1 space-y-2">
<div className="font-bold text-xs text-primary flex items-center justify-between">
+25 -1
View File
@@ -4,6 +4,8 @@ import { Slider } from '@/components/ui/slider';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
import { Box, Wind } from 'lucide-react';
import { useWindStore } from '@/store/appStore';
import { calculateSign } from '@/lib/nbr-tables/table-23';
import Sign3DViewer from '@/components/three/Sign3D';
@@ -22,6 +24,7 @@ const SignModule: React.FC = () => {
const [alpha, setAlpha] = React.useState<90 | 50>(90);
const [hasEndPlates, setHasEndPlates] = React.useState(true);
const [groundClearance, setGroundClearance] = React.useState(0.5);
const [viewMode, setViewMode] = React.useState<'solid' | 'airflow'>('solid');
const result = useMemo(
() =>
@@ -156,7 +159,27 @@ const SignModule: React.FC = () => {
<Badge variant="secondary" className="bg-background/80 backdrop-blur-sm text-foreground border border-border/80 shadow-sm">F = {result.forceKN.toFixed(2)} kN</Badge>
<Badge variant="outline" className="bg-background/80 backdrop-blur-sm text-foreground border border-border/80 shadow-sm">Cf = {result.cf.toFixed(2)}</Badge>
</div>
<div className="absolute top-4 right-4 z-10">
<div className="absolute top-4 right-4 z-10 flex flex-wrap gap-2 pointer-events-auto justify-end">
<div className="flex bg-background/80 backdrop-blur-sm rounded-md p-1 shadow-sm border border-border">
<Button
variant={viewMode === 'solid' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-3 text-xs"
onClick={() => setViewMode('solid')}
>
<Box className="w-3.5 h-3.5 mr-1.5" />
Sólido
</Button>
<Button
variant={viewMode === 'airflow' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-3 text-xs"
onClick={() => setViewMode('airflow')}
>
<Wind className="w-3.5 h-3.5 mr-1.5" />
Fluxo
</Button>
</div>
<EducationalManual type="sign" params={{ length, height, alpha, hasEndPlates }} />
</div>
<PressureLegend />
@@ -169,6 +192,7 @@ const SignModule: React.FC = () => {
cf={result.cf}
forceKN={result.forceKN}
applicationPoint={result.applicationPoint}
viewMode={viewMode}
/>
</div>
+24 -3
View File
@@ -1,4 +1,5 @@
import React, { useMemo } from 'react';
import React, { useState, useMemo } from 'react';
import { Wind, Box } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Slider } from '@/components/ui/slider';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -19,9 +20,10 @@ const TowerModule: React.FC = () => {
const [barType, setBarType] = React.useState<TowerBarType>('flat');
const [baseWidth, setBaseWidth] = React.useState(3);
const [height, setHeight] = React.useState(30);
const [panels, setPanels] = React.useState(6);
const [phi, setPhi] = React.useState(0.3);
const [panels, setPanels] = React.useState(5);
const [phi, setPhi] = React.useState(0.2);
const [alphaWind, setAlphaWind] = React.useState<0 | 45 | 90>(0);
const [viewMode, setViewMode] = usePersistentState<'solid' | 'airflow'>('TowerModule_viewMode', 'solid');
const result = useMemo(() => {
const aFace = baseWidth * height;
@@ -178,6 +180,24 @@ const TowerModule: React.FC = () => {
<Badge variant="secondary" className="bg-background/80 text-foreground border border-border/50 backdrop-blur-sm shadow-sm">{section === 'square' ? 'Quadrada' : 'Triangular'}</Badge>
<Badge variant="secondary" className="bg-background/80 text-foreground border border-border/50 backdrop-blur-sm shadow-sm">α = {alphaWind}°</Badge>
</div>
<div className="absolute top-4 right-16 z-10">
<div className="flex bg-background/80 backdrop-blur-sm border border-border/80 rounded-md shadow-sm p-1">
<button
onClick={() => setViewMode('solid')}
className={`px-3 py-1 text-xs font-medium rounded-sm transition-colors ${viewMode === 'solid' ? 'bg-primary/20 text-primary' : 'text-muted-foreground hover:bg-muted/50'}`}
>
<Box className="w-3.5 h-3.5 inline-block mr-1.5" />
Sólido
</button>
<button
onClick={() => setViewMode('airflow')}
className={`px-3 py-1 text-xs font-medium rounded-sm transition-colors ${viewMode === 'airflow' ? 'bg-primary/20 text-primary' : 'text-muted-foreground hover:bg-muted/50'}`}
>
<Wind className="w-3.5 h-3.5 inline-block mr-1.5" />
Fluxo
</button>
</div>
</div>
<div className="absolute top-4 right-4 z-10">
<EducationalManual type="tower" params={{ section, alphaWind }} />
</div>
@@ -190,6 +210,7 @@ const TowerModule: React.FC = () => {
phi={phi}
alphaWind={alphaWind}
forceKN={result.forceKN}
viewMode={viewMode}
/>
</div>
+25 -1
View File
@@ -5,6 +5,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Box, Wind } from 'lucide-react';
import { useWindStore } from '@/store/appStore';
import { calculateVault, type VaultRegime } from '@/lib/modules/vault';
import Vault3DViewer from '@/components/three/Vault3D';
@@ -24,6 +26,7 @@ const VaultModule: React.FC = () => {
const [regime, setRegime] = React.useState<VaultRegime>('laminar-rough');
const [localCpi, setLocalCpi] = React.useState<number>(0);
const [viewDirection, setViewDirection] = React.useState<'perpendicular' | 'parallel'>('perpendicular');
const [viewMode, setViewMode] = React.useState<'solid' | 'airflow'>('solid');
const result = useMemo(() => calculateVault({ f: rise, l: span, b: length, vk, regime, cpi: localCpi }), [span, length, rise, vk, regime, localCpi]);
@@ -167,7 +170,27 @@ const VaultModule: React.FC = () => {
Cpi = {localCpi > 0 ? `+${localCpi.toFixed(2)}` : localCpi.toFixed(2)}
</Badge>
</div>
<div className="absolute top-4 right-4 z-10 flex gap-2">
<div className="absolute top-4 right-4 z-10 flex flex-wrap gap-2 pointer-events-auto justify-end">
<div className="flex bg-background/80 backdrop-blur-sm rounded-md p-1 shadow-sm border border-border">
<Button
variant={viewMode === 'solid' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-3 text-xs"
onClick={() => setViewMode('solid')}
>
<Box className="w-3.5 h-3.5 mr-1.5" />
Sólido
</Button>
<Button
variant={viewMode === 'airflow' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-3 text-xs"
onClick={() => setViewMode('airflow')}
>
<Wind className="w-3.5 h-3.5 mr-1.5" />
Fluxo
</Button>
</div>
<Tabs value={viewDirection} onValueChange={(v) => setViewDirection(v as 'perpendicular' | 'parallel')} className="bg-background/80 backdrop-blur-sm rounded-md shadow-sm border border-border">
<TabsList className="h-8 p-1">
<TabsTrigger value="perpendicular" className="text-xs px-3">Vento </TabsTrigger>
@@ -186,6 +209,7 @@ const VaultModule: React.FC = () => {
cpeProfile={{ ...result.windPerpendicular }}
cpeParallel={{ ...result.windParallel }}
viewDirection={viewDirection}
viewMode={viewMode}
/>
</div>
</div>
+8 -4
View File
@@ -16,6 +16,7 @@ export interface GlobalWindState {
largestDimension: number;
heightZ: number;
locality: string | null;
fviz: number; // Fator de vizinhança 3D (0.7 a 1.3)
// Saídas (Calculados)
structureClass: StructureClass;
@@ -42,6 +43,7 @@ export interface GlobalWindState {
setPermeabilityCase: (c: PermeabilityCase) => void;
setCpiRatio: (r: number) => void;
setCpiManual: (c: number) => void;
setFviz: (f: number) => void;
companyLogo: string | null;
setCompanyLogo: (logo: string | null) => void;
@@ -81,7 +83,7 @@ export const useWindStore = create<GlobalWindState>((set, get) => {
structureClass: calc.structClass,
s2: calc.s2,
vk: calc.vk,
q: calc.q,
q: calc.q * (state.fviz ?? 1.0),
cpi: calcCpi(state.permeabilityCase, state.cpiRatio, state.windAngle),
};
};
@@ -95,6 +97,7 @@ export const useWindStore = create<GlobalWindState>((set, get) => {
largestDimension: 30,
heightZ: 10,
locality: null,
fviz: 1.0,
structureClass: 'B',
s2: 1.06,
@@ -169,9 +172,10 @@ export const useWindStore = create<GlobalWindState>((set, get) => {
},
setCpiManual: (c) => {
set({ cpi: clampCpi(c) });
// Not calling updateCalculations to avoid overriding it immediately if permeabilityCase applies,
// but if the user overrides manually we might want to switch permeabilityCase to 'custom' ?
// Wait, there is no 'custom'. We just set the value.
},
setFviz: (f) => {
set({ fviz: f });
get().updateCalculations();
},
};
});
+5 -2
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { temporal } from 'zundo';
import { getWallCpeOfficial as getWallCpe, getRoofCpeOfficial as getRoofCpe } from '../lib/coefficients';
import { useWindStore } from './appStore';
import type { WallCoefficients, RoofCoefficients } from '../lib/coefficients';
@@ -40,7 +41,9 @@ const initHeight = 6;
const initPitch = 10;
const initAngle: 0 | 90 = 0;
export const useGalpaoStore = create<GalpaoState>((set, get) => ({
export const useGalpaoStore = create<GalpaoState>()(
temporal(
(set, get) => ({
template: 'standard',
extraHeight: 1.5,
skirtHeight: 4.5,
@@ -85,4 +88,4 @@ export const useGalpaoStore = create<GalpaoState>((set, get) => ({
set({ roofPitch: val });
get().updateCoefficients();
},
}));
})));
+34 -1
View File
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { VitePWA } from 'vite-plugin-pwa'
import path from 'path'
import fs from 'fs'
@@ -24,7 +25,39 @@ try {
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'logo_brainwind.svg'],
manifest: {
name: 'VentoApp - BrainWind',
short_name: 'VentoApp',
description: 'Cálculo de ação do vento NBR 6123:2023',
theme_color: '#020617', // slate-950
background_color: '#ffffff',
display: 'standalone',
icons: [
{
src: 'logo_brainwind.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'logo_brainwind.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,json}'],
maximumFileSizeToCacheInBytes: 5000000 // 5MB limit for 3D assets
}
})
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
+11
View File
@@ -0,0 +1,11 @@
[phases.setup]
nixPkgs = ["nodejs_23"]
[phases.install]
cmds = ["cd app && npm install"]
[phases.build]
cmds = ["cd app && npm run build"]
[start]
cmd = "cd app && npx --yes serve@latest -s dist"