🚀 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 SceneCanvas from '../SceneCanvas';
|
||||||
import FallbackDiagram from '../FallbackDiagram';
|
import FallbackDiagram from '../FallbackDiagram';
|
||||||
import { useCanvasTheme } from '@/lib/theme';
|
import { useCanvasTheme } from '@/lib/theme';
|
||||||
|
import { DomeAirflowSystem } from './DomeAirflowSystem';
|
||||||
|
|
||||||
export interface Dome3DInput {
|
export interface Dome3DInput {
|
||||||
diameter: number;
|
diameter: number;
|
||||||
@@ -14,6 +15,7 @@ export interface Dome3DInput {
|
|||||||
cpeBarlavento: number;
|
cpeBarlavento: number;
|
||||||
cpeTopo: number;
|
cpeTopo: number;
|
||||||
cpeLateral: number;
|
cpeLateral: number;
|
||||||
|
viewMode?: 'solid' | 'airflow';
|
||||||
}
|
}
|
||||||
|
|
||||||
function domeColor(cpe: number, cpi: number, isDark: boolean): THREE.Color {
|
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)}%)`);
|
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 theme = useCanvasTheme();
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
|
const isAirflow = viewMode === 'airflow';
|
||||||
const radius = diameter / 2;
|
const radius = diameter / 2;
|
||||||
const segments = 64;
|
const segments = 64;
|
||||||
|
|
||||||
@@ -64,7 +67,7 @@ function DomeModel({ diameter, rise, wallHeight, cpi, cpeBarlavento, cpeTopo, cp
|
|||||||
{/* Parede cilíndrica inferior */}
|
{/* Parede cilíndrica inferior */}
|
||||||
<mesh position={[0, wallHeight / 2, 0]} castShadow receiveShadow>
|
<mesh position={[0, wallHeight / 2, 0]} castShadow receiveShadow>
|
||||||
<cylinderGeometry args={[radius, radius, wallHeight, segments, 1, false]} />
|
<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>
|
</mesh>
|
||||||
|
|
||||||
{/* Detalhes de anéis metálicos nas bordas */}
|
{/* 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="attributes-normal" args={[new Float32Array(normals), 3]} />
|
||||||
<bufferAttribute attach="index" args={[new Uint16Array(indices), 1]} />
|
<bufferAttribute attach="index" args={[new Uint16Array(indices), 1]} />
|
||||||
</bufferGeometry>
|
</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>
|
</mesh>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -139,22 +142,24 @@ function DomeModel({ diameter, rise, wallHeight, cpi, cpeBarlavento, cpeTopo, cp
|
|||||||
<cylinderGeometry args={[0.1, 0.1, 1.2, 16]} />
|
<cylinderGeometry args={[0.1, 0.1, 1.2, 16]} />
|
||||||
<meshStandardMaterial color="#3b82f6" roughness={0.3} />
|
<meshStandardMaterial color="#3b82f6" roughness={0.3} />
|
||||||
</mesh>
|
</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>
|
</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 */}
|
{/* Texto informativo */}
|
||||||
<Text
|
<Text
|
||||||
position={[0, wallHeight + rise + 0.8, 0]}
|
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"}
|
color={isDark ? "#cbd5e1" : "#1a202c"}
|
||||||
anchorX="center"
|
anchorX="center"
|
||||||
anchorY="bottom"
|
anchorY="bottom"
|
||||||
@@ -168,6 +173,7 @@ function DomeModel({ diameter, rise, wallHeight, cpi, cpeBarlavento, cpeTopo, cp
|
|||||||
export default function Dome3DViewer(props: Dome3DInput) {
|
export default function Dome3DViewer(props: Dome3DInput) {
|
||||||
const theme = useCanvasTheme();
|
const theme = useCanvasTheme();
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
|
const { viewMode = 'solid' } = props;
|
||||||
|
|
||||||
const fallback = (
|
const fallback = (
|
||||||
<FallbackDiagram
|
<FallbackDiagram
|
||||||
@@ -181,6 +187,7 @@ export default function Dome3DViewer(props: Dome3DInput) {
|
|||||||
return (
|
return (
|
||||||
<SceneCanvas moduleId="cupula"
|
<SceneCanvas moduleId="cupula"
|
||||||
shadows
|
shadows
|
||||||
|
frameloop={viewMode === 'airflow' ? 'always' : 'demand'}
|
||||||
gl={{ preserveDrawingBuffer: true, antialias: true }}
|
gl={{ preserveDrawingBuffer: true, antialias: true }}
|
||||||
camera={{ position: [props.diameter * 1.5, (props.wallHeight + props.rise) * 1.5, props.diameter * 1.5], fov: 40 }}
|
camera={{ position: [props.diameter * 1.5, (props.wallHeight + props.rise) * 1.5, props.diameter * 1.5], fov: 40 }}
|
||||||
fallback={fallback}
|
fallback={fallback}
|
||||||
@@ -199,6 +206,9 @@ export default function Dome3DViewer(props: Dome3DInput) {
|
|||||||
shadow-camera-bottom={-maxDim}
|
shadow-camera-bottom={-maxDim}
|
||||||
/>
|
/>
|
||||||
<DomeModel {...props} />
|
<DomeModel {...props} />
|
||||||
|
{viewMode === 'airflow' && (
|
||||||
|
<DomeAirflowSystem diameter={props.diameter} rise={props.rise} wallHeight={props.wallHeight} />
|
||||||
|
)}
|
||||||
<Grid
|
<Grid
|
||||||
infiniteGrid
|
infiniteGrid
|
||||||
fadeDistance={maxDim * 5}
|
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 FallbackDiagram from '../FallbackDiagram';
|
||||||
import { WindArrow } from './WindArrow';
|
import { WindArrow } from './WindArrow';
|
||||||
import { useCanvasTheme } from '@/lib/theme';
|
import { useCanvasTheme } from '@/lib/theme';
|
||||||
|
import { SignAirflowSystem } from './SignAirflowSystem';
|
||||||
|
|
||||||
export interface Sign3DInput {
|
export interface Sign3DInput {
|
||||||
/** Comprimento ℓ (m) */
|
/** Comprimento ℓ (m) */
|
||||||
@@ -22,6 +23,7 @@ export interface Sign3DInput {
|
|||||||
forceKN: number;
|
forceKN: number;
|
||||||
/** Excentricidade e (m) */
|
/** Excentricidade e (m) */
|
||||||
applicationPoint: number;
|
applicationPoint: number;
|
||||||
|
viewMode?: 'solid' | 'airflow';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,9 +37,11 @@ function SignModel({
|
|||||||
alpha,
|
alpha,
|
||||||
cf,
|
cf,
|
||||||
applicationPoint,
|
applicationPoint,
|
||||||
|
viewMode = 'solid',
|
||||||
}: Sign3DInput) {
|
}: Sign3DInput) {
|
||||||
const theme = useCanvasTheme();
|
const theme = useCanvasTheme();
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
|
const isAirflow = viewMode === 'airflow';
|
||||||
const baseY = groundClearance;
|
const baseY = groundClearance;
|
||||||
const topY = baseY + height;
|
const topY = baseY + height;
|
||||||
const halfL = length / 2;
|
const halfL = length / 2;
|
||||||
@@ -67,7 +71,7 @@ function SignModel({
|
|||||||
{/* Placa principal */}
|
{/* Placa principal */}
|
||||||
<mesh position={[0, (baseY + topY) / 2, 0]} castShadow receiveShadow>
|
<mesh position={[0, (baseY + topY) / 2, 0]} castShadow receiveShadow>
|
||||||
<boxGeometry args={[0.1, height, length]} />
|
<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>
|
</mesh>
|
||||||
|
|
||||||
{/* Placas de extremidade (retornos aerodinâmicos nas pontas) */}
|
{/* Placas de extremidade (retornos aerodinâmicos nas pontas) */}
|
||||||
@@ -75,11 +79,11 @@ function SignModel({
|
|||||||
<>
|
<>
|
||||||
<mesh position={[0, (baseY + topY) / 2, halfL]} castShadow>
|
<mesh position={[0, (baseY + topY) / 2, halfL]} castShadow>
|
||||||
<boxGeometry args={[0.3, height * 0.95, 0.04]} />
|
<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>
|
||||||
<mesh position={[0, (baseY + topY) / 2, -halfL]} castShadow>
|
<mesh position={[0, (baseY + topY) / 2, -halfL]} castShadow>
|
||||||
<boxGeometry args={[0.3, height * 0.95, 0.04]} />
|
<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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -206,6 +210,7 @@ export default function Sign3DViewer({
|
|||||||
cf,
|
cf,
|
||||||
forceKN,
|
forceKN,
|
||||||
applicationPoint,
|
applicationPoint,
|
||||||
|
viewMode = 'solid',
|
||||||
}: Sign3DInput) {
|
}: Sign3DInput) {
|
||||||
const theme = useCanvasTheme();
|
const theme = useCanvasTheme();
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
@@ -222,6 +227,7 @@ export default function Sign3DViewer({
|
|||||||
return (
|
return (
|
||||||
<SceneCanvas moduleId="muro_placa"
|
<SceneCanvas moduleId="muro_placa"
|
||||||
shadows
|
shadows
|
||||||
|
frameloop={viewMode === 'airflow' ? 'always' : 'demand'}
|
||||||
gl={{ preserveDrawingBuffer: true, antialias: true }}
|
gl={{ preserveDrawingBuffer: true, antialias: true }}
|
||||||
camera={{ position: [length * 1.3, height * 1.5, length * 1.3], fov: 40 }}
|
camera={{ position: [length * 1.3, height * 1.5, length * 1.3], fov: 40 }}
|
||||||
fallback={fallback}
|
fallback={fallback}
|
||||||
@@ -236,7 +242,17 @@ export default function Sign3DViewer({
|
|||||||
cf={cf}
|
cf={cf}
|
||||||
forceKN={forceKN}
|
forceKN={forceKN}
|
||||||
applicationPoint={applicationPoint}
|
applicationPoint={applicationPoint}
|
||||||
|
viewMode={viewMode}
|
||||||
/>
|
/>
|
||||||
|
{viewMode === 'airflow' && (
|
||||||
|
<SignAirflowSystem
|
||||||
|
length={length}
|
||||||
|
height={height}
|
||||||
|
groundClearance={groundClearance}
|
||||||
|
alpha={alpha}
|
||||||
|
cf={cf}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Grid
|
<Grid
|
||||||
infiniteGrid
|
infiniteGrid
|
||||||
fadeDistance={maxDim * 5}
|
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 SceneCanvas from '../SceneCanvas';
|
||||||
import FallbackDiagram from '../FallbackDiagram';
|
import FallbackDiagram from '../FallbackDiagram';
|
||||||
import { useCanvasTheme } from '@/lib/theme';
|
import { useCanvasTheme } from '@/lib/theme';
|
||||||
|
import { VaultAirflowSystem } from './VaultAirflowSystem';
|
||||||
|
|
||||||
export interface Vault3DInput {
|
export interface Vault3DInput {
|
||||||
span: number;
|
span: number;
|
||||||
@@ -14,6 +15,7 @@ export interface Vault3DInput {
|
|||||||
cpeProfile: Record<string, number>;
|
cpeProfile: Record<string, number>;
|
||||||
cpeParallel?: Record<string, number>;
|
cpeParallel?: Record<string, number>;
|
||||||
viewDirection?: 'perpendicular' | 'parallel';
|
viewDirection?: 'perpendicular' | 'parallel';
|
||||||
|
viewMode?: 'solid' | 'airflow';
|
||||||
}
|
}
|
||||||
|
|
||||||
function vaultColor(cpe: number, cpi: number, isDark: boolean): THREE.Color {
|
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)}%)`);
|
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 theme = useCanvasTheme();
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
|
const isAirflow = viewMode === 'airflow';
|
||||||
const segments = 64;
|
const segments = 64;
|
||||||
|
|
||||||
const points = useMemo(() => {
|
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="attributes-position" args={[new Float32Array(verts), 3]} />
|
||||||
<bufferAttribute attach="index" args={[new Uint16Array(indices), 1]} />
|
<bufferAttribute attach="index" args={[new Uint16Array(indices), 1]} />
|
||||||
</bufferGeometry>
|
</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>
|
</mesh>
|
||||||
<Text
|
<Text
|
||||||
position={[midPt.x, midPt.y + 0.5, length / 2]}
|
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="attributes-position" args={[new Float32Array(verts), 3]} />
|
||||||
<bufferAttribute attach="index" args={[new Uint16Array(indices), 1]} />
|
<bufferAttribute attach="index" args={[new Uint16Array(indices), 1]} />
|
||||||
</bufferGeometry>
|
</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>
|
</mesh>
|
||||||
<Text
|
<Text
|
||||||
position={[0, rise + 0.5, (zStart + zEnd) / 2]}
|
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) */}
|
{/* Tímpano Traseiro (Z = 0) */}
|
||||||
<mesh position={[0, 0, 0]} castShadow receiveShadow>
|
<mesh position={[0, 0, 0]} castShadow receiveShadow>
|
||||||
<shapeGeometry args={[archShape]} />
|
<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>
|
</mesh>
|
||||||
|
|
||||||
{/* Tímpano Frontal (Z = length) */}
|
{/* Tímpano Frontal (Z = length) */}
|
||||||
<mesh position={[0, 0, length]} castShadow receiveShadow>
|
<mesh position={[0, 0, length]} castShadow receiveShadow>
|
||||||
<shapeGeometry args={[archShape]} />
|
<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>
|
</mesh>
|
||||||
|
|
||||||
{/* Rótulo de dimensões */}
|
{/* 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 theme = useCanvasTheme();
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
@@ -202,6 +205,7 @@ export default function Vault3DViewer({ span, length, rise, cpi, cpeProfile, cpe
|
|||||||
return (
|
return (
|
||||||
<SceneCanvas moduleId="abobada"
|
<SceneCanvas moduleId="abobada"
|
||||||
shadows
|
shadows
|
||||||
|
frameloop={viewMode === 'airflow' ? 'always' : 'demand'}
|
||||||
gl={{ preserveDrawingBuffer: true, antialias: true }}
|
gl={{ preserveDrawingBuffer: true, antialias: true }}
|
||||||
camera={{ position: [span * 1.2, rise * 1.5, length * 1.2], fov: 40 }}
|
camera={{ position: [span * 1.2, rise * 1.5, length * 1.2], fov: 40 }}
|
||||||
fallback={fallback}
|
fallback={fallback}
|
||||||
@@ -214,7 +218,19 @@ export default function Vault3DViewer({ span, length, rise, cpi, cpeProfile, cpe
|
|||||||
shadow-mapSize-width={1024}
|
shadow-mapSize-width={1024}
|
||||||
shadow-mapSize-height={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
|
<Grid
|
||||||
infiniteGrid
|
infiniteGrid
|
||||||
fadeDistance={maxDimension * 5}
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Box, Wind } from 'lucide-react';
|
||||||
import { useWindStore } from '@/store/appStore';
|
import { useWindStore } from '@/store/appStore';
|
||||||
import { CpiSettingsCard } from '@/components/CpiSettingsCard';
|
import { CpiSettingsCard } from '@/components/CpiSettingsCard';
|
||||||
import { calculateDome, type DomeType } from '@/lib/modules/dome';
|
import { calculateDome, type DomeType } from '@/lib/modules/dome';
|
||||||
@@ -22,6 +24,7 @@ const DomeModule: React.FC = () => {
|
|||||||
const [rise, setRise] = React.useState(5);
|
const [rise, setRise] = React.useState(5);
|
||||||
const [wallHeight, setWallHeight] = React.useState(8);
|
const [wallHeight, setWallHeight] = React.useState(8);
|
||||||
const [type, setType] = React.useState<DomeType>('on-ground');
|
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]);
|
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)}
|
Cpi = {cpi.toFixed(2)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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 }} />
|
<EducationalManual type="dome" params={{ rise, diameter, cpi }} />
|
||||||
</div>
|
</div>
|
||||||
<PressureLegend />
|
<PressureLegend />
|
||||||
@@ -171,6 +194,7 @@ const DomeModule: React.FC = () => {
|
|||||||
cpeBarlavento={result.cpeBarlavento}
|
cpeBarlavento={result.cpeBarlavento}
|
||||||
cpeTopo={result.cpeTopo}
|
cpeTopo={result.cpeTopo}
|
||||||
cpeLateral={result.cpeLateral}
|
cpeLateral={result.cpeLateral}
|
||||||
|
viewMode={viewMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { Slider } from '@/components/ui/slider';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Box, Wind } from 'lucide-react';
|
||||||
import { useWindStore } from '@/store/appStore';
|
import { useWindStore } from '@/store/appStore';
|
||||||
import { calculateSign } from '@/lib/nbr-tables/table-23';
|
import { calculateSign } from '@/lib/nbr-tables/table-23';
|
||||||
import Sign3DViewer from '@/components/three/Sign3D';
|
import Sign3DViewer from '@/components/three/Sign3D';
|
||||||
@@ -22,6 +24,7 @@ const SignModule: React.FC = () => {
|
|||||||
const [alpha, setAlpha] = React.useState<90 | 50>(90);
|
const [alpha, setAlpha] = React.useState<90 | 50>(90);
|
||||||
const [hasEndPlates, setHasEndPlates] = React.useState(true);
|
const [hasEndPlates, setHasEndPlates] = React.useState(true);
|
||||||
const [groundClearance, setGroundClearance] = React.useState(0.5);
|
const [groundClearance, setGroundClearance] = React.useState(0.5);
|
||||||
|
const [viewMode, setViewMode] = React.useState<'solid' | 'airflow'>('solid');
|
||||||
|
|
||||||
const result = useMemo(
|
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="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>
|
<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>
|
||||||
<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 }} />
|
<EducationalManual type="sign" params={{ length, height, alpha, hasEndPlates }} />
|
||||||
</div>
|
</div>
|
||||||
<PressureLegend />
|
<PressureLegend />
|
||||||
@@ -169,6 +192,7 @@ const SignModule: React.FC = () => {
|
|||||||
cf={result.cf}
|
cf={result.cf}
|
||||||
forceKN={result.forceKN}
|
forceKN={result.forceKN}
|
||||||
applicationPoint={result.applicationPoint}
|
applicationPoint={result.applicationPoint}
|
||||||
|
viewMode={viewMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
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 { useWindStore } from '@/store/appStore';
|
||||||
import { calculateVault, type VaultRegime } from '@/lib/modules/vault';
|
import { calculateVault, type VaultRegime } from '@/lib/modules/vault';
|
||||||
import Vault3DViewer from '@/components/three/Vault3D';
|
import Vault3DViewer from '@/components/three/Vault3D';
|
||||||
@@ -24,6 +26,7 @@ const VaultModule: React.FC = () => {
|
|||||||
const [regime, setRegime] = React.useState<VaultRegime>('laminar-rough');
|
const [regime, setRegime] = React.useState<VaultRegime>('laminar-rough');
|
||||||
const [localCpi, setLocalCpi] = React.useState<number>(0);
|
const [localCpi, setLocalCpi] = React.useState<number>(0);
|
||||||
const [viewDirection, setViewDirection] = React.useState<'perpendicular' | 'parallel'>('perpendicular');
|
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]);
|
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)}
|
Cpi = {localCpi > 0 ? `+${localCpi.toFixed(2)}` : localCpi.toFixed(2)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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">
|
<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">
|
<TabsList className="h-8 p-1">
|
||||||
<TabsTrigger value="perpendicular" className="text-xs px-3">Vento ⊥</TabsTrigger>
|
<TabsTrigger value="perpendicular" className="text-xs px-3">Vento ⊥</TabsTrigger>
|
||||||
@@ -186,6 +209,7 @@ const VaultModule: React.FC = () => {
|
|||||||
cpeProfile={{ ...result.windPerpendicular }}
|
cpeProfile={{ ...result.windPerpendicular }}
|
||||||
cpeParallel={{ ...result.windParallel }}
|
cpeParallel={{ ...result.windParallel }}
|
||||||
viewDirection={viewDirection}
|
viewDirection={viewDirection}
|
||||||
|
viewMode={viewMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user