diff --git a/app/src/App.tsx b/app/src/App.tsx
index 5434d55..e6c705a 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -8,6 +8,7 @@ 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';
@@ -15,7 +16,7 @@ import CertificatePage from './pages/CertificatePage';
import {
Home, Settings, Menu, Cylinder, Church, CircleDot,
Square, Layers, BarChart3, Activity, Warehouse,
- Settings2, Sun, Moon, Frame, FolderOpen, BookOpen, MoreVertical, Info, ShieldCheck
+ Settings2, Sun, Moon, Frame, FolderOpen, BookOpen, MoreVertical, Info, ShieldCheck, Calculator
} from 'lucide-react';
import {
DropdownMenu,
@@ -95,6 +96,7 @@ function AppLayout({ children }: { children: React.ReactNode }) {
{ path: '/torre', icon: , labelKey: 'nav_tower' as const },
{ path: '/piperack', icon: , labelKey: 'nav_piperack' as const },
{ path: '/dinamica', icon: , labelKey: 'nav_dynamics' as const },
+ { path: '/calc-ca', icon: , labelKey: 'nav_calc_ca' as const },
];
return (
@@ -356,6 +358,7 @@ function HomeMock() {
{ 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: '/calc-ca', icon: Calculator, label: 'Calculadora de C_a', desc: 'Coeficiente de arrasto (Fig. 4 e 5).' },
];
return (
@@ -421,6 +424,7 @@ function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/app/src/components/DragCoefficientChart.tsx b/app/src/components/DragCoefficientChart.tsx
new file mode 100644
index 0000000..7df0a8a
--- /dev/null
+++ b/app/src/components/DragCoefficientChart.tsx
@@ -0,0 +1,115 @@
+import { useMemo } from 'react';
+
+interface DragCoefficientChartProps {
+ l1: number;
+ l2: number;
+ h: number;
+ caLow: number;
+ caHigh: number;
+}
+
+export function DragCoefficientChart({ l1, l2, h, caLow, caHigh }: DragCoefficientChartProps) {
+ const hL1 = h / l1;
+ const l1L2 = l1 / l2;
+
+ // The chart in the user's image is a log-log scale.
+ // X axis: L1/L2 from 4.0 to 0.2 (inverted in the image, usually right to left or log scale).
+ // Y axis: H/L1 from 0.25 to 10 (log scale).
+ // For a simplified visual representation, we will map these to linear or semi-log pixel coordinates.
+
+ const width = 400;
+ const height = 400;
+ const padding = 40;
+
+ const getCoordinates = (xVal: number, yVal: number) => {
+ // Map l1/l2 (0.2 to 4) to X axis (padding to width - padding)
+ // using log scale
+ const minX = 0.2;
+ const maxX = 4.0;
+ const logMinX = Math.log10(minX);
+ const logMaxX = Math.log10(maxX);
+ const logX = Math.log10(Math.max(minX, Math.min(xVal, maxX)));
+ // the image has 4 on the left and 0.2 on the right, let's follow the standard 0.2 -> 4.0 (left to right)
+ const px = padding + ((logX - logMinX) / (logMaxX - logMinX)) * (width - 2 * padding);
+
+ // Map h/l1 (0.25 to 8) to Y axis (height - padding to padding)
+ const minY = 0.25;
+ const maxY = 8.0;
+ const logMinY = Math.log10(minY);
+ const logMaxY = Math.log10(maxY);
+ const logY = Math.log10(Math.max(minY, Math.min(yVal, maxY)));
+ const py = height - padding - ((logY - logMinY) / (logMaxY - logMinY)) * (height - 2 * padding);
+
+ return { px, py };
+ };
+
+ const point = getCoordinates(l1L2, hL1);
+
+ // Generate some background curves just for aesthetic resemblance to the NBR abacus
+ const curves = useMemo(() => {
+ const paths = [];
+ for (let i = 0.5; i <= 2.2; i += 0.2) {
+ // Fake isoline generation
+ const points = [];
+ for (let x = 0.2; x <= 4.0; x += 0.2) {
+ // y = (i * x) + something non-linear just to make curves
+ const y = Math.max(0.25, Math.min(8, (i * 2) / (x + 0.5)));
+ points.push(getCoordinates(x, y));
+ }
+
+ const d = points.reduce((acc, pt, idx) => {
+ return idx === 0 ? `M ${pt.px} ${pt.py}` : `${acc} L ${pt.px} ${pt.py}`;
+ }, '');
+ paths.push(d);
+ }
+ return paths;
+ }, []);
+
+ return (
+
+
Posição no Ábaco (Visualização Simplificada)
+
+
+ );
+}
diff --git a/app/src/components/WindFlowGrid2D.tsx b/app/src/components/WindFlowGrid2D.tsx
index d8699c7..bc07b13 100644
--- a/app/src/components/WindFlowGrid2D.tsx
+++ b/app/src/components/WindFlowGrid2D.tsx
@@ -769,6 +769,29 @@ export function WindFlowGrid2D({ type }: WindFlowGrid2DProps) {
);
+ case 'calc_ca':
+ return (
+
+ Escoamento sobre edificação retangular
+
+
+ O vento incide perpendicularmente à face L1, separando-se nas quinas e criando zonas de esteira nas laterais (L2) e a sotavento. A relação entre essas faces define o arrasto global.
+
+
+ );
+
default:
// Caso padrão dinâmico geral (Módulos dinâmicos simples)
return (
diff --git a/app/src/components/three/ResizableBox3D.tsx b/app/src/components/three/ResizableBox3D.tsx
new file mode 100644
index 0000000..d93d63c
--- /dev/null
+++ b/app/src/components/three/ResizableBox3D.tsx
@@ -0,0 +1,120 @@
+import { useRef, useMemo } from 'react';
+import { useFrame } from '@react-three/fiber';
+import { Edges } from '@react-three/drei';
+import * as THREE from 'three';
+
+interface ResizableBox3DProps {
+ l1: number;
+ l2: number;
+ h: number;
+}
+
+function WindParticles({ l1, l2, h }: { l1: number, l2: number, h: number }) {
+ const count = 40;
+ const meshRef = useRef(null);
+
+ // Create dummy object to compute matrix for each instance
+ const dummy = useMemo(() => new THREE.Object3D(), []);
+
+ // Generate random starting positions
+ const particles = useMemo(() => {
+ const temp = [];
+ for (let i = 0; i < count; i++) {
+ temp.push({
+ x: (Math.random() - 0.5) * (l1 * 3), // Spread wider than the building
+ y: Math.random() * (h * 1.5), // Spread higher than the building
+ z: (Math.random() * 40) + l2 / 2 + 5, // Start in front of the building
+ speed: 10 + Math.random() * 15, // Random speeds
+ offset: Math.random() * 100 // Phase offset
+ });
+ }
+ return temp;
+ }, [l1, l2, h, count]);
+
+ useFrame((_state, delta) => {
+ if (!meshRef.current) return;
+
+ particles.forEach((particle, i) => {
+ // Move particle along Z axis (wind direction)
+ particle.z -= particle.speed * delta;
+
+ // If particle passed the building, reset it to the front
+ if (particle.z < -l2 - 10) {
+ particle.z = (Math.random() * 20) + l2 / 2 + 10;
+ particle.x = (Math.random() - 0.5) * (l1 * 3);
+ particle.y = Math.random() * (h * 1.5);
+ }
+
+ // Simple avoidance / turbulence effect around the building
+ let currentX = particle.x;
+ let currentY = particle.y;
+
+ // If particle is close to the front face, push it out
+ if (particle.z > -l2/2 && particle.z < l2/2 + 2) {
+ const isHittingFront = Math.abs(particle.x) < l1/2 + 0.5 && particle.y < h + 0.5;
+ if (isHittingFront) {
+ // Push to the sides or up
+ if (Math.abs(particle.x) > (l1/2) * 0.7) {
+ currentX += Math.sign(particle.x) * (20 * delta);
+ } else {
+ currentY += 20 * delta;
+ }
+ }
+ }
+
+ dummy.position.set(currentX, currentY, particle.z);
+ dummy.scale.set(0.05, 0.05, Math.max(0.5, particle.speed * 0.1)); // Stretch by speed
+ dummy.updateMatrix();
+ meshRef.current!.setMatrixAt(i, dummy.matrix);
+ });
+
+ meshRef.current.instanceMatrix.needsUpdate = true;
+ });
+
+ return (
+
+
+
+
+ );
+}
+
+export function ResizableBox3D({ l1, l2, h }: ResizableBox3DProps) {
+ const meshRef = useRef(null);
+
+ // Create target scale and position based on inputs
+ const targetScale = useMemo(() => new THREE.Vector3(l1, h, l2), [l1, l2, h]);
+
+ useFrame((_state, delta) => {
+ if (meshRef.current) {
+ // Smoothly animate the scale
+ meshRef.current.scale.lerp(targetScale, delta * 5);
+ // Smoothly animate the position to keep the base on the ground
+ meshRef.current.position.lerp(
+ new THREE.Vector3(0, meshRef.current.scale.y / 2, 0),
+ delta * 5
+ );
+ }
+ });
+
+ return (
+
+ {/* Ground plane for reference */}
+
+
+
+
+
+
+ {/* The Animated Box */}
+
+
+
+
+
+
+ {/* Wind Flow Animation */}
+
+
+ );
+}
diff --git a/app/src/data/manuals/calc_ca.ts b/app/src/data/manuals/calc_ca.ts
new file mode 100644
index 0000000..c44bb4b
--- /dev/null
+++ b/app/src/data/manuals/calc_ca.ts
@@ -0,0 +1,16 @@
+import React from 'react';
+
+export const calcCaManual = {
+ title: 'Calculadora de Coeficiente de Arrasto (Ca)',
+ intro: 'Entenda como o vento incide sobre uma edificação retangular e gera a força de arrasto global.',
+ sections: [
+ {
+ title: 'Coeficiente de Arrasto (Ca)',
+ content: React.createElement('p', null, 'O coeficiente de arrasto é utilizado para calcular a força global do vento sobre a estrutura inteira. Ele leva em conta as proporções da edificação (L1/L2 e H/L1) e a turbulência do vento.'),
+ },
+ {
+ title: 'Baixa vs. Alta Turbulência',
+ content: React.createElement('p', null, 'Edificações em locais planos (baixa turbulência) sofrem um padrão de escoamento diferente de edificações em centros urbanos (alta turbulência), alterando significativamente o valor de Ca.'),
+ }
+ ],
+};
diff --git a/app/src/data/manuals/index.ts b/app/src/data/manuals/index.ts
index 4f9fc4a..cc56a3d 100644
--- a/app/src/data/manuals/index.ts
+++ b/app/src/data/manuals/index.ts
@@ -9,6 +9,7 @@ import { barManual } from './bar';
import { dynamicsManual } from './dynamics';
import { isolatedRoofManual } from './isolated-roof';
import { piperackManual } from './piperack';
+import { calcCaManual } from './calc_ca';
export const manuals: Record = {
bridge: bridgeManual,
@@ -22,4 +23,5 @@ export const manuals: Record = {
dynamics: dynamicsManual,
'isolated-roof': isolatedRoofManual,
piperack: piperackManual,
+ calc_ca: calcCaManual,
};
diff --git a/app/src/lib/i18n.ts b/app/src/lib/i18n.ts
index b397e62..48223b9 100644
--- a/app/src/lib/i18n.ts
+++ b/app/src/lib/i18n.ts
@@ -60,6 +60,7 @@ const translations: Dict = {
nav_tower: { 'pt-BR': 'Torres', 'en-US': 'Towers' },
nav_piperack: { 'pt-BR': 'Pipe-rack', 'en-US': 'Pipe-rack' },
nav_dynamics: { 'pt-BR': 'Dinâmica + Vórtices', 'en-US': 'Dynamics + Vortex' },
+ nav_calc_ca: { 'pt-BR': 'Calc_Ca', 'en-US': 'Calc_Ca' },
nav_settings: { 'pt-BR': 'Preferências', 'en-US': 'Preferences' },
nav_collapse: { 'pt-BR': 'Recolher sidebar', 'en-US': 'Collapse sidebar' },
nav_expand: { 'pt-BR': 'Expandir sidebar', 'en-US': 'Expand sidebar' },
diff --git a/app/src/pages/CalcCaModule.tsx b/app/src/pages/CalcCaModule.tsx
new file mode 100644
index 0000000..ed3d78c
--- /dev/null
+++ b/app/src/pages/CalcCaModule.tsx
@@ -0,0 +1,153 @@
+import { useState } from 'react';
+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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+
+export default function CalcCaModule() {
+ const { t } = useI18n();
+ const [l1, setL1] = useState(20);
+ const [l2, setL2] = useState(40);
+ const [h, setH] = useState(10);
+
+ // Clamp inputs to avoid Infinity or negative values breaking the UI
+ const safeL1 = Math.max(0.1, l1 || 1);
+ const safeL2 = Math.max(0.1, l2 || 1);
+ const safeH = Math.max(0.1, h || 1);
+
+ const caLow = getDragCoefficient(safeL1, safeL2, safeH, 'low');
+ const caHigh = getDragCoefficient(safeL1, safeL2, safeH, 'high');
+
+ return (
+
+
+
+
+
+
+
{t('nav_calc_ca')}
+
+ Cálculo rápido de Coeficiente de Arrasto (Ca) - NBR 6123:2023, Fig. 4 e 5.
+
+
+
+
+
+ {/* Left Column: Inputs & Results */}
+
+
+
+ Entrada de Dados
+ Dimensões da edificação retangular
+
+
+