🚀 Auto-deploy: BrainWind atualizado em 29/07/2026 10:44:07
This commit is contained in:
@@ -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>
|
||||
<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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user