🚀 Auto-deploy: BrainWind atualizado em 29/07/2026 10:44:07

This commit is contained in:
2026-07-29 10:44:07 +00:00
parent d4d4905798
commit fde885ca60
9 changed files with 719 additions and 27 deletions
+19 -9
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,7 +127,7 @@ 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>
);
})}
@@ -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>
</group>
{/* Texto Vento */}
<Text
position={[0, -1.5, 0]}
rotation={[Math.PI / 2, 0, 0]}
fontSize={0.4}
position={[radius + 2.5, wallHeight / 2 + 1.2, 0]}
rotation={[0, 0, 0]}
fontSize={1.2}
color="#3b82f6"
anchorX="center"
anchorY="middle"
anchorY="bottom"
>
Vento
</Text>
</group>
{/* 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,186 @@
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 {
p.currentCpe = -0.5; // Lateral / Sotavento
}
} 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 ? '#ef4444' : '#dc2626')
.lerp(new THREE.Color(isDark ? '#fcd34d' : '#f59e0b'), 1 - Math.min(1, p.currentCpe));
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj
.set(isDark ? '#7dd3fc' : '#38bdf8')
.lerp(new THREE.Color(isDark ? '#1e40af' : '#1d4ed8'), intensity);
} else {
colorObj.set(isDark ? '#f1f5f9' : '#64748b');
}
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>
);
}
+19 -3
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;
@@ -67,7 +71,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 +79,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>
</>
)}
@@ -206,6 +210,7 @@ export default function Sign3DViewer({
cf,
forceKN,
applicationPoint,
viewMode = 'solid',
}: Sign3DInput) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
@@ -222,6 +227,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 +242,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,187 @@
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 = 350;
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.12 + Math.random() * 0.08,
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 ? '#ef4444' : '#dc2626')
.lerp(new THREE.Color(isDark ? '#fcd34d' : '#f59e0b'), 1 - Math.min(1, p.currentCpe));
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj
.set(isDark ? '#7dd3fc' : '#38bdf8')
.lerp(new THREE.Color(isDark ? '#1e40af' : '#1d4ed8'), intensity);
} else {
colorObj.set(isDark ? '#f1f5f9' : '#64748b');
}
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>
);
}
+23 -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,13 +166,13 @@ 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>
{/* Rótulo de dimensões */}
@@ -186,7 +189,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 +205,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 +218,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,205 @@
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 {
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 ? '#ef4444' : '#dc2626')
.lerp(new THREE.Color(isDark ? '#fcd34d' : '#f59e0b'), 1 - Math.min(1, p.currentCpe));
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj
.set(isDark ? '#7dd3fc' : '#38bdf8')
.lerp(new THREE.Color(isDark ? '#1e40af' : '#1d4ed8'), intensity);
} else {
colorObj.set(isDark ? '#f1f5f9' : '#64748b');
}
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 -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>
+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>
+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>